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/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/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/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; } 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/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/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/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 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/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 ===" 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/case_sets.yaml b/scripts/experiments/agent-compat/definitions/case_sets.yaml index c8cdfb8..09c4fe9 100644 --- a/scripts/experiments/agent-compat/definitions/case_sets.yaml +++ b/scripts/experiments/agent-compat/definitions/case_sets.yaml @@ -64,3 +64,15 @@ 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"] } + 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 5dcc9b6..ebdf1ad 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,8 +92,217 @@ 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' 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 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 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: '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' + 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` every few seconds (up to + ~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: > + 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 + +# 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 new file mode 100644 index 0000000..9b9dcbc --- /dev/null +++ b/scripts/experiments/agent-compat/definitions/console_human.json @@ -0,0 +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 \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/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 239203f..1dadab0 100644 --- a/scripts/experiments/agent-compat/definitions/inbox_seeds.json +++ b/scripts/experiments/agent-compat/definitions/inbox_seeds.json @@ -1,9 +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 \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'." + } + ], + "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 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 ===" 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 338b64f..fc56263 100755 --- a/scripts/experiments/agent-compat/run.sh +++ b/scripts/experiments/agent-compat/run.sh @@ -218,24 +218,47 @@ 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`). - if [ "${SEED_INBOX:-}" = 1 ]; then - TERMCHART_VIEWER_URL="$VURL" TERMCHART_VIEWER_TOKEN="$VTOK" python3 "$SCRIPT_DIR/seed_inbox.py" \ - && log "seeded console inbox for the inbox eval" || log "warn: inbox seed failed" - 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 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)