fix(headless): ignore pasted prose on orchestrator stdin

This commit is contained in:
Mikael Hugo 2026-05-02 14:08:08 +02:00
parent 3d0ebd981f
commit bba5a7f143
4 changed files with 178 additions and 46 deletions

View file

@ -243,10 +243,7 @@ export function summarizeToolArgs(
}
/** Summarize SF extension tool args into a compact identifier string. */
function summarizeSfTool(
name: string,
input: Record<string, unknown>,
): string {
function summarizeSfTool(name: string, input: Record<string, unknown>): string {
const parts: string[] = [];
if (input.milestoneId) parts.push(String(input.milestoneId));
if (input.sliceId) parts.push(String(input.sliceId));
@ -603,46 +600,70 @@ export function startSupervisedStdinReader(
onResponse: (id: string) => void,
): () => void {
return attachJsonlLineReader(process.stdin as Readable, (line) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
process.stderr.write(
`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`,
);
return;
}
const type = String(msg.type ?? "");
switch (type) {
case "extension_ui_response": {
const id = String(msg.id ?? "");
const value = msg.value !== undefined ? String(msg.value) : undefined;
const confirmed =
typeof msg.confirmed === "boolean" ? msg.confirmed : undefined;
const cancelled =
typeof msg.cancelled === "boolean" ? msg.cancelled : undefined;
client.sendUIResponse(id, { value, confirmed, cancelled });
if (id) {
onResponse(id);
}
break;
}
case "prompt":
client.prompt(String(msg.message ?? ""));
break;
case "steer":
client.steer(String(msg.message ?? ""));
break;
case "follow_up":
client.followUp(String(msg.message ?? ""));
break;
default:
process.stderr.write(
`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`,
);
break;
}
handleSupervisedStdinLine(line, client, onResponse);
});
}
function shouldWarnInvalidOrchestratorLine(line: string): boolean {
const trimmed = line.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/**
* Handle one supervised JSONL stdin frame.
*
* Purpose: accept real orchestrator control records while ignoring accidental
* pasted prose in headless terminals without flooding stderr.
*
* Consumer: startSupervisedStdinReader, which receives LF-framed stdin lines.
*/
export function handleSupervisedStdinLine(
line: string,
client: RpcClient,
onResponse: (id: string) => void,
stderr: Pick<NodeJS.WriteStream, "write"> = process.stderr,
): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
if (shouldWarnInvalidOrchestratorLine(line)) {
stderr.write(
`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`,
);
}
return;
}
const type = String(msg.type ?? "");
switch (type) {
case "extension_ui_response": {
const id = String(msg.id ?? "");
const value = msg.value !== undefined ? String(msg.value) : undefined;
const confirmed =
typeof msg.confirmed === "boolean" ? msg.confirmed : undefined;
const cancelled =
typeof msg.cancelled === "boolean" ? msg.cancelled : undefined;
client.sendUIResponse(id, { value, confirmed, cancelled });
if (id) {
onResponse(id);
}
break;
}
case "prompt":
client.prompt(String(msg.message ?? ""));
break;
case "steer":
client.steer(String(msg.message ?? ""));
break;
case "follow_up":
client.followUp(String(msg.message ?? ""));
break;
default:
stderr.write(
`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`,
);
break;
}
}

View file

@ -260,8 +260,11 @@ export function registerHooks(
const { runGapAudit } = await import("../gap-audit.js");
const filed = runGapAudit(process.cwd());
if (filed > 0) {
const { selfFeedbackDestinationLabel } = await import(
"../self-feedback.js"
);
ctx.ui?.notify?.(
`Gap audit filed ${filed} new finding${filed === 1 ? "" : "s"} in .sf/SELF-FEEDBACK.md`,
`Gap audit filed ${filed} new finding${filed === 1 ? "" : "s"} in ${selfFeedbackDestinationLabel(process.cwd())}`,
"info",
);
}

View file

@ -184,6 +184,21 @@ function upstreamLogPath(): string {
return join(sfHome, "agent", "upstream-feedback.jsonl");
}
/**
* Return the operator-facing destination for new self-feedback in `basePath`.
*
* Purpose: keep startup notices honest when sf is dogfooded in other repos;
* external-project findings are about sf but are persisted globally, not in
* the target repo's `.sf/` directory.
*
* Consumer: session_start notifications that summarize detector writes.
*/
export function selfFeedbackDestinationLabel(basePath: string): string {
return isForgeRepo(basePath)
? ".sf/SELF-FEEDBACK.md"
: "~/.sf/agent/upstream-feedback.jsonl";
}
/**
* Migrate the legacy filename. Older sf versions wrote `BACKLOG.md`; the
* canonical name is now `SELF-FEEDBACK.md` (matches `self-feedback.jsonl`).

View file

@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { handleSupervisedStdinLine } from "../headless-ui.js";
class MockRpcClient {
prompts: string[] = [];
steers: string[] = [];
followUps: string[] = [];
uiResponses: Array<{
id: string;
response: {
value?: string;
confirmed?: boolean;
cancelled?: boolean;
};
}> = [];
prompt(message: string): void {
this.prompts.push(message);
}
steer(message: string): void {
this.steers.push(message);
}
followUp(message: string): void {
this.followUps.push(message);
}
sendUIResponse(
id: string,
response: {
value?: string;
confirmed?: boolean;
cancelled?: boolean;
},
): void {
this.uiResponses.push({ id, response });
}
}
function stderrSink(): { lines: string[]; write: (line: string) => boolean } {
const lines: string[] = [];
return {
lines,
write(line: string): boolean {
lines.push(line);
return true;
},
};
}
describe("handleSupervisedStdinLine", () => {
it("ignores pasted prose without warning spam", () => {
const client = new MockRpcClient();
const stderr = stderrSink();
handleSupervisedStdinLine(
"rchestrator stdin, skipping",
client as never,
() => {},
stderr,
);
assert.deepEqual(stderr.lines, []);
assert.deepEqual(client.prompts, []);
});
it("warns for malformed JSON-looking orchestrator frames", () => {
const client = new MockRpcClient();
const stderr = stderrSink();
handleSupervisedStdinLine("{ bad", client as never, () => {}, stderr);
assert.equal(stderr.lines.length, 1);
assert.match(stderr.lines[0] ?? "", /invalid JSON from orchestrator stdin/);
});
it("dispatches valid steer frames", () => {
const client = new MockRpcClient();
const stderr = stderrSink();
handleSupervisedStdinLine(
JSON.stringify({ type: "steer", message: "continue" }),
client as never,
() => {},
stderr,
);
assert.deepEqual(client.steers, ["continue"]);
assert.deepEqual(stderr.lines, []);
});
});