Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/inbox-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const n = Number(r.headers.get("x-termchart-inbox-unread") ?? "0");
if (!Number.isFinite(n) || n <= 0) return;
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<stale> 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");
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/list.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -58,17 +59,20 @@ export async function list(argv: string[], deps: ListDeps): Promise<number> {
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();
items.sort((x, y) => y.ts - x.ts);
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)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/pull.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -57,6 +58,7 @@ export async function pull(argv: string[], deps: PullDeps): Promise<number> {
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)) {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/test/inbox-nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/test/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
3 changes: 3 additions & 0 deletions packages/viewer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 3 additions & 1 deletion plugin/skills/inbox-watch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo> --agent <id>
📬 2 unread console messages from the human — ACT ON THIS BEFORE FINISHING:
› <the message text, inline>
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
Expand Down
3 changes: 3 additions & 0 deletions plugin/skills/termchart/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p> --agent <a> --message "<what you changed>"`.
**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;
Expand Down
19 changes: 19 additions & 0 deletions scripts/ci-local.sh
Original file line number Diff line number Diff line change
@@ -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 ==="
85 changes: 85 additions & 0 deletions scripts/experiments/agent-compat/console_human.py
Original file line number Diff line number Diff line change
@@ -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":"<chip id>"}
| {"kind":"tick_checklist","scope":"p/a","ref":"<checklistId>/<itemId>"} ] }
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)
12 changes: 12 additions & 0 deletions scripts/experiments/agent-compat/definitions/case_sets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Loading
Loading