From 1a818d7933b53ff666be5694aa5cf6bca3503819 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 02:22:02 +0000 Subject: [PATCH 01/11] feat(console): surface unread messages on the read path too (pull header, list summary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nudge fired only on writes (push/patch/scoped status) — a human message arriving after the agent's LAST write was never surfaced. Close the gap on both halves of the read loop: - server: scoped GET /state (pull) now sets the x-termchart-inbox-unread header, same once-per-new-message semantics as the write path - cli pull: prints the content nudge off that header - cli list: prints a workspace-wide unread summary via GET /inbox?all=1 — the one read an agent still does when it's done writing, and the only place cross-scope messages can surface; re-announces until read (an explicit catalog read should show what's unread); stderr-only so --json stdout stays clean - skill: final-check convention — run `termchart list` as the last termchart action; plugin 0.16.4, marketplace entry synced (was stale 0.15.0) - eval: TC-INBOX-MIDTASK — seed_inbox.py gains @await scopes posted by a background watcher only after the agent's board appears (a human typing mid-task); run.sh wires the watcher up and kills it after the runner Verified live: pull nudges after a post-push message; list re-announces until read and shows cross-scope unread; quiet after a real read; --json stdout unpolluted. Touched tests 33/33. --- .claude-plugin/marketplace.json | 2 +- packages/cli/src/inbox-nudge.ts | 27 +++++++ packages/cli/src/list.ts | 4 + packages/cli/src/pull.ts | 2 + packages/cli/test/inbox-nudge.test.ts | 21 +++++ packages/viewer/src/server.ts | 3 + plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/termchart/SKILL.md | 3 + .../definitions/cases/termchart_inbox.yaml | 31 ++++++++ .../agent-compat/definitions/inbox_seeds.json | 4 + scripts/experiments/agent-compat/run.sh | 8 +- .../experiments/agent-compat/seed_inbox.py | 78 +++++++++++++++---- 12 files changed, 166 insertions(+), 19 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0850bc3..d62a626 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "termchart", "source": "./plugin", "description": "A live canvas your AI draws on — instead of walls of text, your agent pushes rich, native visuals to a browser / iPad / second screen in real time: React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes, with animated edges and live status overlays (toasts, progress bars, loaders). Persona recipes, a showcase gallery, and fullscreen panes. (A deterministic Mermaid → ASCII/Unicode terminal renderer is still bundled but deprecated.)", - "version": "0.15.0", + "version": "0.16.4", "license": "MIT", "keywords": [ "viewer", diff --git a/packages/cli/src/inbox-nudge.ts b/packages/cli/src/inbox-nudge.ts index 967b5ba..913cd7e 100644 --- a/packages/cli/src/inbox-nudge.ts +++ b/packages/cli/src/inbox-nudge.ts @@ -11,6 +11,33 @@ */ interface UnreadScope { project: string; agent: string; events?: { kind: string; text?: string; ref?: string }[] } +/** + * Workspace-wide unread summary for `termchart list` — the one read an agent reliably does even + * when it isn't writing anymore, and the only place cross-scope messages can surface. Unlike the + * header nudge this re-announces until read: an explicit catalog read should show what's unread. + */ +export async function notifyInboxAll(base: string): Promise { + try { + const rr = await fetch(`${base}/inbox?all=1`, { method: "GET", signal: AbortSignal.timeout(4000) }); + if (!rr.ok) return; + const data = (await rr.json()) as { scopes?: UnreadScope[] }; + const scopes = data.scopes ?? []; + if (!scopes.length) return; + process.stderr.write(`📬 unread console messages from the human — ACT ON THESE BEFORE FINISHING:\n`); + for (const s of scopes.slice(0, 5)) { + const last = s.events?.[s.events.length - 1]; + const gist = last ? (last.kind === "action" ? `[action] ${last.ref ?? ""}` : (last.text ?? "").slice(0, 120)) : ""; + process.stderr.write(` › ${s.project}/${s.agent} (${s.events?.length ?? 0}): ${gist}\n`); + } + const first = scopes[0]; + process.stderr.write( + ` Read each with \`termchart inbox --project ${first.project} --agent ${first.agent}\` (per scope), apply, then \`termchart reply\`.\n`, + ); + } catch { + // Best-effort: list output must never fail because the inbox endpoint hiccuped. + } +} + 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; diff --git a/packages/cli/src/list.ts b/packages/cli/src/list.ts index 908adab..b7117bc 100644 --- a/packages/cli/src/list.ts +++ b/packages/cli/src/list.ts @@ -1,4 +1,5 @@ import { takeValue } from "./args.js"; +import { notifyInboxAll } from "./inbox-nudge.js"; import { EXIT_NO_VIEWER, REQUEST_TIMEOUT_MS, isConnError, missingConfigMessage, unreachableMessage } from "./viewer-detect.js"; export interface ListDeps { @@ -58,10 +59,12 @@ export async function list(argv: string[], deps: ListDeps): Promise { if (a.project) items = items.filter((it) => it.project === a.project); if (a.json) { process.stdout.write(JSON.stringify(items, null, 2) + "\n"); + await notifyInboxAll(base); // stderr-only, never pollutes the JSON on stdout return 0; } if (!items.length) { process.stdout.write("No views stored.\n"); + await notifyInboxAll(base); return 0; } const now = Date.now(); @@ -69,6 +72,7 @@ export async function list(argv: string[], deps: ListDeps): Promise { for (const it of items) { process.stdout.write(`${it.project}/${it.agent} · [${it.type}] · ${it.description} (${ago(it.ts, now)})\n`); } + await notifyInboxAll(base); // list is the read every agent does — surface cross-scope unread here return 0; } catch (e) { if (isConnError(e)) { diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts index 8f69516..52d2054 100644 --- a/packages/cli/src/pull.ts +++ b/packages/cli/src/pull.ts @@ -1,4 +1,5 @@ import { takeValue } from "./args.js"; +import { notifyInbox } from "./inbox-nudge.js"; import { EXIT_NO_VIEWER, REQUEST_TIMEOUT_MS, isConnError, missingConfigMessage, unreachableMessage } from "./viewer-detect.js"; export interface PullDeps { @@ -57,6 +58,7 @@ export async function pull(argv: string[], deps: PullDeps): Promise { return 1; } process.stdout.write((a.json ? JSON.stringify(view, null, 2) : view.content) + "\n"); + await notifyInbox(r, base, a.project, a.agent); // a pull is part of the read loop — surface unread here too 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 index 97d3294..5fd8953 100644 --- a/packages/cli/test/inbox-nudge.test.ts +++ b/packages/cli/test/inbox-nudge.test.ts @@ -69,6 +69,27 @@ describe("notifyInbox", () => { expect(out).not.toContain("oldest — dropped"); }); + it("notifyInboxAll prints a per-scope unread summary (and is silent with none)", async () => { + const { notifyInboxAll } = await import("../src/inbox-nudge.js"); + const base = await inboxStub([ + { project: "p", agent: "a", events: [{ seq: 1, kind: "message", text: "banner please" }] }, + { project: "q", agent: "b", events: [{ seq: 2, kind: "action", ref: "approve" }] }, + ]); + const { errs, restore } = captureStderr(); + await notifyInboxAll(base); + restore(); + const out = errs.join(""); + expect(out).toContain("ACT ON THESE BEFORE FINISHING"); + expect(out).toContain("p/a (1): banner please"); + expect(out).toContain("q/b (1): [action] approve"); + + const empty = await (async () => { server?.close(); return inboxStub([]); })(); + const cap2 = captureStderr(); + await notifyInboxAll(empty); + cap2.restore(); + expect(cap2.errs).toEqual([]); + }); + 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"); diff --git a/packages/viewer/src/server.ts b/packages/viewer/src/server.ts index f38f378..470091d 100644 --- a/packages/viewer/src/server.ts +++ b/packages/viewer/src/server.ts @@ -895,6 +895,9 @@ export function createViewerServer(opts: ServerOpts) { if (project && agent) { const one = store.list(wsid).find((p) => p.project === project && p.agent === agent); if (!one) return send(res, 404, "no such scope"); + // A scoped pull is part of the agent's read loop too — without this, a human message that + // arrives after the agent's last write only surfaces if the agent happens to write again. + setInboxHeader(res, wsid, `${project}/${agent}`); return send(res, 200, JSON.stringify(one), "application/json"); } return send(res, 200, JSON.stringify(store.list(wsid)), "application/json"); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f3dbd99..4d71708 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.3", + "version": "0.16.4", "author": { "name": "Ivan Cheung" }, diff --git a/plugin/skills/termchart/SKILL.md b/plugin/skills/termchart/SKILL.md index c460f1c..b49a28f 100644 --- a/plugin/skills/termchart/SKILL.md +++ b/plugin/skills/termchart/SKILL.md @@ -76,6 +76,9 @@ For **ER / schema / class** stay on `--type flow` — make each node `"type":"en 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 ""`. +**Final check:** as your LAST termchart action, run `termchart list` once — it prints any unread +console messages across every scope; if it shows 📬, handle it before you finish. The human can +type at any moment, including after your last push. 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; diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index 5dcc9b6..63a8b1f 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -97,3 +97,34 @@ benchmarks: - type: command command: 'tc-judge --project tc-inbox --agent unprompted --rubric-name inbox-login --threshold 0.66' expected_exit_code: 0 + +# 3) MIDTASK — the human types AFTER the agent's first push (the @await watcher posts the seed only +# once the board exists). The write-path nudge can't fire (the agent is done writing); what must +# catch it is the `termchart list` unread summary — the scenario's last step — plus the skill's +# final-check convention. Same seed + rubric; scope tc-inbox/midtask. +- benchmark_type: interactive_simulation + id: TC-INBOX-MIDTASK + case_data: + name: "inbox: catch a message that arrives mid-task (list nudge)" + id: TC-INBOX-MIDTASK + description: "Seed posts after the first push — does the agent catch it via the list unread summary?" + requirements: ["Notice mid-task console input via the list unread summary and apply it."] + complexity: { architecture: ARCH_SINGLE, mutation_risk: RISK_READ_ONLY, tool_depth: DEPTH_DISCOVERY, environment: ENV_EMPTY, adk_api_tier: ADK_T1 } + environment_config: { resources: [], verification_steps: [] } + agent_generation: { requirements: [], example_conversations: [], prompt_template: "direct", output_format: { type: direct }, max_generation_turns: 1 } + conversations: + - id: midtask + name: Login screen (message lands after the push) + initial_prompt: > + Draw a login screen for a web app on the termchart viewer. Use exactly + `--project tc-inbox --agent midtask` as the scope (you choose the --type). + After it lands, run `termchart list` to confirm what's stored and tell me. + simulant_goal: *sg + simulant_knowledge_system: *sks + validation_objectives: + - type: command + command: 'termchart pull --project tc-inbox --agent midtask --json 2>/dev/null | grep -qi "beam me in"' + expected_exit_code: 0 + - type: command + command: 'tc-judge --project tc-inbox --agent midtask --rubric-name inbox-login --threshold 0.66' + expected_exit_code: 0 diff --git a/scripts/experiments/agent-compat/definitions/inbox_seeds.json b/scripts/experiments/agent-compat/definitions/inbox_seeds.json index 239203f..ad75051 100644 --- a/scripts/experiments/agent-compat/definitions/inbox_seeds.json +++ b/scripts/experiments/agent-compat/definitions/inbox_seeds.json @@ -5,5 +5,9 @@ ], "tc-inbox/unprompted": [ {"kind": "message", "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'."} + ], + "_comment_await": "@await scopes are seeded only AFTER the agent's board appears on the viewer (mid-task human input). The write-path nudge can't fire for them if the agent is done writing — the `termchart list` unread summary / final-check is what must catch it.", + "tc-inbox/midtask@await": [ + {"kind": "message", "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'."} ] } diff --git a/scripts/experiments/agent-compat/run.sh b/scripts/experiments/agent-compat/run.sh index 338b64f..c756c3f 100755 --- a/scripts/experiments/agent-compat/run.sh +++ b/scripts/experiments/agent-compat/run.sh @@ -221,9 +221,14 @@ for b in "${BACKENDS[@]}"; do "$BIN/termchart" clear --all >/dev/null 2>&1 || log "warn: viewer clear failed (continuing)" # Inbox eval: seed the human's console messages AFTER the clear (which wipes the inbox ring) and # BEFORE the agent runs, so it discovers them (the 📬 nudge on its push, or `termchart inbox`). + # `@await` scopes get a background watcher that posts the seed only once the agent's board + # appears — mid-task human input (killed after the runner finishes so it never leaks). + SEED_WATCHER_PID="" if [ "${SEED_INBOX:-}" = 1 ]; then - TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" \ + TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --immediate \ && log "seeded console inbox for the inbox eval" || log "warn: inbox seed failed" + TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --await & + SEED_WATCHER_PID=$! fi if "${RUNNER[@]}" \ --config-dir "$DEF_DIR" \ @@ -237,6 +242,7 @@ for b in "${BACKENDS[@]}"; do else RESULT[$b]="FAIL (exit $?)" fi + [ -n "$SEED_WATCHER_PID" ] && kill "$SEED_WATCHER_PID" 2>/dev/null log "backend $b -> ${RESULT[$b]}" done diff --git a/scripts/experiments/agent-compat/seed_inbox.py b/scripts/experiments/agent-compat/seed_inbox.py index 089f0f6..2062dd5 100644 --- a/scripts/experiments/agent-compat/seed_inbox.py +++ b/scripts/experiments/agent-compat/seed_inbox.py @@ -1,21 +1,33 @@ #!/usr/bin/env python3 -"""Pre-seed the viewer's console inbox for the inbox eval. +"""Seed the viewer's console inbox for the inbox eval. The bidirectional console (issue #108) has human→agent messages arrive out-of-band (the human types in the viewer, POST /inbox) — NOT through the agent-generator conversation. To eval "does the agent -pick up + act on console input", we drop the human's message into the scope's inbox BEFORE the agent -runs; the agent then discovers it (the 📬 nudge on its first push, or by reading `termchart inbox`) -and should apply it. Runs on the host with the viewer URL/token (run.sh exports them), after the -per-backend `clear` (which wipes the inbox ring). - -Reads definitions/inbox_seeds.json: { "project/agent": [ {"kind":"message","text":"…"} - | {"kind":"action","ref":"…"} ], … } -Usage: TERMCHART_VIEWER_URL=… TERMCHART_VIEWER_TOKEN=… python3 seed_inbox.py [seeds.json] +pick up + act on console input", we drop the human's message into the scope's inbox; the agent then +discovers it (the 📬 nudge on a push/pull/list, or by reading `termchart inbox`) and should apply +it. Runs on the host with the viewer URL/token (run.sh exports them), after the per-backend `clear` +(which wipes the inbox ring). + +Reads definitions/inbox_seeds.json: + { "project/agent": [ {"kind":"message","text":"…"} | {"kind":"action","ref":"…"} ], … } +A scope key suffixed with "@await" is a MID-TASK seed: it is posted only once the scope's board +exists on the viewer (polled via GET /list) — modelling a human who types AFTER the agent's first +push. run.sh runs `--immediate` synchronously and `--await` as a background watcher. + +Usage: TERMCHART_VIEWER_URL=… TERMCHART_VIEWER_TOKEN=… python3 seed_inbox.py [--immediate|--await] [seeds.json] """ -import json, os, sys, pathlib, urllib.error, urllib.request +import json, os, sys, pathlib, time, urllib.error, urllib.request + +AWAIT_SUFFIX = "@await" +AWAIT_POLL_S = 0.5 +AWAIT_TIMEOUT_S = 900 HERE = pathlib.Path(__file__).resolve().parent -seeds_path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "definitions" / "inbox_seeds.json" +mode = "all" +argv = sys.argv[1:] +if argv and argv[0] in ("--immediate", "--await"): + mode = argv.pop(0).lstrip("-") +seeds_path = pathlib.Path(argv[0]) if argv else HERE / "definitions" / "inbox_seeds.json" base = os.environ.get("TERMCHART_VIEWER_URL") token = os.environ.get("TERMCHART_VIEWER_TOKEN") if not base: @@ -26,10 +38,10 @@ if token: headers["authorization"] = f"Bearer {token}" -posted = 0 -scopes = {k: v for k, v in seeds.items() if not k.startswith("_")} # "_"-keys are docs, not scopes -for scope, events in scopes.items(): + +def post_events(scope: str, events: list) -> int: project, _, agent = scope.partition("/") + n = 0 for ev in events: body = {"project": project, "agent": agent, "kind": ev["kind"]} if "text" in ev: @@ -39,7 +51,41 @@ req = urllib.request.Request(f"{base}/inbox", data=json.dumps(body).encode(), headers=headers, method="POST") try: urllib.request.urlopen(req, timeout=15) - posted += 1 + n += 1 except (urllib.error.URLError, TimeoutError, ValueError) as e: print(f"seed_inbox: {scope} <- {ev.get('kind')} FAILED: {e}", file=sys.stderr) -print(f"seed_inbox: posted {posted} event(s) across {len(scopes)} scope(s)", file=sys.stderr) + return n + + +def scope_exists(scope: str) -> bool: + project, _, agent = scope.partition("/") + try: + with urllib.request.urlopen(f"{base}/list", timeout=15) as r: + items = json.loads(r.read()) + return any(it.get("project") == project and it.get("agent") == agent for it in items) + except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError): + return False + + +scopes = {k: v for k, v in seeds.items() if not k.startswith("_")} # "_"-keys are docs, not scopes +immediate = {k: v for k, v in scopes.items() if not k.endswith(AWAIT_SUFFIX)} +awaited = {k.removesuffix(AWAIT_SUFFIX): v for k, v in scopes.items() if k.endswith(AWAIT_SUFFIX)} + +posted = 0 +if mode in ("all", "immediate"): + for scope, events in immediate.items(): + posted += post_events(scope, events) + print(f"seed_inbox: posted {posted} event(s) across {len(immediate)} scope(s)", file=sys.stderr) + +if mode in ("all", "await") and awaited: + deadline = time.monotonic() + AWAIT_TIMEOUT_S + pending = dict(awaited) + while pending and time.monotonic() < deadline: + for scope in list(pending): + if scope_exists(scope): + n = post_events(scope, pending.pop(scope)) + print(f"seed_inbox: mid-task seeded {scope} ({n} event(s)) after its board appeared", file=sys.stderr) + if pending: + time.sleep(AWAIT_POLL_S) + for scope in pending: + print(f"seed_inbox: AWAIT TIMEOUT — {scope} board never appeared; seed not posted", file=sys.stderr) From f423a80544232c92042b7494040732eddf829279 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 02:29:06 +0000 Subject: [PATCH 02/11] =?UTF-8?q?agent-compat:=20interactive=20console=20c?= =?UTF-8?q?ases=20=E2=80=94=20suggest-chips=20decision=20+=20checklist=20t?= =?UTF-8?q?ick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TC-INBOX-DECIDE: agent posts two chips, blocks on inbox --wait; console_human.py (the simulated human, launched by run.sh) clicks 'green' via GET /suggest -> POST /inbox action once the chips are up. The choice reaches the agent only through the console. Deterministic gate: button color prop is the clicked id. - TC-INBOX-TICK: human ticks an editable Checklist out-of-band (POST /interact — produces NO inbox event by design); the agent must observe it by polling pull and reply naming the item. Gates: tick landed in the spec + reply names it. - pull-grep objectives now grep raw `pull` output (content in --json is an escaped string, so quote-anchored patterns silently miss — caught in dry-run). Dry-run verified: watcher clicks + ticks; inbox --wait returns the action; all objective commands pass against simulated-agent state. --- .../experiments/agent-compat/console_human.py | 85 +++++++++++++++++ .../definitions/cases/termchart_inbox.yaml | 91 ++++++++++++++++++- .../definitions/console_human.json | 7 ++ .../agent-compat/rubrics/inbox-decide.json | 5 + scripts/experiments/agent-compat/run.sh | 9 +- 5 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 scripts/experiments/agent-compat/console_human.py create mode 100644 scripts/experiments/agent-compat/definitions/console_human.json create mode 100644 scripts/experiments/agent-compat/rubrics/inbox-decide.json diff --git a/scripts/experiments/agent-compat/console_human.py b/scripts/experiments/agent-compat/console_human.py new file mode 100644 index 0000000..05a6c13 --- /dev/null +++ b/scripts/experiments/agent-compat/console_human.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Simulated HUMAN for the console eval's interactive directions (runs on the host). + +The agent-generator simulant only talks in chat; clicking a suggestion chip or ticking a Checklist +happens on the human's *screen* (viewer UI → POST /inbox action | POST /interact). This watcher +plays that human: it polls until the agent's artifact appears, performs the configured click/tick +exactly once, and exits when all actions are done. run.sh launches it in the background (gated by +SEED_INBOX=1, killed after the runner). + +Reads definitions/console_human.json: + { "actions": [ {"kind":"click_suggestion","scope":"p/a","ref":""} + | {"kind":"tick_checklist","scope":"p/a","ref":"/"} ] } +Usage: TERMCHART_VIEWER_URL=… python3 console_human.py [actions.json] +""" +import json, os, pathlib, sys, time, urllib.error, urllib.request + +POLL_S = 0.5 +TIMEOUT_S = 900 + +HERE = pathlib.Path(__file__).resolve().parent +actions_path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "definitions" / "console_human.json" +base = os.environ.get("TERMCHART_VIEWER_URL") +if not base: + sys.exit("console_human: TERMCHART_VIEWER_URL not set") + + +def get_json(path: str): + try: + with urllib.request.urlopen(f"{base}/{path}", timeout=15) as r: + return json.loads(r.read()) + except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError): + return None + + +def post_json(path: str, body: dict) -> bool: + req = urllib.request.Request( + f"{base}/{path}", data=json.dumps(body).encode(), + headers={"content-type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=15) + return True + except (urllib.error.URLError, TimeoutError, ValueError) as e: + print(f"console_human: POST /{path} FAILED: {e}", file=sys.stderr) + return False + + +def try_click(project: str, agent: str, ref: str) -> bool: + """Click the chip once the agent's suggest carries it.""" + data = get_json(f"suggest?project={project}&agent={agent}") + items = (data or {}).get("items") or [] + if not any(it.get("id") == ref for it in items): + return False + if post_json("inbox", {"project": project, "agent": agent, "kind": "action", "ref": ref}): + print(f"console_human: clicked chip '{ref}' in {project}/{agent}", file=sys.stderr) + return True + return False + + +def try_tick(project: str, agent: str, ref: str) -> bool: + """Tick the checklist item once the agent's board carries an editable checklist with it.""" + checklist_id, _, item_id = ref.partition("/") + data = get_json(f"state?project={project}&agent={agent}") + content = (data or {}).get("content") + if not content or checklist_id not in content or item_id not in content: + return False + if post_json("interact", {"project": project, "agent": agent, "ref": ref, "value": True}): + print(f"console_human: ticked '{ref}' in {project}/{agent}", file=sys.stderr) + return True + return False + + +cfg = json.loads(actions_path.read_text()) +pending = [a for a in cfg.get("actions", [])] +deadline = time.monotonic() + TIMEOUT_S +while pending and time.monotonic() < deadline: + for a in list(pending): + project, _, agent = a["scope"].partition("/") + done = try_click(project, agent, a["ref"]) if a["kind"] == "click_suggestion" \ + else try_tick(project, agent, a["ref"]) + if done: + pending.remove(a) + if pending: + time.sleep(POLL_S) +for a in pending: + print(f"console_human: TIMEOUT — {a['kind']} {a['scope']} '{a['ref']}' never became possible", file=sys.stderr) diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index 63a8b1f..3969200 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -55,7 +55,7 @@ benchmarks: # This is the trustworthy signal — the rubric judge below can be fooled/masked, so gate on # an exact string that ONLY appears if the agent read + applied the console note. - type: command - command: 'termchart pull --project tc-inbox --agent prompted --json 2>/dev/null | grep -qi "beam me in"' + command: 'termchart pull --project tc-inbox --agent prompted 2>/dev/null | grep -qi "beam me in"' expected_exit_code: 0 # Rubric judge — did the board reflect the console request (banner + relabeled button)? - type: command @@ -92,7 +92,7 @@ benchmarks: # DETERMINISTIC (no LLM): board must literally contain the console-only button label. Passes # ONLY if the agent noticed the 📬 nudge, read the inbox, and applied it — unfoolable. - type: command - command: 'termchart pull --project tc-inbox --agent unprompted --json 2>/dev/null | grep -qi "beam me in"' + command: 'termchart pull --project tc-inbox --agent unprompted 2>/dev/null | grep -qi "beam me in"' expected_exit_code: 0 - type: command command: 'tc-judge --project tc-inbox --agent unprompted --rubric-name inbox-login --threshold 0.66' @@ -123,8 +123,93 @@ benchmarks: simulant_knowledge_system: *sks validation_objectives: - type: command - command: 'termchart pull --project tc-inbox --agent midtask --json 2>/dev/null | grep -qi "beam me in"' + command: 'termchart pull --project tc-inbox --agent midtask 2>/dev/null | grep -qi "beam me in"' expected_exit_code: 0 - type: command command: 'tc-judge --project tc-inbox --agent midtask --rubric-name inbox-login --threshold 0.66' expected_exit_code: 0 + +# 4) DECIDE — agent→human chips + blocking wait. The agent posts two suggestion chips and BLOCKS on +# `termchart inbox --wait`; console_human.py clicks "green" once the chips are up (GET /suggest → +# POST /inbox action). The chosen color reaches the agent ONLY through the console. +- benchmark_type: interactive_simulation + id: TC-INBOX-DECIDE + case_data: + name: "inbox: suggest chips + block on the human's click" + id: TC-INBOX-DECIDE + description: "Agent asks the human to decide via chips, waits for the click, applies the choice." + requirements: ["Post suggestion chips, block on inbox --wait, apply the clicked choice, reply."] + complexity: { architecture: ARCH_SINGLE, mutation_risk: RISK_READ_ONLY, tool_depth: DEPTH_DISCOVERY, environment: ENV_EMPTY, adk_api_tier: ADK_T1 } + environment_config: { resources: [], verification_steps: [] } + agent_generation: { requirements: [], example_conversations: [], prompt_template: "direct", output_format: { type: direct }, max_generation_turns: 1 } + conversations: + - id: decide + name: Login screen, human picks the button color + initial_prompt: > + Draw a login screen for a web app on the termchart viewer, scope exactly + `--project tc-inbox --agent decide` (you choose the --type). I want to pick the + sign-in button color myself from my screen: post exactly two suggestion chips with + ids "blue" and "green" (labels "Blue"/"Green") using `termchart suggest`, then block + on `termchart inbox --project tc-inbox --agent decide --wait` (re-run it if it + returns empty, up to ~2 minutes) until my click arrives as an action event. Set the + sign-in button's color prop to the clicked id (the literal word) and re-push, then + post a one-line `termchart reply` naming the color you applied. + simulant_goal: > + Get the agent to post the chips, block on the inbox, apply the clicked color, and + reply. NEVER say which color you prefer in chat — the choice arrives only via the + console click (which happens on your screen, outside this conversation). If the agent + asks you to pick in chat, tell it "I'll click on my screen — wait for it". Close as + soon as it reports it applied the clicked color and replied, or reports an error. + simulant_knowledge_system: *sks + validation_objectives: + # DETERMINISTIC: the button color prop is the clicked chip's id ("green"). + - type: command + command: 'termchart pull --project tc-inbox --agent decide 2>/dev/null | grep -qiE ''"color"[^,}]*green''' + expected_exit_code: 0 + - type: command + command: 'tc-judge --project tc-inbox --agent decide --rubric-name inbox-decide --threshold 0.66' + expected_exit_code: 0 + - type: command + command: 'termchart inbox --project tc-inbox --agent decide --peek --json 2>/dev/null | grep -q ''"kind": "reply"''' + expected_exit_code: 0 + +# 5) TICK — human ticks an editable Checklist on their screen (POST /interact; NO inbox event is +# produced by design), so the agent must observe state: poll `termchart pull` until an item is +# checked, then name it in a reply. console_human.py ticks "steps/order" once the board exists. +- benchmark_type: interactive_simulation + id: TC-INBOX-TICK + case_data: + name: "inbox: observe a human checklist tick via pull" + id: TC-INBOX-TICK + description: "Human ticks a checklist item out-of-band; agent detects it by polling pull and replies." + requirements: ["Push an editable checklist, poll pull until the human's tick appears, reply naming it."] + complexity: { architecture: ARCH_SINGLE, mutation_risk: RISK_READ_ONLY, tool_depth: DEPTH_DISCOVERY, environment: ENV_EMPTY, adk_api_tier: ADK_T1 } + environment_config: { resources: [], verification_steps: [] } + agent_generation: { requirements: [], example_conversations: [], prompt_template: "direct", output_format: { type: direct }, max_generation_turns: 1 } + conversations: + - id: tick + name: Build-steps checklist, human ticks one + initial_prompt: > + Push a `component` board to scope exactly `--project tc-inbox --agent tick`: a + Checklist titled "Build steps" whose props are exactly + id "steps", editable true, and items with ids/labels "specs", "order", "assemble" + (shape: {"type":"Checklist","props":{"id":"steps","editable":true,"items":[{"id":"specs","label":"specs"},…]}}). + I'll tick the step I've already finished on my screen. Poll + `termchart pull --project tc-inbox --agent tick --json` every few seconds (up to + ~2 minutes) until one item shows "checked": true, then post a one-line + `termchart reply` naming exactly which item I ticked. + simulant_goal: > + Get the agent to push the checklist, wait for the human's tick (it happens on the + human's screen, outside this conversation), and reply naming the ticked item. NEVER + say which item is done in chat. If the agent asks, say "it's ticked on my screen — + check the board". Close as soon as it replies naming an item, or reports an error. + simulant_knowledge_system: *sks + validation_objectives: + # Harness sanity: the human's tick actually landed in the stored spec. + - type: command + command: 'termchart pull --project tc-inbox --agent tick 2>/dev/null | grep -q ''"checked": *true''' + expected_exit_code: 0 + # DETERMINISTIC: the agent's reply names the ticked item ("order"). + - type: command + command: 'termchart inbox --project tc-inbox --agent tick --peek --json 2>/dev/null | grep -A3 ''"kind": "reply"'' | grep -qi order' + expected_exit_code: 0 diff --git a/scripts/experiments/agent-compat/definitions/console_human.json b/scripts/experiments/agent-compat/definitions/console_human.json new file mode 100644 index 0000000..083951d --- /dev/null +++ b/scripts/experiments/agent-compat/definitions/console_human.json @@ -0,0 +1,7 @@ +{ + "_comment": "Out-of-band human actions for the interactive console cases (played by console_human.py). The chip ids / checklist ref are pinned by each case's initial_prompt so the watcher knows what to look for; the AGENT is never told which one the human picks — that information only arrives via the console/interact channel.", + "actions": [ + {"kind": "click_suggestion", "scope": "tc-inbox/decide", "ref": "green"}, + {"kind": "tick_checklist", "scope": "tc-inbox/tick", "ref": "steps/order"} + ] +} diff --git a/scripts/experiments/agent-compat/rubrics/inbox-decide.json b/scripts/experiments/agent-compat/rubrics/inbox-decide.json new file mode 100644 index 0000000..9384998 --- /dev/null +++ b/scripts/experiments/agent-compat/rubrics/inbox-decide.json @@ -0,0 +1,5 @@ +[ + {"key": "button_green", "desc": "The primary sign-in button is green (the human clicked the 'Green' chip). This choice arrives ONLY via the console click — the agent could not know it otherwise."}, + {"key": "not_blue", "desc": "The primary sign-in button is NOT blue (the un-chosen chip)."}, + {"key": "login_screen", "desc": "It reads as a login screen (a sign-in title/heading and email + password areas)"} +] diff --git a/scripts/experiments/agent-compat/run.sh b/scripts/experiments/agent-compat/run.sh index c756c3f..5e63a60 100755 --- a/scripts/experiments/agent-compat/run.sh +++ b/scripts/experiments/agent-compat/run.sh @@ -223,12 +223,15 @@ for b in "${BACKENDS[@]}"; do # BEFORE the agent runs, so it discovers them (the 📬 nudge on its push, or `termchart inbox`). # `@await` scopes get a background watcher that posts the seed only once the agent's board # appears — mid-task human input (killed after the runner finishes so it never leaks). - SEED_WATCHER_PID="" + SEED_WATCHER_PIDS="" if [ "${SEED_INBOX:-}" = 1 ]; then TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --immediate \ && log "seeded console inbox for the inbox eval" || log "warn: inbox seed failed" TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --await & - SEED_WATCHER_PID=$! + SEED_WATCHER_PIDS="$!" + # The simulated human for the interactive cases (chip clicks, checklist ticks). + TERMCHART_VIEWER_URL="$VURL" python3 "$SCRIPT_DIR/console_human.py" & + SEED_WATCHER_PIDS="$SEED_WATCHER_PIDS $!" fi if "${RUNNER[@]}" \ --config-dir "$DEF_DIR" \ @@ -242,7 +245,7 @@ for b in "${BACKENDS[@]}"; do else RESULT[$b]="FAIL (exit $?)" fi - [ -n "$SEED_WATCHER_PID" ] && kill "$SEED_WATCHER_PID" 2>/dev/null + [ -n "$SEED_WATCHER_PIDS" ] && kill $SEED_WATCHER_PIDS 2>/dev/null log "backend $b -> ${RESULT[$b]}" done From 6d7380730058dc2e98c663448a0a18430fb77206 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 02:39:58 +0000 Subject: [PATCH 03/11] agent-compat: DECIDE deterministic gate greps the clicked id, not a prop name Run 1 near-miss: the flow worked end-to-end (chips, --wait click, green button per judge, reply) but the agent expressed the color without a literal "color" prop. Gate on content containing the clicked id and NOT the un-clicked one (blue is also Mantine's default primary, so a click-blind agent still fails). --- .../agent-compat/definitions/cases/termchart_inbox.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index 3969200..a340bda 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -162,9 +162,11 @@ benchmarks: soon as it reports it applied the clicked color and replied, or reports an error. simulant_knowledge_system: *sks validation_objectives: - # DETERMINISTIC: the button color prop is the clicked chip's id ("green"). + # DETERMINISTIC: the spec carries the clicked id ("green") and NOT the un-clicked one + # ("blue" — also Mantine's default primary, so a click-blind agent fails this). Agents + # express the color via different props (color/c/bg), so don't anchor on a prop name. - type: command - command: 'termchart pull --project tc-inbox --agent decide 2>/dev/null | grep -qiE ''"color"[^,}]*green''' + command: 'C=$(termchart pull --project tc-inbox --agent decide 2>/dev/null); echo "$C" | grep -qi green && ! echo "$C" | grep -qi blue' expected_exit_code: 0 - type: command command: 'tc-judge --project tc-inbox --agent decide --rubric-name inbox-decide --threshold 0.66' From 483324312fe9c3d065f5de2cb00a5d085b944873 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 02:51:55 +0000 Subject: [PATCH 04/11] =?UTF-8?q?agent-compat:=20CASE=5FISOLATED=20mode=20?= =?UTF-8?q?=E2=80=94=20one=20clear+seed=20cycle=20per=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbox cases share one viewer/workspace; an agent following the list unread-summary in one case can consume another case's seed (observed: DECIDE read UNPROMPTED's seed before that case ran -> false FAIL). Singleton case-sets + a per-set clear/seed/watch/run cycle make that state-sharing unrepresentable. --- .../agent-compat/definitions/case_sets.yaml | 10 +++ scripts/experiments/agent-compat/run.sh | 64 +++++++++++-------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/scripts/experiments/agent-compat/definitions/case_sets.yaml b/scripts/experiments/agent-compat/definitions/case_sets.yaml index c8cdfb8..4d4eb06 100644 --- a/scripts/experiments/agent-compat/definitions/case_sets.yaml +++ b/scripts/experiments/agent-compat/definitions/case_sets.yaml @@ -64,3 +64,13 @@ case_sets: termchart-inbox: description: "Bidirectional console eval (issue #108) — read a pre-seeded console note + reply; needs SEED_INBOX=1" patterns: ['^TC-INBOX-'] + + # Singleton sets for ISOLATED inbox runs (CASE_ISOLATED=1): the interactive cases share one + # viewer/workspace, and an agent following the list unread-summary in one case can consume + # another case's seed (observed: DECIDE's agent read UNPROMPTED's seed before it ran). One + # case per runner invocation, with a clear+seed cycle between, removes the shared state. + termchart-inbox-prompted: { case_ids: ["TC-INBOX-PROMPTED"] } + termchart-inbox-unprompted: { case_ids: ["TC-INBOX-UNPROMPTED"] } + termchart-inbox-midtask: { case_ids: ["TC-INBOX-MIDTASK"] } + termchart-inbox-decide: { case_ids: ["TC-INBOX-DECIDE"] } + termchart-inbox-tick: { case_ids: ["TC-INBOX-TICK"] } diff --git a/scripts/experiments/agent-compat/run.sh b/scripts/experiments/agent-compat/run.sh index 5e63a60..fc56263 100755 --- a/scripts/experiments/agent-compat/run.sh +++ b/scripts/experiments/agent-compat/run.sh @@ -218,34 +218,48 @@ for b in "${BACKENDS[@]}"; do log "backend $b -> ${RESULT[$b]}" continue fi - "$BIN/termchart" clear --all >/dev/null 2>&1 || log "warn: viewer clear failed (continuing)" - # Inbox eval: seed the human's console messages AFTER the clear (which wipes the inbox ring) and - # BEFORE the agent runs, so it discovers them (the 📬 nudge on its push, or `termchart inbox`). - # `@await` scopes get a background watcher that posts the seed only once the agent's board - # appears — mid-task human input (killed after the runner finishes so it never leaks). - SEED_WATCHER_PIDS="" - if [ "${SEED_INBOX:-}" = 1 ]; then - TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --immediate \ - && log "seeded console inbox for the inbox eval" || log "warn: inbox seed failed" - TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --await & - SEED_WATCHER_PIDS="$!" - # The simulated human for the interactive cases (chip clicks, checklist ticks). - TERMCHART_VIEWER_URL="$VURL" python3 "$SCRIPT_DIR/console_human.py" & - SEED_WATCHER_PIDS="$SEED_WATCHER_PIDS $!" - fi - if "${RUNNER[@]}" \ - --config-dir "$DEF_DIR" \ - --case-set "$CASE_SET" \ - --generator-set "$b" \ - --name "compat-$b" \ - --user agent-compat \ - --concurrency "${CONCURRENCY:-1}" \ - --require-all-pass; then + # One clear+seed+run cycle. Inbox eval (SEED_INBOX=1): seed the human's console messages AFTER + # the clear (which wipes the inbox ring) and BEFORE the agent runs. `@await` scopes get a + # background watcher that posts the seed only once the agent's board appears (mid-task human + # input); console_human.py plays the human for the interactive cases (chip clicks, checklist + # ticks). Watchers are killed after the runner so they never leak. + run_case_set() { + local cs="$1" + "$BIN/termchart" clear --all >/dev/null 2>&1 || log "warn: viewer clear failed (continuing)" + local watcher_pids="" + if [ "${SEED_INBOX:-}" = 1 ]; then + TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --immediate \ + && log "seeded console inbox for the inbox eval" || log "warn: inbox seed failed" + TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" --await & + watcher_pids="$!" + TERMCHART_VIEWER_URL="$VURL" python3 "$SCRIPT_DIR/console_human.py" & + watcher_pids="$watcher_pids $!" + fi + local rc=0 + "${RUNNER[@]}" \ + --config-dir "$DEF_DIR" \ + --case-set "$cs" \ + --generator-set "$b" \ + --name "compat-$b" \ + --user agent-compat \ + --concurrency "${CONCURRENCY:-1}" \ + --require-all-pass || rc=$? + [ -n "$watcher_pids" ] && kill $watcher_pids 2>/dev/null + return $rc + } + # CASE_ISOLATED=1: run each comma-separated case-set in its own clear+seed cycle. The inbox + # cases share one viewer/workspace, and an agent following the list unread-summary in one case + # can consume another case's seed (observed: DECIDE's agent read UNPROMPTED's seed before that + # case ran). Isolation makes the shared-state class of false FAILs unrepresentable. + if [ "${CASE_ISOLATED:-}" = 1 ]; then RESULT[$b]="PASS" + IFS=',' read -ra _SETS <<< "$CASE_SET" + for _cs in "${_SETS[@]}"; do + if ! run_case_set "$_cs"; then RESULT[$b]="FAIL (set $_cs)"; fi + done else - RESULT[$b]="FAIL (exit $?)" + if run_case_set "$CASE_SET"; then RESULT[$b]="PASS"; else RESULT[$b]="FAIL (exit $?)"; fi fi - [ -n "$SEED_WATCHER_PIDS" ] && kill $SEED_WATCHER_PIDS 2>/dev/null log "backend $b -> ${RESULT[$b]}" done From 7d1f0003bb69490e7658a8c9c6e55fe990eded88 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 03:00:10 +0000 Subject: [PATCH 05/11] agent-compat: TICK prompt points at raw pull (not --json) + explicit naming requirement Run-1 miss was real signal: the agent parsed pull --json (content is a double-encoded string), extracted an empty item id, and replied 'You checked: '. Keep the strict objective; stop steering the agent into the harder surface. --- .../agent-compat/definitions/cases/termchart_inbox.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index a340bda..b6b98e6 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -197,9 +197,10 @@ benchmarks: id "steps", editable true, and items with ids/labels "specs", "order", "assemble" (shape: {"type":"Checklist","props":{"id":"steps","editable":true,"items":[{"id":"specs","label":"specs"},…]}}). I'll tick the step I've already finished on my screen. Poll - `termchart pull --project tc-inbox --agent tick --json` every few seconds (up to + `termchart pull --project tc-inbox --agent tick` every few seconds (up to ~2 minutes) until one item shows "checked": true, then post a one-line - `termchart reply` naming exactly which item I ticked. + `termchart reply` naming exactly which item I ticked (its id/label must + appear in the reply text). simulant_goal: > Get the agent to push the checklist, wait for the human's tick (it happens on the human's screen, outside this conversation), and reply naming the ticked item. NEVER From 71727e39ea7838163baf0b8fba126625daff1dc9 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 8 Jul 2026 03:20:37 +0000 Subject: [PATCH 06/11] agent-compat: TICK prompt stops teaching an unmatchable grep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario said the item 'shows "checked": true' — spaced — but interact re-stringifies the spec compact ("checked":true), so agents that grepped the prompt's literal form polled for 2 minutes against a pattern that cannot match. Say compact explicitly; the parse robustness burden stays on the agent. --- .../agent-compat/definitions/cases/termchart_inbox.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index b6b98e6..447031e 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -198,7 +198,8 @@ benchmarks: (shape: {"type":"Checklist","props":{"id":"steps","editable":true,"items":[{"id":"specs","label":"specs"},…]}}). I'll tick the step I've already finished on my screen. Poll `termchart pull --project tc-inbox --agent tick` every few seconds (up to - ~2 minutes) until one item shows "checked": true, then post a one-line + ~2 minutes) until one of the items becomes checked (the stored spec is + COMPACT json — "checked":true with no space), then post a one-line `termchart reply` naming exactly which item I ticked (its id/label must appear in the reply text). simulant_goal: > From c632e69e0b7c7f512ad977fa1093da5f7bed3941 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 9 Jul 2026 01:44:35 +0000 Subject: [PATCH 07/11] cli: --follow resyncs on server ring reset; agent-compat: heavy --follow coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical battery (9/9): delivery/order, ack-advance, SIGINT<3s, --json lines, two concurrent followers, backoff growth on server death, RESTART recovery, auto-provisioned-wsid semantics. It caught a real bug: after a viewer restart (or clear) the server seq regresses and a live follower's stale cursor filtered the first post-restart message out FOREVER. follow() now detects the regression (cursor < ours, empty events), logs 'server ring reset', rewinds to 0, and re-polls — unit-tested via the maxPolls hook (sinces 0,7,0). Two agent cases: TC-INBOX-FOLLOW (background follow catches a mid-work @await seed while multi-step drawing) and TC-INBOX-FOLLOWDECIDE (DECIDE forced through --follow, no --wait — the agent must bound the unbounded stream itself). The pair measures whether --follow can stand in for --wait under real agents. --- packages/cli/src/inbox.ts | 8 ++ packages/cli/test/inbox.test.ts | 29 ++++++ .../agent-compat/definitions/case_sets.yaml | 2 + .../definitions/cases/termchart_inbox.yaml | 89 +++++++++++++++++++ .../definitions/console_human.json | 21 ++++- .../agent-compat/definitions/inbox_seeds.json | 27 ++++-- 6 files changed, 166 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/inbox.ts b/packages/cli/src/inbox.ts index d80b139..7b71ffd 100644 --- a/packages/cli/src/inbox.ts +++ b/packages/cli/src/inbox.ts @@ -244,6 +244,14 @@ async function follow( continue; } backoff = 0; // healthy round + // Cursor REGRESSION = the server's ring restarted under us (viewer restart, `clear`): seq + // resets, and the first post-restart events sit BELOW our stale cursor — since= would + // filter them out forever. Rewind to 0 and re-poll now; the fresh ring holds only new events. + if (data.cursor < cursor && data.events.length === 0) { + process.stderr.write(`inbox: server ring reset (cursor ${data.cursor} < ${cursor}); resyncing…\n`); + cursor = 0; + continue; + } for (const e of data.events) { process.stdout.write((a.json ? JSON.stringify(e) : fmt(e)) + "\n"); } diff --git a/packages/cli/test/inbox.test.ts b/packages/cli/test/inbox.test.ts index a5322be..f859d91 100644 --- a/packages/cli/test/inbox.test.ts +++ b/packages/cli/test/inbox.test.ts @@ -195,6 +195,35 @@ describe("inbox", () => { expect(out.join("")).toContain("rerun"); }); + it("--follow resyncs when the server's cursor regresses (ring reset under a live follower)", async () => { + // Poll 1: normal (cursor 7). Poll 2: server restarted — empty events, cursor 1 (< 7). A stale + // since=7 would filter the fresh ring's first message forever; the client must rewind to 0. + // Poll 3 (since=0 after resync): delivers the post-restart message. + let hits = 0; + const sinces: string[] = []; + server = createServer((req, res) => { + hits++; + sinces.push(new URL(req.url!, "http://x").searchParams.get("since") ?? ""); + const body = hits === 1 + ? { events: [{ seq: 7, ts: 1, kind: "message", text: "pre-restart" }], cursor: 7 } + : hits === 2 + ? { events: [], cursor: 1 } + : { events: [{ seq: 1, ts: 2, kind: "message", text: "post-restart" }], cursor: 1 }; + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body)); + }); + const url: string = await new Promise((r) => server!.listen(0, () => r(`http://127.0.0.1:${(server!.address() as AddressInfo).port}`))); + const out: string[] = []; + const errs: string[] = []; + const so = vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + const se = vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + const code = await inbox(["--project", "p", "--agent", "a", "--follow"], { env: env(url), maxPolls: 3 }); + so.mockRestore(); se.mockRestore(); + expect(code).toBe(0); + expect(errs.join("")).toContain("server ring reset"); + expect(sinces).toEqual(["0", "7", "0"]); // rewound after the regression + expect(out.join("")).toContain("post-restart"); + }); + it("--follow throttles (does not hot-loop) when the server returns events but never advances the cursor", async () => { // Misbehaving server: always returns one event with a fixed cursor=1. server = createServer((_req, res) => res.writeHead(200, { "content-type": "application/json" }) diff --git a/scripts/experiments/agent-compat/definitions/case_sets.yaml b/scripts/experiments/agent-compat/definitions/case_sets.yaml index 4d4eb06..09c4fe9 100644 --- a/scripts/experiments/agent-compat/definitions/case_sets.yaml +++ b/scripts/experiments/agent-compat/definitions/case_sets.yaml @@ -74,3 +74,5 @@ case_sets: termchart-inbox-midtask: { case_ids: ["TC-INBOX-MIDTASK"] } termchart-inbox-decide: { case_ids: ["TC-INBOX-DECIDE"] } termchart-inbox-tick: { case_ids: ["TC-INBOX-TICK"] } + termchart-inbox-follow: { case_ids: ["TC-INBOX-FOLLOW"] } + termchart-inbox-followdecide: { case_ids: ["TC-INBOX-FOLLOWDECIDE"] } diff --git a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml index 447031e..ebdf1ad 100644 --- a/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml +++ b/scripts/experiments/agent-compat/definitions/cases/termchart_inbox.yaml @@ -217,3 +217,92 @@ benchmarks: - type: command command: 'termchart inbox --project tc-inbox --agent tick --peek --json 2>/dev/null | grep -A3 ''"kind": "reply"'' | grep -qi order' expected_exit_code: 0 + +# 6) FOLLOW — the streaming listener under a real agent, headless. The agent must run +# `inbox --follow` as a BACKGROUND process (it never exits on its own) while doing multi-step +# work; the human's message arrives mid-work (@await seed) and lands in the follow output, which +# the agent must check before finishing. Tests whether follow is operable at all from a +# turn-based tool loop — the crux of the "can follow replace wait" question. +- benchmark_type: interactive_simulation + id: TC-INBOX-FOLLOW + case_data: + name: "inbox: background --follow catches a mid-work message" + id: TC-INBOX-FOLLOW + description: "Agent streams the inbox in the background while working; message arrives mid-work." + requirements: ["Run inbox --follow in the background, catch the mid-work message, apply + reply."] + complexity: { architecture: ARCH_SINGLE, mutation_risk: RISK_READ_ONLY, tool_depth: DEPTH_DISCOVERY, environment: ENV_EMPTY, adk_api_tier: ADK_T1 } + environment_config: { resources: [], verification_steps: [] } + agent_generation: { requirements: [], example_conversations: [], prompt_template: "direct", output_format: { type: direct }, max_generation_turns: 1 } + conversations: + - id: follow + name: Login screen while listening + initial_prompt: > + I might send you notes through the viewer console while you work, so FIRST start the + listener in the background, capturing its output to a file: + `termchart inbox --project tc-inbox --agent follow --follow > /tmp/follow.log 2>&1 &` + (it streams events and never exits on its own — do NOT run it in the foreground). + Then draw a login screen for a web app at exactly `--project tc-inbox --agent follow` + (you choose the --type), and improve it in at least two separate pushes/patches ~15 + seconds apart (e.g. refine spacing, then add a footer). Before you finish: stop the + listener, read /tmp/follow.log, apply anything I asked for in it to the board, and post + a one-line `termchart reply` saying what you changed. + simulant_goal: > + Get the agent to run the background listener, do the multi-step drawing work, apply the + console note that arrives mid-work, and reply. Do NOT restate the note's content in chat + (it must come from the follow output). Close as soon as it reports it applied the note + and replied, or reports an error. + simulant_knowledge_system: *sks + validation_objectives: + - type: command + command: 'termchart pull --project tc-inbox --agent follow 2>/dev/null | grep -qi "beam me in"' + expected_exit_code: 0 + - type: command + command: 'tc-judge --project tc-inbox --agent follow --rubric-name inbox-login --threshold 0.66' + expected_exit_code: 0 + - type: command + command: 'termchart inbox --project tc-inbox --agent follow --peek --json 2>/dev/null | grep -q ''"kind": "reply"''' + expected_exit_code: 0 + +# 7) FOLLOWDECIDE — DECIDE forced through --follow instead of --wait. Directly probes "can --wait +# be removed in lieu of --follow": the agent must bound the unbounded stream itself (background +# + poll the file, or timeout(1)) to extract one click event. Compare failure modes vs DECIDE. +- benchmark_type: interactive_simulation + id: TC-INBOX-FOLLOWDECIDE + case_data: + name: "inbox: receive the human's click via --follow (no --wait)" + id: TC-INBOX-FOLLOWDECIDE + description: "Chips + follow-based reception of the click — follow standing in for wait." + requirements: ["Post chips, receive the click via --follow (not --wait), apply the choice, reply."] + complexity: { architecture: ARCH_SINGLE, mutation_risk: RISK_READ_ONLY, tool_depth: DEPTH_DISCOVERY, environment: ENV_EMPTY, adk_api_tier: ADK_T1 } + environment_config: { resources: [], verification_steps: [] } + agent_generation: { requirements: [], example_conversations: [], prompt_template: "direct", output_format: { type: direct }, max_generation_turns: 1 } + conversations: + - id: followdecide + name: Login screen, click received via follow + initial_prompt: > + Draw a login screen for a web app at exactly `--project tc-inbox --agent followdecide` + (you choose the --type). I want to pick the sign-in button color from my screen: post + exactly two suggestion chips with ids "blue" and "green" (labels "Blue"/"Green") via + `termchart suggest`. Then receive my click using + `termchart inbox --project tc-inbox --agent followdecide --follow` — NOT --wait. + Careful: --follow streams forever and never exits on its own, so bound it yourself + (background it to a file and poll, or wrap it in a timeout). When my click arrives as + an action event, set the sign-in button's color to the clicked id and re-push, then + post a one-line `termchart reply` naming the color. + simulant_goal: > + Get the agent to post the chips, receive the click through --follow (never --wait), + apply the clicked color, and reply. NEVER say which color you prefer in chat — the + choice arrives only via the console click on your screen. If the agent asks you to pick + in chat, tell it "I'll click on my screen — watch the stream". Close as soon as it + reports it applied the clicked color and replied, or reports an error. + simulant_knowledge_system: *sks + validation_objectives: + - type: command + command: 'C=$(termchart pull --project tc-inbox --agent followdecide 2>/dev/null); echo "$C" | grep -qi green && ! echo "$C" | grep -qi blue' + expected_exit_code: 0 + - type: command + command: 'tc-judge --project tc-inbox --agent followdecide --rubric-name inbox-decide --threshold 0.66' + expected_exit_code: 0 + - type: command + command: 'termchart inbox --project tc-inbox --agent followdecide --peek --json 2>/dev/null | grep -q ''"kind": "reply"''' + expected_exit_code: 0 diff --git a/scripts/experiments/agent-compat/definitions/console_human.json b/scripts/experiments/agent-compat/definitions/console_human.json index 083951d..9b9dcbc 100644 --- a/scripts/experiments/agent-compat/definitions/console_human.json +++ b/scripts/experiments/agent-compat/definitions/console_human.json @@ -1,7 +1,20 @@ { - "_comment": "Out-of-band human actions for the interactive console cases (played by console_human.py). The chip ids / checklist ref are pinned by each case's initial_prompt so the watcher knows what to look for; the AGENT is never told which one the human picks — that information only arrives via the console/interact channel.", + "_comment": "Out-of-band human actions for the interactive console cases (played by console_human.py). The chip ids / checklist ref are pinned by each case's initial_prompt so the watcher knows what to look for; the AGENT is never told which one the human picks \u2014 that information only arrives via the console/interact channel.", "actions": [ - {"kind": "click_suggestion", "scope": "tc-inbox/decide", "ref": "green"}, - {"kind": "tick_checklist", "scope": "tc-inbox/tick", "ref": "steps/order"} + { + "kind": "click_suggestion", + "scope": "tc-inbox/decide", + "ref": "green" + }, + { + "kind": "tick_checklist", + "scope": "tc-inbox/tick", + "ref": "steps/order" + }, + { + "kind": "click_suggestion", + "scope": "tc-inbox/followdecide", + "ref": "green" + } ] -} +} \ No newline at end of file diff --git a/scripts/experiments/agent-compat/definitions/inbox_seeds.json b/scripts/experiments/agent-compat/definitions/inbox_seeds.json index ad75051..1dadab0 100644 --- a/scripts/experiments/agent-compat/definitions/inbox_seeds.json +++ b/scripts/experiments/agent-compat/definitions/inbox_seeds.json @@ -1,13 +1,28 @@ { - "_comment": "Console asks are deliberately things a default login screen would NEVER contain, so the board can only satisfy the rubric if the agent actually read the console (not by drawing a nice generic login form). A 'Forgot password?'/'Sign in with Google' seed is a false-positive trap — see git history.", + "_comment": "Console asks are deliberately things a default login screen would NEVER contain, so the board can only satisfy the rubric if the agent actually read the console (not by drawing a nice generic login form). A 'Forgot password?'/'Sign in with Google' seed is a false-positive trap \u2014 see git history.", "tc-inbox/prompted": [ - {"kind": "message", "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'."} + { + "kind": "message", + "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'." + } ], "tc-inbox/unprompted": [ - {"kind": "message", "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'."} + { + "kind": "message", + "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'." + } ], - "_comment_await": "@await scopes are seeded only AFTER the agent's board appears on the viewer (mid-task human input). The write-path nudge can't fire for them if the agent is done writing — the `termchart list` unread summary / final-check is what must catch it.", + "_comment_await": "@await scopes are seeded only AFTER the agent's board appears on the viewer (mid-task human input). The write-path nudge can't fire for them if the agent is done writing \u2014 the `termchart list` unread summary / final-check is what must catch it.", "tc-inbox/midtask@await": [ - {"kind": "message", "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'."} + { + "kind": "message", + "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'." + } + ], + "tc-inbox/follow@await": [ + { + "kind": "message", + "text": "Two changes: (1) add a yellow notice banner at the very top reading exactly 'Scheduled maintenance Sunday 2-4am PT', and (2) change the sign-in button label to 'Beam me in'." + } ] -} +} \ No newline at end of file From b3222c330a219072a24a1fc4d5fbd847d41b5d72 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 9 Jul 2026 01:55:20 +0000 Subject: [PATCH 08/11] cli(suggest): unknown-flag error names the real chip forms FOLLOWDECIDE r1: Claude guessed --id/--label/--type chips, got bare 'Unknown flag' errors, and burned its whole follow window on the guess-retry loop. --- packages/cli/src/suggest.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/suggest.ts b/packages/cli/src/suggest.ts index a8f9320..e94028f 100644 --- a/packages/cli/src/suggest.ts +++ b/packages/cli/src/suggest.ts @@ -20,7 +20,11 @@ function parse(argv: string[]): Args { // --items / --item carry chip text ("id:label"), which is free-form. else if (arg === "--items") take(arg, (v) => (a.items = v), ++i, true); else if (arg === "--item") take(arg, (v) => a.itemList.push(v), ++i, true); - else a.error = `Unknown flag: ${arg}`; + // Agents guess per-chip interfaces (--id/--label/--type) and a bare "Unknown flag" sends them + // into a guess-retry loop — name the real forms right in the error (same lesson as reply's + // positional text: the recovery hint must be in the output the agent is already reading). + else a.error = `Unknown flag: ${arg} — chips are passed via --items '["Approve","Hold"]' ` + + `(or '[{"id":"a","label":"Approve"}]'), or repeated --item id:label`; } return a; } From d30b547adbfcac1e0cc23fa36327ba6b38777b54 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 9 Jul 2026 02:15:58 +0000 Subject: [PATCH 09/11] agent-compat: mechanical stress battery for inbox --follow (9 checks) --- .../agent-compat/follow_battery.sh | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100755 scripts/experiments/agent-compat/follow_battery.sh diff --git a/scripts/experiments/agent-compat/follow_battery.sh b/scripts/experiments/agent-compat/follow_battery.sh new file mode 100755 index 0000000..a706ef6 --- /dev/null +++ b/scripts/experiments/agent-compat/follow_battery.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Mechanical stress battery for `termchart inbox --follow` +# Mechanical stress battery for `termchart inbox --follow` — delivery/order, ack, signals, +# --json, concurrent followers, backoff, server-restart resync, auto-provisioned wsids. +# Usage: bash follow_battery.sh [workdir] (builds NOT included — build cli first) +WT=$(cd "$(dirname "$0")/../../.." && pwd) # repo root +T="${1:-$(mktemp -d)}" +PORT=39471 +tc(){ node "$WT/packages/cli/dist/cli.js" "$@"; } +export TERMCHART_VIEWER_URL="http://localhost:$PORT/w/local" TERMCHART_VIEWER_TOKEN=dev +serve(){ (cd "$WT" && exec node packages/cli/dist/cli.js serve --port $PORT --token dev) >"$T/serve_$1.log" 2>&1 & echo $!; } +post(){ curl -s -X POST "$TERMCHART_VIEWER_URL/inbox" -H 'content-type: application/json' \ + -d "{\"project\":\"fw\",\"agent\":\"a\",\"kind\":\"message\",\"text\":\"$1\"}" >/dev/null; } +pass=0; fail=0 +ck(){ if eval "$2"; then echo "PASS: $1"; pass=$((pass+1)); else echo "FAIL: $1"; fail=$((fail+1)); fi } + +SP=$(serve t1); sleep 5 + +# T1: delivery + ordering + no dups (5 messages while following) +node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow > "$T/t1.out" 2>"$T/t1.err" & F=$! +sleep 2 +for i in 1 2 3 4 5; do post "msg-$i"; sleep 0.4; done +sleep 3 +ck "T1 all 5 delivered in order" '[ "$(grep -oE "msg-[0-9]" "$T/t1.out" | tr -d "\n")" = "msg-1msg-2msg-3msg-4msg-5" ]' + +# T2: follow advances the ack (a later push shows NO nudge) +echo '{"type":"Text","children":"x"}' > "$T/b.json" +tc push --project fw --agent a --type component --description d "$T/b.json" 2>"$T/t2.err" >/dev/null +ck "T2 follow acked (no nudge on next push)" '! grep -q 📬 "$T/t2.err"' + +# T3: SIGINT exits promptly even mid-poll +S=$(date +%s); kill -INT $F; for i in $(seq 1 50); do kill -0 $F 2>/dev/null || break; sleep 0.1; done; E=$(date +%s) +ck "T3 SIGINT exit < 3s (took $((E-S))s)" '[ $((E-S)) -le 3 ]' + +# T4: --json emits parseable event lines +node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow --json > "$T/t4.out" 2>/dev/null & F4=$! +sleep 2; post "json-check"; sleep 2; kill -INT $F4 2>/dev/null; sleep 1 +ck "T4 --json lines parse" 'python3 -c " +import json,sys +lines=[l for l in open(\"$T/t4.out\") if l.strip()] +assert lines and all(json.loads(l).get(\"kind\") for l in lines)"' + +# T5: two concurrent followers both receive +node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow > "$T/t5a.out" 2>/dev/null & FA=$! +node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow > "$T/t5b.out" 2>/dev/null & FB=$! +sleep 2; post "both-see-me"; sleep 3 +ck "T5 both followers got it" 'grep -q both-see-me "$T/t5a.out" && grep -q both-see-me "$T/t5b.out"' +kill -INT $FA $FB 2>/dev/null; sleep 1 + +# T6: backoff on server death (1s -> 2s -> 4s pattern in stderr), no exit +node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow > "$T/t6.out" 2>"$T/t6.err" & F6=$! +sleep 2; kill $SP 2>/dev/null; sleep 9 +ck "T6 reconnect lines with growing backoff" 'grep -q "reconnecting in 1s" "$T/t6.err" && grep -q "reconnecting in 2s" "$T/t6.err"' +ck "T6 follower still alive" 'kill -0 $F6 2>/dev/null' + +# T7: server RESTART on same port — does the follower resume and see NEW events? +SP=$(serve t7); sleep 5 +post "after-restart" +sleep 34 # reconnect long-poll holds ~25s with the stale cursor before the regression is visible +ck "T7 post-restart message delivered after resync" 'grep -q after-restart "$T/t6.out" && grep -q "server ring reset" "$T/t6.err"' +kill -INT $F6 2>/dev/null; sleep 1 + +# T8: unknown wsid AUTO-PROVISIONS (by design) — follow must stay alive and quiet, not crash +TERMCHART_VIEWER_URL="http://localhost:$PORT/w/nope" timeout 8 node "$WT/packages/cli/dist/cli.js" inbox --project fw --agent a --follow >"$T/t8.out" 2>"$T/t8.err"; RC=$? +ck "T8 unknown wsid: alive+quiet until timeout (rc=$RC, want 124)" '[ "$RC" = "124" ] && ! grep -qE "failed|retrying" "$T/t8.err"' + +kill $SP 2>/dev/null +echo "=== battery: $pass pass, $fail fail ===" From 1888e4e7d3799d64aba97dd9ed3978a79dcb22ae Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 9 Jul 2026 02:22:51 +0000 Subject: [PATCH 10/11] docs(inbox-watch): mechanism-1 example shows the current content-inline nudge --- plugin/skills/inbox-watch/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/skills/inbox-watch/SKILL.md b/plugin/skills/inbox-watch/SKILL.md index 81ddcec..baeeb9d 100644 --- a/plugin/skills/inbox-watch/SKILL.md +++ b/plugin/skills/inbox-watch/SKILL.md @@ -18,7 +18,9 @@ Your `termchart push` / `patch` / `status` returns an `x-termchart-inbox-unread` time you write after a new message arrives**; the CLI prints to stderr: ``` -📬 2 console messages from the human — run: termchart inbox --project --agent +📬 2 unread console messages from the human — ACT ON THIS BEFORE FINISHING: + › + Apply what it asks in this scope, mark it read with `termchart inbox …`, then acknowledge with `termchart reply …` ``` It nudges **once per new message** — it won't re-announce the same backlog on every subsequent write From 827a14839f5833229c54d9128e09416498de8361 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 9 Jul 2026 06:31:26 +0000 Subject: [PATCH 11/11] =?UTF-8?q?scripts:=20ci-local.sh=20=E2=80=94=20run?= =?UTF-8?q?=20the=20PR=20CI=20jobs=20locally=20via=20act=20(podman)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci-local.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100755 scripts/ci-local.sh diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100755 index 0000000..133a232 --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Run the PR-triggered CI jobs locally via act (rootless podman) — the pre-push gate. +# Usage: bash scripts/ci-local.sh [job] (job = overlap|build-test|e2e|corpus; default: all) +set -e +cd "$(dirname "$0")/.." +IMG="-P ubuntu-latest=catthehacker/ubuntu:act-latest" +run(){ echo "=== act: $* ==="; act pull_request $IMG "$@"; } +case "${1:-all}" in + overlap|e2e|corpus) run -j "$1" ;; + build-test) run -j build-test --matrix node-version:20; run -j build-test --matrix node-version:22 ;; + all) + run -j build-test --matrix node-version:20 + run -j build-test --matrix node-version:22 + run -j e2e + run -j overlap + run -j corpus ;; + *) echo "unknown job: $1 (overlap|build-test|e2e|corpus|all)"; exit 2 ;; +esac +echo "=== local CI: all requested jobs green ==="