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.
+1
View File
@@ -960,6 +960,7 @@
"developers/cli",
"packages/cli",
"developers/catalog-search",
"developers/harness-tool-context",
"packages/lint"
]
},
+31 -1
View File
@@ -1,5 +1,7 @@
import { runCommand } from "citty";
import { readFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const trackCheckReport = vi.fn();
@@ -530,6 +532,34 @@ it("threads --no-proxy into the browser check options", async () => {
expect(runPipeline).toHaveBeenCalledWith(PROJECT, expect.objectContaining({ autoProxy: false }));
});
it("waits for the project lock declared in HF_TOOL_CONTEXT before running the pipeline", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-check-lock-"));
const lock = join(dir, ".hyperframes", "project.lock");
mkdirSync(join(dir, ".hyperframes"));
writeFileSync(lock, JSON.stringify({ pid: process.pid, command: "snapshot", startedAt: 1 }));
vi.stubEnv(
"HF_TOOL_CONTEXT",
JSON.stringify({ version: 1, capabilities: { "project-lock": { waitMs: 0 } } }),
);
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async () => report);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => ({ ...PROJECT, dir }),
runPipeline,
withMeta: (value) => value,
});
try {
await runCommand(command, { rawArgs: ["--json"] });
expect(runPipeline).not.toHaveBeenCalled();
expect(consumeCommandResult().exitCode).toBe(1);
expect(String(log.mock.calls[0]?.[0])).toContain("hyperframes snapshot");
} finally {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async () => report);
+13 -2
View File
@@ -1,4 +1,7 @@
import { resolve } from "node:path";
import { formatFindingTimes } from "../utils/checkFindings.js";
import { withProjectLock } from "../utils/projectLock.js";
import { reportArtifacts } from "../utils/toolContext.js";
import { defineCommand, parseArgs } from "citty";
import type { ArgsDef } from "citty";
import type { Example } from "./_examples.js";
@@ -37,7 +40,13 @@ export interface CheckCommandDependencies {
const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
resolveProject,
runPipeline: runCheckPipeline,
async runPipeline(project, options) {
const report = await runCheckPipeline(project, options);
const images = (files: readonly string[]) => files.map((file) => resolve(project.dir, file));
reportArtifacts("check", "frame", images(report.snapshots.files));
reportArtifacts("check", "finding", images(report.snapshots.findingFiles));
return report;
},
withMeta,
};
@@ -158,7 +167,9 @@ export function createCheckCommand(
if (!asJson) {
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
}
const report = await dependencies.runPipeline(project, options);
const report = await withProjectLock(project.dir, "check", () =>
dependencies.runPipeline(project, options),
);
if (asJson) {
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
} else {
+156 -139
View File
@@ -1,3 +1,5 @@
import { reportArtifacts } from "../utils/toolContext.js";
import { withProjectLock } from "../utils/projectLock.js";
import {
parseSnapshotRange,
sampleSnapshotRange,
@@ -1010,152 +1012,167 @@ export default defineCommand({
const snapshotDir = args.output
? resolve(String(args.output))
: join(project.dir, "snapshots");
const paths = await captureSnapshots(project.dir, {
frames,
range,
composition,
timeout,
at: atTimestamps,
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
zoom: zoomTarget,
zoomScale,
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
against,
});
if (paths.length === 0) {
console.log(
`\n${c.error("✗")} Could not determine composition duration — no frames captured`,
await withProjectLock(project.dir, "snapshot", async () => {
const paths = await captureSnapshots(project.dir, {
frames,
range,
composition,
timeout,
at: atTimestamps,
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
zoom: zoomTarget,
zoomScale,
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
against,
});
reportArtifacts(
"snapshot",
"frame",
paths.map((path) => resolve(project.dir, path)),
);
failCommand();
}
console.log(
`\n${c.success("◇")} ${paths.length} snapshots saved to ${args.output ? snapshotDir : "snapshots/"}`,
);
for (const p of paths) {
console.log(` ${p}`);
}
if (against) {
console.log(
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
);
}
// Generate contact sheet for quick AI review
try {
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
const sheets = await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
for (const sheet of sheets) {
console.log(` ${resolve(sheet)} (grid view for AI review)`);
if (paths.length === 0) {
console.log(
`\n${c.error("✗")} Could not determine composition duration — no frames captured`,
);
failCommand();
}
} catch (err) {
console.warn(
` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
);
}
// Gemini vision descriptions. Runs by default — see describeArg
// resolution above. `null` means the user explicitly opted out with
// `--describe false`; missing GEMINI_API_KEY logs a skip and continues.
if (describeArg !== null) {
console.log(
`\n${c.success("◇")} ${paths.length} snapshots saved to ${args.output ? snapshotDir : "snapshots/"}`,
);
for (const p of paths) {
console.log(` ${p}`);
}
if (against) {
console.log(
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
);
}
// Generate contact sheet for quick AI review
try {
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!geminiKey) {
if (args.describe !== undefined)
console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`);
} else if (paths.length > 0) {
console.log(` ${c.dim("Describing frames with Gemini vision...")}`);
const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe");
const ai = new GoogleGenAI({ apiKey: geminiKey });
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
const customQuestion =
describeArg === "true"
? "Describe this video composition frame in 1-2 sentences. Be specific and factual: what elements are visible, what text appears, is the frame blank/black/loading, what is the composition. Flag any obvious problems."
: describeArg;
const descriptions: string[] = [
`# Snapshot Frame Descriptions`,
``,
`**Question asked:** ${customQuestion}`,
``,
`Compare each description against your storyboard spec. A "black frame" or "loading screen" for a content beat is a bug.`,
``,
];
// Scale down PNGs before sending to stay under Gemini's 4 MB inline
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if
// available; otherwise skip files over the limit.
type SharpFn = (buf: Buffer) => {
resize: (w: number) => { jpeg: () => { toBuffer: () => Promise<Buffer> } };
};
let sharpFn: SharpFn | null = null;
try {
const s = await import("sharp");
sharpFn = (s.default ?? s) as unknown as SharpFn;
} catch {
/* sharp not installed — fall back to size check */
}
const results = await Promise.allSettled(
paths.map(async (p) => {
const filename = basename(p);
const filePath = join(snapshotDir, filename);
if (!existsSync(filePath)) return { filename, desc: "file not found" };
const raw = readFileSync(filePath);
let imageData: Buffer;
let mimeType = "image/png";
if (sharpFn) {
imageData = await sharpFn(raw).resize(960).jpeg().toBuffer();
mimeType = "image/jpeg";
} else {
if (raw.length > 3_800_000)
return {
filename,
desc: "file too large for Gemini — install sharp to enable auto-resize",
};
imageData = raw;
}
const base64 = imageData.toString("base64");
const response = await ai.models.generateContent({
model,
contents: [
{
role: "user",
parts: [{ inlineData: { mimeType, data: base64 } }, { text: customQuestion }],
},
],
config: { maxOutputTokens: 250 },
});
return { filename, desc: response.text?.trim() || "no description" };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
descriptions.push(`## ${result.value.filename}`, `${result.value.desc}`, ``);
} else {
// Log first failure so Gemini issues are visible rather than silent
const errMsg = normalizeErrorMessage(result.reason);
descriptions.push(`## (error)`, `Gemini call failed: ${errMsg.slice(0, 120)}`, ``);
}
}
const descPath = join(snapshotDir, "descriptions.md");
writeFileSync(descPath, descriptions.join("\n"));
console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`);
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
const sheets = await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
reportArtifacts("snapshot", "contact-sheet", sheets);
for (const sheet of sheets) {
console.log(` ${resolve(sheet)} (grid view for AI review)`);
}
} catch (descErr) {
const msg = normalizeErrorMessage(descErr);
console.log(` ${c.dim(`--describe failed: ${msg}`)}`);
} catch (err) {
console.warn(
` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
);
}
}
// Gemini vision descriptions. Runs by default — see describeArg
// resolution above. `null` means the user explicitly opted out with
// `--describe false`; missing GEMINI_API_KEY logs a skip and continues.
if (describeArg !== null) {
try {
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!geminiKey) {
if (args.describe !== undefined)
console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`);
} else if (paths.length > 0) {
console.log(` ${c.dim("Describing frames with Gemini vision...")}`);
const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe");
const ai = new GoogleGenAI({ apiKey: geminiKey });
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
const customQuestion =
describeArg === "true"
? "Describe this video composition frame in 1-2 sentences. Be specific and factual: what elements are visible, what text appears, is the frame blank/black/loading, what is the composition. Flag any obvious problems."
: describeArg;
const descriptions: string[] = [
`# Snapshot Frame Descriptions`,
``,
`**Question asked:** ${customQuestion}`,
``,
`Compare each description against your storyboard spec. A "black frame" or "loading screen" for a content beat is a bug.`,
``,
];
// Scale down PNGs before sending to stay under Gemini's 4 MB inline
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if
// available; otherwise skip files over the limit.
type SharpFn = (buf: Buffer) => {
resize: (w: number) => { jpeg: () => { toBuffer: () => Promise<Buffer> } };
};
let sharpFn: SharpFn | null = null;
try {
const s = await import("sharp");
sharpFn = (s.default ?? s) as unknown as SharpFn;
} catch {
/* sharp not installed — fall back to size check */
}
const results = await Promise.allSettled(
paths.map(async (p) => {
const filename = basename(p);
const filePath = join(snapshotDir, filename);
if (!existsSync(filePath)) return { filename, desc: "file not found" };
const raw = readFileSync(filePath);
let imageData: Buffer;
let mimeType = "image/png";
if (sharpFn) {
imageData = await sharpFn(raw).resize(960).jpeg().toBuffer();
mimeType = "image/jpeg";
} else {
if (raw.length > 3_800_000)
return {
filename,
desc: "file too large for Gemini — install sharp to enable auto-resize",
};
imageData = raw;
}
const base64 = imageData.toString("base64");
const response = await ai.models.generateContent({
model,
contents: [
{
role: "user",
parts: [
{ inlineData: { mimeType, data: base64 } },
{ text: customQuestion },
],
},
],
config: { maxOutputTokens: 250 },
});
return { filename, desc: response.text?.trim() || "no description" };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
descriptions.push(`## ${result.value.filename}`, `${result.value.desc}`, ``);
} else {
// Log first failure so Gemini issues are visible rather than silent
const errMsg = normalizeErrorMessage(result.reason);
descriptions.push(
`## (error)`,
`Gemini call failed: ${errMsg.slice(0, 120)}`,
``,
);
}
}
const descPath = join(snapshotDir, "descriptions.md");
writeFileSync(descPath, descriptions.join("\n"));
console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`);
}
} catch (descErr) {
const msg = normalizeErrorMessage(descErr);
console.log(` ${c.dim(`--describe failed: ${msg}`)}`);
}
}
});
} catch (err) {
const msg = normalizeErrorMessage(err);
console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
+161
View File
@@ -0,0 +1,161 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { ProjectLockTimeout, projectLockPath, withProjectLock } from "./projectLock.js";
const directories: string[] = [];
const project = () => {
const directory = mkdtempSync(join(tmpdir(), "hf-project-lock-"));
directories.push(directory);
return directory;
};
afterEach(() => {
for (const directory of directories.splice(0))
rmSync(directory, { recursive: true, force: true });
});
const locked = (waitMs: number) => ({ projectLock: { waitMs } });
const hold = (dir: string, pid: number) => {
mkdirSync(dirname(projectLockPath(dir)), { recursive: true });
writeFileSync(projectLockPath(dir), JSON.stringify({ pid, command: "check", startedAt: 1 }));
};
describe("withProjectLock", () => {
it("runs without a lock file when the capability is absent", async () => {
const dir = project();
expect(
await withProjectLock(dir, "check", async () => existsSync(projectLockPath(dir)), {}),
).toBe(false);
});
it("holds the lock while running and releases it afterwards, even on failure", async () => {
const dir = project();
const seen = await withProjectLock(
dir,
"snapshot",
async () => JSON.parse(readFileSync(projectLockPath(dir), "utf8")),
locked(0),
);
expect(seen).toMatchObject({ pid: process.pid, command: "snapshot" });
expect(existsSync(projectLockPath(dir))).toBe(false);
await expect(
withProjectLock(dir, "check", async () => Promise.reject(new Error("boom")), locked(0)),
).rejects.toThrow("boom");
expect(existsSync(projectLockPath(dir))).toBe(false);
});
it("serializes concurrent holders in one project", async () => {
const dir = project();
const order: string[] = [];
const task = (name: string) =>
withProjectLock(
dir,
name,
async () => {
order.push(`${name}:start`);
await new Promise((done) => setTimeout(done, 50));
order.push(`${name}:end`);
},
locked(5000),
);
await Promise.all([task("check"), task("snapshot")]);
expect(order).toEqual(["check:start", "check:end", "snapshot:start", "snapshot:end"]);
});
it("waits up to waitMs for a live holder, then refuses with who holds it", async () => {
const dir = project();
hold(dir, process.pid);
const started = Date.now();
await expect(withProjectLock(dir, "snapshot", async () => "ran", locked(300))).rejects.toThrow(
ProjectLockTimeout,
);
expect(Date.now() - started).toBeGreaterThanOrEqual(300);
await expect(withProjectLock(dir, "snapshot", async () => "ran", locked(0))).rejects.toThrow(
`hyperframes check (pid ${process.pid}) is still using this project`,
);
});
it("takes over a lock whose process has exited", async () => {
const dir = project();
hold(dir, 2 ** 22 + 12345);
expect(await withProjectLock(dir, "check", async () => "ran", locked(0))).toBe("ran");
});
it("serializes processes that concurrently take over a dead holder", async () => {
const dir = project();
const events = join(dir, "events.ndjson");
hold(dir, 2 ** 22 + 12345);
const moduleUrl = pathToFileURL(join(import.meta.dirname, "projectLock.ts")).href;
const source = `
import { appendFileSync } from "node:fs";
import { withProjectLock } from ${JSON.stringify(moduleUrl)};
await withProjectLock(
${JSON.stringify(dir)},
"check",
async () => {
appendFileSync(${JSON.stringify(events)}, JSON.stringify({ type: "start", pid: process.pid }) + "\\n");
await new Promise((done) => setTimeout(done, 50));
appendFileSync(${JSON.stringify(events)}, JSON.stringify({ type: "end", pid: process.pid }) + "\\n");
},
{ projectLock: { waitMs: 5000 } },
);
`;
const children = Array.from({ length: 6 }, () =>
spawn(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], {
stdio: ["ignore", "ignore", "pipe"],
}),
);
const outcomes = await Promise.all(
children.map(
(child) =>
new Promise<{ code: number | null; error: string }>((resolve) => {
let error = "";
child.stderr.on("data", (chunk) => (error += String(chunk)));
child.on("close", (code) => resolve({ code, error }));
}),
),
);
expect(outcomes).toEqual(outcomes.map(() => ({ code: 0, error: "" })));
const records = readFileSync(events, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type: "start" | "end"; pid: number });
let active = 0;
for (const record of records) {
active += record.type === "start" ? 1 : -1;
expect(active).toBeLessThanOrEqual(1);
}
expect(active).toBe(0);
});
it("refuses a breaker left by a killed process and names the file", async () => {
const dir = project();
hold(dir, 2 ** 22 + 12345);
const breakPath = `${projectLockPath(dir)}.break`;
const source = `
import { linkSync, writeFileSync } from "node:fs";
const holder = { pid: process.pid, command: "check", startedAt: 1 };
const draft = ${JSON.stringify(`${breakPath}.killed`)};
writeFileSync(draft, JSON.stringify(holder), { flag: "wx" });
linkSync(draft, ${JSON.stringify(breakPath)});
process.kill(process.pid, "SIGKILL");
`;
const child = spawn(process.execPath, ["--input-type=module", "--eval", source], {
stdio: "ignore",
});
const outcome = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve) => {
child.on("close", (code, signal) => resolve({ code, signal }));
},
);
expect(outcome.signal === "SIGKILL" || outcome.code !== 0).toBe(true);
expect(existsSync(breakPath)).toBe(true);
await expect(withProjectLock(dir, "check", async () => "ran", locked(0))).rejects.toThrow(
`hyperframes left ${breakPath} (pid ${child.pid}); delete it to continue`,
);
expect(existsSync(breakPath)).toBe(true);
expect(existsSync(projectLockPath(dir))).toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { readToolContext, type ToolContext } from "./toolContext.js";
export class ProjectLockTimeout extends Error {}
interface Holder {
pid: number;
command: string;
startedAt: number;
}
const POLL_MS = 250;
export const projectLockPath = (projectDir: string) =>
join(projectDir, ".hyperframes", "project.lock");
function readHolder(path: string): Holder | null {
try {
return JSON.parse(readFileSync(path, "utf8")) as Holder;
} catch {
return null;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
const sameHolder = (a: Holder | null, b: Holder) =>
a?.pid === b.pid && a.startedAt === b.startedAt && a.command === b.command;
function breakStaleHolder(path: string, current: Holder, holder: Holder): boolean {
const breakPath = `${path}.break`;
const breakDraft = `${breakPath}.${holder.pid}.${holder.startedAt}`;
let acquired = false;
writeFileSync(breakDraft, JSON.stringify(holder), { flag: "wx" });
try {
try {
linkSync(breakDraft, breakPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
}
if (sameHolder(readHolder(path), current)) {
try {
unlinkSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
acquired = tryAcquire(path, holder);
} finally {
unlinkSync(breakDraft);
if (sameHolder(readHolder(breakPath), holder)) unlinkSync(breakPath);
}
return acquired;
}
function tryAcquire(path: string, holder: Holder): boolean {
const draft = `${path}.${holder.pid}.${holder.startedAt}`;
writeFileSync(draft, JSON.stringify(holder), { flag: "wx" });
try {
linkSync(draft, path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
} finally {
unlinkSync(draft);
}
const current = readHolder(path);
if (current && !alive(current.pid)) return breakStaleHolder(path, current, holder);
return false;
}
export async function withProjectLock<T>(
projectDir: string,
command: string,
run: () => Promise<T>,
context: ToolContext = readToolContext(),
): Promise<T> {
if (!context.projectLock) return run();
const path = projectLockPath(projectDir);
mkdirSync(dirname(path), { recursive: true });
const holder: Holder = { pid: process.pid, command, startedAt: Date.now() };
const deadline = holder.startedAt + context.projectLock.waitMs;
while (!tryAcquire(path, holder)) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
const current = readHolder(path);
const breakPath = `${path}.break`;
const breaker = readHolder(breakPath);
if ((!current || !alive(current.pid)) && breaker && !alive(breaker.pid)) {
throw new ProjectLockTimeout(
`hyperframes left ${breakPath} (pid ${breaker.pid}); delete it to continue`,
);
}
throw new ProjectLockTimeout(
`hyperframes ${current?.command ?? "another command"} (pid ${current?.pid ?? "unknown"}) ` +
`is still using this project after ${Math.round(context.projectLock.waitMs / 1000)}s`,
);
}
await sleep(Math.min(POLL_MS, remaining));
}
try {
return await run();
} finally {
if (sameHolder(readHolder(path), holder)) unlinkSync(path);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { readToolContext, reportArtifacts } from "./toolContext.js";
const directories: string[] = [];
const temporary = () => {
const directory = mkdtempSync(join(tmpdir(), "hf-tool-context-"));
directories.push(directory);
return directory;
};
afterEach(() => {
for (const directory of directories.splice(0))
rmSync(directory, { recursive: true, force: true });
});
const context = (capabilities: unknown, version: unknown = 1) =>
JSON.stringify({ version, capabilities });
const eventsAt = (path: string) => ({ transport: { type: "ndjson-file", path } });
describe("readToolContext", () => {
it("is empty without a valid version 1 declaration", () => {
for (const raw of [
undefined,
"",
"not json",
"[]",
context({ events: eventsAt("/tmp/e") }, 2),
context(["events"]),
])
expect(readToolContext(raw)).toEqual({});
});
it("keeps each valid capability and ignores invalid or unknown ones", () => {
const raw = context({
events: eventsAt("/tmp/events.ndjson"),
"project-lock": { waitMs: -1 },
"future-capability": { anything: true },
});
expect(readToolContext(raw)).toEqual({ events: { path: "/tmp/events.ndjson" } });
expect(readToolContext(context({ "project-lock": { waitMs: 500 } }))).toEqual({
projectLock: { waitMs: 500 },
});
expect(readToolContext(context({ events: eventsAt("relative.ndjson") }))).toEqual({});
expect(
readToolContext(context({ events: { transport: { type: "http", path: "/x" } } })),
).toEqual({});
});
});
describe("reportArtifacts", () => {
it("appends one artifact.created line per path, with absolute paths and media types", () => {
const path = join(temporary(), "events.ndjson");
const events = { events: { path } };
reportArtifacts("snapshot", "contact-sheet", ["/p/contact-sheet.jpg"], events);
reportArtifacts("check", "finding", ["/p/crop.bin", "/p/crop.png"], events);
const lines = readFileSync(path, "utf8")
.trimEnd()
.split("\n")
.map((line) => JSON.parse(line));
expect(lines).toEqual([
{
version: 1,
type: "artifact.created",
command: "snapshot",
artifact: {
role: "contact-sheet",
path: resolve("/p/contact-sheet.jpg"),
mediaType: "image/jpeg",
},
},
{
version: 1,
type: "artifact.created",
command: "check",
artifact: {
role: "finding",
path: resolve("/p/crop.bin"),
mediaType: "application/octet-stream",
},
},
{
version: 1,
type: "artifact.created",
command: "check",
artifact: { role: "finding", path: resolve("/p/crop.png"), mediaType: "image/png" },
},
]);
});
it("writes nothing without the events capability, and only warns when the file is unwritable", () => {
expect(() => reportArtifacts("snapshot", "frame", ["/p/a.png"], {})).not.toThrow();
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
reportArtifacts("snapshot", "frame", ["/p/a.png"], {
events: { path: join(temporary(), "missing", "events.ndjson") },
});
expect(error).toHaveBeenCalledOnce();
error.mockRestore();
});
});
+84
View File
@@ -0,0 +1,84 @@
import { appendFileSync } from "node:fs";
import { extname, isAbsolute, resolve } from "node:path";
const TOOL_CONTEXT_VERSION = 1;
export interface ToolContext {
events?: { path: string };
projectLock?: { waitMs: number };
}
export type ArtifactRole = "contact-sheet" | "frame" | "finding";
interface ArtifactCreatedEvent {
version: typeof TOOL_CONTEXT_VERSION;
type: "artifact.created";
command: string;
artifact: { role: ArtifactRole; path: string; mediaType: string };
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function parseEvents(value: unknown): ToolContext["events"] {
if (!isRecord(value) || !isRecord(value.transport)) return undefined;
const { type, path } = value.transport;
if (type !== "ndjson-file" || typeof path !== "string" || !isAbsolute(path)) return undefined;
return { path };
}
function parseProjectLock(value: unknown): ToolContext["projectLock"] {
if (!isRecord(value)) return undefined;
const { waitMs } = value;
if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0) return undefined;
return { waitMs };
}
export function readToolContext(raw = process.env.HF_TOOL_CONTEXT): ToolContext {
if (!raw) return {};
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return {};
}
if (!isRecord(value) || value.version !== TOOL_CONTEXT_VERSION || !isRecord(value.capabilities))
return {};
const events = parseEvents(value.capabilities.events);
const projectLock = parseProjectLock(value.capabilities["project-lock"]);
return { ...(events && { events }), ...(projectLock && { projectLock }) };
}
const MEDIA_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
};
export function reportArtifacts(
command: string,
role: ArtifactRole,
paths: readonly string[],
context = readToolContext(),
): void {
if (!context.events || paths.length === 0) return;
const lines = paths.map((path) => {
const absolute = resolve(path);
const event: ArtifactCreatedEvent = {
version: TOOL_CONTEXT_VERSION,
type: "artifact.created",
command,
artifact: {
role,
path: absolute,
mediaType: MEDIA_TYPES[extname(absolute).toLowerCase()] ?? "application/octet-stream",
},
};
return JSON.stringify(event) + "\n";
});
try {
appendFileSync(context.events.path, lines.join(""));
} catch (error) {
console.error(`HF_TOOL_CONTEXT events could not be written: ${(error as Error).message}`);
}
}