feat(cli): HF_TOOL_CONTEXT artifact events and project lock for agent harnesses (#5520)

* feat(cli): HF_TOOL_CONTEXT artifact events and project lock for agent harnesses

* fix(cli): serialize stale project lock takeover

Use a short-lived break lock so only one waiter can remove a dead
holder's lock. Cover the contention path with six real processes.

* fix(cli): hold snapshot lock through finalization

Keep the project lock while contact sheets, artifact events, and frame
descriptions read captured files so another snapshot cannot replace them.

* fix(cli): recover a project lock after the breaker crashes

A killed takeover left project.lock.break in place, so later commands
waited out the timeout even though both holders were dead. Reclaim that
file only when its recorded process is dead, and compare artifact paths
after the platform resolves them.

* fix(cli): accept a killed breaker process with no signal name

Windows ends the process without reporting SIGKILL, so the regression
was failing before it checked that the leftover breaker can be reclaimed.

* fix(cli): leave a killed project-lock breaker in place

Reclaiming a dead breaker unlinks a name another process may just have
created, so two commands can hold the lock. The timeout names that file
and its pid and says to delete it to continue.
This commit is contained in:
Xuanru Li
2026-10-13 03:55:36 +00:00
committed by GitHub
parent b75fa6d996
commit 1b60a40eff
9 changed files with 753 additions and 142 deletions
+90
View File
@@ -0,0 +1,90 @@
---
title: "Running the CLI from an agent harness"
description: "How an agent harness declares what it supports through HF_TOOL_CONTEXT, and what the CLI reports back."
---
An agent harness (Claude Code, Codex, your own) runs `hyperframes` commands on the agent's behalf.
`HF_TOOL_CONTEXT` lets the harness tell one invocation what it supports, so the CLI can do more for
it: report the images it produced, or wait its turn instead of racing another command in the same
project. Without the variable, every command behaves exactly as it does in a terminal.
```bash
export HF_TOOL_CONTEXT='{"version":1,"capabilities":{
"events": {"transport": {"type": "ndjson-file", "path": "/tmp/call-42/events.ndjson"}},
"project-lock": {"waitMs": 90000}
}}'
npx hyperframes snapshot --at 1,3
```
The harness sets the variable for one tool call, reads what the call produced, and removes its files.
## The declaration
| Field | Value |
| --- | --- |
| `version` | `1`. Any other version, or a value that is not JSON, is treated as no declaration. |
| `capabilities` | An object keyed by capability name. Each value configures that capability. |
The CLI ignores capability names it does not know, and a known capability whose value is invalid, so
a harness can declare capabilities newer than the CLI it runs. The variable carries data only: no
capability runs code the harness names.
## `events`: what a command produced
```json
{ "transport": { "type": "ndjson-file", "path": "/absolute/events.ndjson" } }
```
The CLI appends one JSON object per line to `path`. It never creates the parent directory, and a
write that fails prints a warning without failing the command. Read the file after the command
exits; several commands in one shell call append to the same file.
Each line is an event:
```json
{
"version": 1,
"type": "artifact.created",
"command": "snapshot",
"artifact": { "role": "contact-sheet", "path": "/project/snapshots/contact-sheet.jpg", "mediaType": "image/jpeg" }
}
```
| Command | `role` | What it is |
| --- | --- | --- |
| `snapshot` | `frame` | Each captured frame (PNG) |
| `snapshot` | `contact-sheet` | Each page of the grid of frames made for review (JPEG) |
| `check` | `frame` | Each frame the browser check captured |
| `check` | `finding` | Each crop around a finding |
Paths are absolute and stay where the command saved them. What to do with an artifact is the
harness's decision: attach the contact sheet to the tool result, or ignore it.
Ignore event types and roles you do not know; new ones are added without a version change.
## `project-lock`: one browser command at a time
```json
{ "waitMs": 90000 }
```
`check` and `snapshot` take a lock on the project before they start a browser, and release it when
they finish. A second command waits up to `waitMs` milliseconds for the lock, then fails, naming the
command and process that hold it. A lock whose process has exited is taken over. A
`project.lock.break` left by a killed process is not: the timeout names that file and its process
id, and says to delete it to continue. The lock is the file `.hyperframes/project.lock`.
Choose `waitMs` below your tool call's own timeout, so a wait ends in the CLI's message rather than
the harness killing the call.
## Hooks that run code
Running your own command before or after a CLI command is not a `HF_TOOL_CONTEXT` capability and
never will be: anything that sets an environment variable could then run code. Such hooks, when they
exist, come only from configuration the person controls.
## Conformance
`packages/cli/src/utils/toolContext.test.ts` and `projectLock.test.ts` pin the parsing, event and
lock behavior above. A harness is correct when it sets the variable for each call it wants the
behavior on, reads `events` only after the call exits, and works the same when the CLI ignores a
capability.