From d7fc579259df15ffe830d38488b3931a3ca1dc7f Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 01:20:10 +0000 Subject: [PATCH 1/2] fix(cli): make the console nudge actionable + accept positional reply text (inbox eval findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbox eval (PR #250) showed both Claude and Gemini ignore the count-only 📬 nudge when nothing in their task mentions the console: they read the line, finish the literal task, and the human's message is never applied (0/3 runs). It also showed both agents' first reply attempt is positional (`reply "text"`), which failed and cost a --help round-trip (3/3 runs). - inbox-nudge: when the unread header fires, fetch the unread events (?all=1 — peek, URL-gated) and print the message content inline plus an explicit act-before-finishing instruction. Feedback inside tool output the agent is already reading is the one channel that reliably lands (same pattern that fixed --type selection). Content fetch is best-effort; the imperative nudge prints regardless. Ack semantics unchanged (only a real `termchart inbox` read advances the receipt). - reply: bare words are now the message (joined); --message still wins; unknown flags still rejected; the missing-message error names both forms. - termchart front-door skill: document the 📬 contract (apply + mark read + reply before finishing); plugin 0.16.3. Verified end-to-end: eval TC-INBOX-UNPROMPTED flips FAIL(3/3)→PASS with the agent visibly acting on the inline nudge, and TC-INBOX-PROMPTED passes with zero reply --help detours (was 100%). Unit: 10/10 on the touched files. --- packages/cli/src/cli.ts | 2 +- packages/cli/src/inbox-nudge.ts | 34 ++++++++++-- packages/cli/src/patch.ts | 2 +- packages/cli/src/push.ts | 2 +- packages/cli/src/reply.ts | 7 ++- packages/cli/src/status.ts | 2 +- packages/cli/test/inbox-nudge.test.ts | 80 +++++++++++++++++++++++++++ packages/cli/test/reply.test.ts | 14 +++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/termchart/SKILL.md | 5 ++ 10 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 packages/cli/test/inbox-nudge.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 24c13b87..415a35e1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -38,7 +38,7 @@ Usage: --follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop) --peek reads without consuming (no ack advance); --all aggregates unread across every scope in one call termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item) - termchart reply [flags] Post an agent→human message into a scope's console thread (--project --agent --message) + termchart reply [flags] [text] Post an agent→human message into a scope's console thread (--project --agent, message via --message or positional) termchart subscribe [flags] Claim ownership of a board so sessions coordinate (advisory; --project --agent --session); takeover wins termchart unsubscribe … Release a board you own (--project --agent --session) termchart subscriptions List which boards are currently owned (--project optional; [--json]) diff --git a/packages/cli/src/inbox-nudge.ts b/packages/cli/src/inbox-nudge.ts index f0da51b3..967b5ba1 100644 --- a/packages/cli/src/inbox-nudge.ts +++ b/packages/cli/src/inbox-nudge.ts @@ -1,15 +1,39 @@ /** * Surface unread console messages on the agent's normal write loop. The viewer sets an * `x-termchart-inbox-unread` header on a successful `push` / `patch` / `status` when the human has - * typed into the console (issue #108) since the agent last read it. We print a one-line hint to - * stderr (not stdout, so it never pollutes piped output) telling the agent how to read them — so it - * notices unprompted human input without having to poll the inbox itself. + * typed into the console (issue #108) since the agent last read it. + * + * The inbox eval showed a count-only hint gets ignored: agents read the line, then finish the + * literal task anyway. So we fetch the unread content (a peek — only a real `termchart inbox` read + * advances the ack/receipt) and print the messages themselves plus an explicit instruction, on + * stderr (never polluting piped stdout). Feedback inside the tool output the agent is already + * reading is the one channel that reliably lands. */ -export function notifyInbox(r: Response, project: string, agent: string): void { +interface UnreadScope { project: string; agent: string; events?: { kind: string; text?: string; ref?: string }[] } + +export async function notifyInbox(r: Response, base: string, project: string, agent: string): Promise { const n = Number(r.headers.get("x-termchart-inbox-unread") ?? "0"); if (!Number.isFinite(n) || n <= 0) return; const noun = n === 1 ? "message" : "messages"; + let lines = ""; + try { + // ?all=1 is unread-only and always a peek; URL-gated like list, so no token needed here. + const rr = await fetch(`${base}/inbox?all=1`, { method: "GET", signal: AbortSignal.timeout(4000) }); + if (rr.ok) { + const data = (await rr.json()) as { scopes?: UnreadScope[] }; + const mine = data.scopes?.find((s) => s.project === project && s.agent === agent); + lines = (mine?.events ?? []) + .slice(-3) + .map((e) => ` › ${e.kind === "action" ? `[action] ${e.ref ?? ""}` : (e.text ?? "").slice(0, 300)}\n`) + .join(""); + } + } catch { + // Content fetch is best-effort — the imperative nudge below fires regardless. + } process.stderr.write( - `📬 ${n} console ${noun} from the human — run: termchart inbox --project ${project} --agent ${agent}\n`, + `📬 ${n} unread console ${noun} from the human — ACT ON THIS BEFORE FINISHING:\n` + + lines + + ` Apply what it asks in this scope, mark it read with \`termchart inbox --project ${project} --agent ${agent}\`,\n` + + ` then acknowledge: termchart reply --project ${project} --agent ${agent} --message ""\n`, ); } diff --git a/packages/cli/src/patch.ts b/packages/cli/src/patch.ts index 587eb74d..c7ba1500 100644 --- a/packages/cli/src/patch.ts +++ b/packages/cli/src/patch.ts @@ -97,7 +97,7 @@ export async function patch(argv: string[], deps: PatchDeps): Promise { const d = (await r.json().catch(() => null)) as { warnings?: unknown } | null; if (d && Array.isArray(d.warnings)) for (const w of d.warnings) process.stderr.write(`patch warning: ${w}\n`); } - notifyInbox(r, a.project, a.agent); // nudge if the human typed into the console since we last looked + await notifyInbox(r, base, a.project, a.agent); // nudge if the human typed into the console since we last looked return 0; } catch (e) { if (isConnError(e)) { diff --git a/packages/cli/src/push.ts b/packages/cli/src/push.ts index badf2bb8..d9958e24 100644 --- a/packages/cli/src/push.ts +++ b/packages/cli/src/push.ts @@ -110,7 +110,7 @@ export async function push(argv: string[], deps: PushDeps): Promise { // The push succeeded, but the server may return non-fatal readability findings (e.g. flow // edges running over nodes / crossings) — severity-tagged — for the caller to fix or weigh. if (r.status !== 204) printGeometry((await r.json().catch(() => null)) as { findings?: PushFinding[]; warnings?: unknown } | null); - notifyInbox(r, a.project, a.agent); // nudge if the human typed into the console since we last looked + await notifyInbox(r, base, a.project, a.agent); // nudge if the human typed into the console since we last looked if (a.focus) { const fr = await post("focus", { project: a.project, agent: a.agent }); if (!fr.ok) process.stderr.write(`focus failed: HTTP ${fr.status}\n`); diff --git a/packages/cli/src/reply.ts b/packages/cli/src/reply.ts index 2376d6cb..8d6540fd 100644 --- a/packages/cli/src/reply.ts +++ b/packages/cli/src/reply.ts @@ -8,13 +8,16 @@ interface Args { project?: string; agent?: string; message?: string; error?: str function parse(argv: string[]): Args { const a: Args = {}; + const positional: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--project") a.project = argv[++i]; else if (arg === "--agent") a.agent = argv[++i]; else if (arg === "--message" || arg === "-m") a.message = argv[++i]; - else a.error = `Unknown flag: ${arg}`; + else if (arg.startsWith("-")) a.error = `Unknown flag: ${arg}`; + else positional.push(arg); // bare words are the message — agents reach for `reply "text"` first } + if (!a.message && positional.length) a.message = positional.join(" "); return a; } @@ -31,7 +34,7 @@ export async function reply(argv: string[], deps: ReplyDeps): Promise { const token = deps.env.TERMCHART_VIEWER_TOKEN; if (!base || !token) { process.stderr.write(missingConfigMessage()); return EXIT_NO_VIEWER; } if (!a.project || !a.agent) { process.stderr.write("--project and --agent are required.\n"); return 3; } - if (!a.message) { process.stderr.write("--message (-m) is required.\n"); return 3; } + if (!a.message) { process.stderr.write("a message is required: --message (-m) \"…\" or positional text.\n"); return 3; } try { const r = await fetch(`${base}/reply`, { diff --git a/packages/cli/src/status.ts b/packages/cli/src/status.ts index c830c699..3ca2d7dc 100644 --- a/packages/cli/src/status.ts +++ b/packages/cli/src/status.ts @@ -104,7 +104,7 @@ export async function status(argv: string[], deps: StatusDeps): Promise process.stderr.write(`status failed: HTTP ${r.status}\n`); return 1; } - if (a.project && a.agent) notifyInbox(r, a.project, a.agent); // scoped status carries the unread nudge + if (a.project && a.agent) await notifyInbox(r, base, a.project, a.agent); // scoped status carries the unread nudge return 0; } catch (e) { if (isConnError(e)) { diff --git a/packages/cli/test/inbox-nudge.test.ts b/packages/cli/test/inbox-nudge.test.ts new file mode 100644 index 00000000..97d32942 --- /dev/null +++ b/packages/cli/test/inbox-nudge.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { type AddressInfo } from "node:net"; +import { notifyInbox } from "../src/inbox-nudge.js"; + +let server: Server | undefined; +afterEach(() => server?.close()); + +/** Serves GET /w/ws1/inbox?all=1 with the given unread scopes. */ +function inboxStub(scopes: unknown[]): Promise { + return new Promise((resolve) => { + server = createServer((req, res) => { + if (req.url?.includes("/inbox") && req.url.includes("all=1")) { + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ scopes })); + } else res.writeHead(404).end(); + }); + server.listen(0, () => resolve(`http://127.0.0.1:${(server!.address() as AddressInfo).port}/w/ws1`)); + }); +} + +const withUnread = (n: string | null) => + new Response(null, { headers: n === null ? {} : { "x-termchart-inbox-unread": n } }); + +function captureStderr(): { errs: string[]; restore: () => void } { + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + return { errs, restore: () => spy.mockRestore() }; +} + +describe("notifyInbox", () => { + it("is silent when the header is absent or zero", async () => { + const { errs, restore } = captureStderr(); + await notifyInbox(withUnread(null), "http://127.0.0.1:1/w/x", "p", "a"); + await notifyInbox(withUnread("0"), "http://127.0.0.1:1/w/x", "p", "a"); + restore(); + expect(errs).toEqual([]); + }); + + it("prints the unread message content inline plus the act-before-finishing contract", async () => { + const base = await inboxStub([ + { project: "p", agent: "a", events: [{ seq: 1, kind: "message", text: "add a maintenance banner" }] }, + { project: "other", agent: "x", events: [{ seq: 9, kind: "message", text: "not ours" }] }, + ]); + const { errs, restore } = captureStderr(); + await notifyInbox(withUnread("1"), base, "p", "a"); + restore(); + const out = errs.join(""); + expect(out).toContain("ACT ON THIS BEFORE FINISHING"); + expect(out).toContain("add a maintenance banner"); + expect(out).not.toContain("not ours"); + expect(out).toContain("termchart inbox --project p --agent a"); + expect(out).toContain("termchart reply --project p --agent a"); + }); + + it("renders action events by ref and caps at the last 3 events", async () => { + const events = [ + { seq: 1, kind: "message", text: "oldest — dropped" }, + { seq: 2, kind: "message", text: "two" }, + { seq: 3, kind: "message", text: "three" }, + { seq: 4, kind: "action", ref: "approve" }, + ]; + const base = await inboxStub([{ project: "p", agent: "a", events }]); + const { errs, restore } = captureStderr(); + await notifyInbox(withUnread("4"), base, "p", "a"); + restore(); + const out = errs.join(""); + expect(out).toContain("[action] approve"); + expect(out).toContain("two"); + expect(out).not.toContain("oldest — dropped"); + }); + + it("still prints the imperative nudge when the content fetch fails", async () => { + const { errs, restore } = captureStderr(); + await notifyInbox(withUnread("2"), "http://127.0.0.1:1/w/dead", "p", "a"); + restore(); + const out = errs.join(""); + expect(out).toContain("📬 2 unread console messages"); + expect(out).toContain("ACT ON THIS BEFORE FINISHING"); + }); +}); diff --git a/packages/cli/test/reply.test.ts b/packages/cli/test/reply.test.ts index 4609d08c..ecef700c 100644 --- a/packages/cli/test/reply.test.ts +++ b/packages/cli/test/reply.test.ts @@ -52,6 +52,20 @@ describe("reply", () => { expect(JSON.parse((await received).body).text).toBe("short"); }); + it("accepts positional message text (agents reach for `reply … \"text\"` first)", async () => { + const { url, received } = await stub(); + const code = await reply(["--project", "p", "--agent", "a", "did", "the", "thing"], { env: env(url) }); + expect(code).toBe(0); + expect(JSON.parse((await received).body).text).toBe("did the thing"); + }); + + it("--message wins over positional text", async () => { + const { url, received } = await stub(); + const code = await reply(["--project", "p", "--agent", "a", "-m", "flagged", "stray"], { env: env(url) }); + expect(code).toBe(0); + expect(JSON.parse((await received).body).text).toBe("flagged"); + }); + it("requires URL+token (no-viewer code) and project/agent/message (exit 3)", async () => { expect(await reply(["--project", "p", "--agent", "a", "-m", "x"], { env: {} })).toBe(EXIT_NO_VIEWER); const { url } = await stub(); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b66fd52c..f3dbd99b 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "termchart", "displayName": "termchart", "description": "A live canvas your AI draws on. Instead of walls of text, your agent pushes rich, native visuals to a browser tab / iPad / second screen in real time \u2014 React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine UI dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes \u2014 with animated edges and live status overlays (toasts, progress bars, loaders). Ships persona recipes, a showcase gallery, fullscreen panes, and a collapsible sidebar. Adds /termchart commands + skills, backed by the termchart CLI. (A deterministic Mermaid\u2192ASCII/Unicode terminal renderer is still bundled but deprecated.)", - "version": "0.16.2", + "version": "0.16.3", "author": { "name": "Ivan Cheung" }, diff --git a/plugin/skills/termchart/SKILL.md b/plugin/skills/termchart/SKILL.md index 268583c3..c460f1c1 100644 --- a/plugin/skills/termchart/SKILL.md +++ b/plugin/skills/termchart/SKILL.md @@ -72,6 +72,11 @@ For **ER / schema / class** stay on `--type flow` — make each node `"type":"en **No viewer configured?** `push` exits 4 — run `termchart serve`, export the printed `TERMCHART_VIEWER_URL`/`TERMCHART_VIEWER_TOKEN`, and retry. +**📬 in any termchart output = the human left you a console message. It is part of your task.** +Before finishing, run the printed `termchart inbox …` command, apply what the message asks to the +same scope (edit + re-push or patch), and acknowledge with +`termchart reply --project

--agent --message ""`. + For richer per-persona recipes (zones, swimlanes, legends, sparklines, sequence layouts, ~30 worked examples), **load the `diagram-recipes` skill** — this skill is the quick front door; `diagram-recipes` is the deep library. From 1836263a3a88165603e40398bcb25ec4920858b9 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 01:23:41 +0000 Subject: [PATCH 2/2] test: update push nudge assertion to the new actionable wording --- packages/cli/test/push.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/push.test.ts b/packages/cli/test/push.test.ts index cc13862a..dddd7d8f 100644 --- a/packages/cli/test/push.test.ts +++ b/packages/cli/test/push.test.ts @@ -169,7 +169,8 @@ describe("push", () => { spy.mockRestore(); expect(code).toBe(0); const out = errs.join(""); - expect(out).toContain("📬 2 console messages"); + expect(out).toContain("📬 2 unread console messages"); + expect(out).toContain("ACT ON THIS BEFORE FINISHING"); expect(out).toContain("termchart inbox --project p --agent a"); });