@staix/agent-hub 0.12.23 → 0.12.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,193 @@
1
+ import { extractNodes, GOAL_STATES, KINDS, looksSecret, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "../memory/store.ts";
2
+ import { syncMemory, type SyncOptions } from "../memory/sync.ts";
3
+ import { importClaudeMem } from "../memory/import.ts";
4
+ import { currentRouting, detectSignals } from "../hub/routing.ts";
5
+ import { memoryProjectKey, mentionsPrivatePath, projectChain, promotionScreen, withoutPii } from "../memory/recall.ts";
6
+ import { loadConfig } from "../hub/daemon.ts";
7
+
8
+ /** The project's config, or none when there is no valid one (reading memory needs none). */
9
+ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
10
+
11
+ /**
12
+ * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
13
+ * supersede and rebuild are a person's (the identity gate refuses agent shells).
14
+ */
15
+ export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <suggestion id> [--json]";
16
+ export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
17
+
18
+ const fail = (message: string): never => { throw new Error(message); };
19
+ const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
20
+ function detail(v: View): string[] {
21
+ return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
22
+ ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
23
+ }
24
+
25
+ export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote"> } = {}): Promise<string> {
26
+ const json = args.includes("--json");
27
+ const rest = args.filter((a) => a !== "--json");
28
+ const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
29
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
30
+ // The project's private paths (`pii.paths`): a note about one is withheld like a note matching a PII pattern. A config
31
+ // that does not load fails the read closed: its privacy policy is not dropped for agent-readable output.
32
+ const privatePaths = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before reading hub memory`); } };
33
+ // A store opened here promotes this project's notes only through its current policy (privatePaths fails closed).
34
+ // The project's own short-term lifetime, so a deadline this command writes (a demotion) matches what its hub would write.
35
+ const store = opts.store ?? new MemoryStore({ ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000, screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), () => privatePaths()) });
36
+ const actor = "user";
37
+ const project = memoryProjectKey(cwd);
38
+ const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
39
+ try {
40
+ switch (sub) {
41
+ case "list": case "search": {
42
+ const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
43
+ if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
44
+ if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
45
+ if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
46
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
47
+ const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
48
+ // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
49
+ // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
50
+ const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
51
+ // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
52
+ const readable = withoutPii(all, currentRouting(cwd), cwd, privatePaths()), rows = readable.slice(0, limit ? Number(limit) : 50);
53
+ if (json) return JSON.stringify(rows, null, 2);
54
+ const c = store.counts(project);
55
+ const withheld = all.length - readable.length;
56
+ return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
57
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
58
+ }
59
+ case "show": {
60
+ const id = rest[0] ?? fail("usage: ahub memory show <id>");
61
+ const v = need(store.get(id, project), id);
62
+ if (!withoutPii([v], currentRouting(cwd), cwd, privatePaths()).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
63
+ return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
64
+ }
65
+ case "graph": {
66
+ const hops = Number(flag("--hops") ?? 1);
67
+ if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
68
+ const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
69
+ const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
70
+ const g = store.graph(project, node, hops);
71
+ return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
72
+ }
73
+ case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
74
+ case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
75
+ case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_USAGE), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
76
+ case "supersede": {
77
+ if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
78
+ return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail("both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (ahub memory promote it first)");
79
+ }
80
+ case "sync": {
81
+ const remote = flag("--remote");
82
+ if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
83
+ const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
84
+ if (json) return JSON.stringify(r, null, 2);
85
+ if (!r.ok) fail(`hub memory sync: ${r.error}`);
86
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
87
+ }
88
+ case "import": {
89
+ const db = flag("--db"), limit = flag("--limit");
90
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N]");
91
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
92
+ const routing = currentRouting(cwd);
93
+ const policy = privatePaths(); // a note about a private path never reaches the synced log
94
+ const key = memoryProjectKey(cwd), deny = loadConfigSafe(cwd)?.local.deny ?? [];
95
+ const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, deny });
96
+ const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
97
+ if (json) return JSON.stringify(r, null, 2);
98
+ return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.project} other projects, ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; the rest were already here)`;
99
+ }
100
+ case "suggestions": {
101
+ const pending = store.suggestions(project).filter((x) => x.status === "suggested");
102
+ if (json) return JSON.stringify(pending, null, 2);
103
+ return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
104
+ }
105
+ case "confirm": case "reject": {
106
+ const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
107
+ return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
108
+ }
109
+ case "machine": {
110
+ const label = flag("--label");
111
+ const m = label ? setMachineLabel(store.dir, label) : store.machine;
112
+ return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
113
+ }
114
+ case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
115
+ default: return fail(MEMORY_USAGE);
116
+ }
117
+ } finally { if (!opts.store) store.close(); }
118
+ }
119
+
120
+ const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
121
+ function leaseText(g: GoalView, me: string): string {
122
+ const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
123
+ if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
124
+ return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
125
+ }
126
+
127
+ /**
128
+ * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
129
+ * person's. A peer links its own board task with hub_task_propose's `goal`.
130
+ */
131
+ export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string> } = {}): Promise<string> {
132
+ const json = args.includes("--json");
133
+ const rest = args.filter((a) => a !== "--json");
134
+ const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
135
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
136
+ const store = opts.store ?? new MemoryStore();
137
+ const project = memoryProjectKey(cwd);
138
+ const me = store.machine.id;
139
+ // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
140
+ const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
141
+ // Agent shells may read goals and a goal syncs to every machine: titles and milestones pass the same screen as notes
142
+ // (PII patterns, pii.paths; a config that does not load fails closed).
143
+ const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
144
+ const clean = (title: string, milestones: string[], of = project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project: of }], currentRouting(cwd), cwd, policy()).length > 0;
145
+ try {
146
+ switch (sub) {
147
+ case "list": {
148
+ const all = rest.includes("--all");
149
+ const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
150
+ const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
151
+ if (json) return JSON.stringify(goals, null, 2);
152
+ return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
153
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
154
+ }
155
+ case "show": {
156
+ const g = need(rest[0]);
157
+ if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
158
+ if (json) return JSON.stringify(g, null, 2);
159
+ return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
160
+ ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
161
+ ...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
162
+ }
163
+ case "new": {
164
+ const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
165
+ const shared = rest.includes("--shared");
166
+ const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
167
+ if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
168
+ const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
169
+ return `long-term task ${id.slice(0, 8)} created: ${title}`;
170
+ }
171
+ case "link": {
172
+ const g = need(rest[0]);
173
+ const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
174
+ if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
175
+ return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
176
+ }
177
+ case "state": case "done": {
178
+ const g = need(rest[0]);
179
+ const state = (sub === "done" ? "done" : rest[1]) as GoalState;
180
+ if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
181
+ store.goalState(g.id, state, "user");
182
+ return `long-term task ${g.id.slice(0, 8)} is ${state}`;
183
+ }
184
+ case "take": {
185
+ const g = need(rest[0]);
186
+ const r = store.take(g.id, "user", { force: rest.includes("--force") });
187
+ if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
188
+ return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
189
+ }
190
+ default: return fail(GOAL_USAGE);
191
+ }
192
+ } finally { if (!opts.store) store.close(); }
193
+ }
package/src/cli/output.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
2
+ import { peerStyle } from "../ui/peer-palette.ts";
1
3
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
2
4
  import { taskProgress } from "../ui/task-progress.ts";
3
5
  import { backendLabel } from "./status-lines.ts";
@@ -5,10 +7,15 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
5
7
 
6
8
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
7
9
  export interface StatusOutput {
8
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; budget?: Record<string, any>;
10
+ taskWip?: Record<string, { wip: number; wipLimit: number }>;
11
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
9
12
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
10
13
  }
11
14
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
15
+ export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
16
+ export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
17
+ return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
18
+ }
12
19
  export function outputWidth(env: { isTTY: boolean; columns?: number; COLUMNS?: string }): number | undefined {
13
20
  if (env.isTTY) return Math.max(80, Number.isFinite(env.columns) ? env.columns! : 80);
14
21
  return env.COLUMNS && /^[1-9][0-9]*$/.test(env.COLUMNS) && Number.isSafeInteger(Number(env.COLUMNS)) ? Number(env.COLUMNS) : undefined;
@@ -19,7 +26,7 @@ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
19
26
  const width = (value: string) => Bun.stringWidth(value);
20
27
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
21
28
  const characters = (value: string) => [...graphemes.segment(value)].map(unit => unit.segment);
22
- const peerTone = (id: string): Tone | undefined => id === "claude" ? "peerClaude" : id === "codex" ? "peerCodex" : undefined;
29
+ const peerTone = (id: string): Tone => peerStyle(id).tone;
23
30
  const shortId = (id: unknown, columns?: number, full = false) => full || columns === undefined ? String(id) : String(id).slice(0, 8);
24
31
  function lines(text: string, columns?: number, tone?: Tone, indent = ""): Span[][] {
25
32
  return (columns === undefined ? [flat(indent + text)] : wrap(flat(indent + text), columns)).map(text => [span(text, tone)]);
@@ -174,6 +181,7 @@ function contextText(p: any, now: number): string {
174
181
  return `${c.used === null || c.used === undefined ? "unknown" : `${Math.round(c.used * 100)}%`}${c.freshness === "stale" ? " stale" : ""}${typeof c.measuredAt === "number" ? ` ${relative(c.measuredAt, now)}` : ""}${c.source ? ` ${c.source}` : ""}`;
175
182
  }
176
183
  export function renderStatus(data: StatusOutput, columns?: number, now = Date.now(), full = false): Span[][] {
184
+ if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
177
185
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
178
186
  if (data.cwd) out.push(...lines(data.cwd, columns));
179
187
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
@@ -189,6 +197,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
189
197
  let settling = false;
190
198
  const peerDetails = Object.entries(data.peers).map(([id, p]) => {
191
199
  const out: Span[][] = [];
200
+ if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
201
+ if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
192
202
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
193
203
  if (p.heldBy) {
194
204
  const note = flat(p.holdNote ?? "");
@@ -205,7 +215,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
205
215
  : flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
206
216
  out.push(...detail("paused", pauseText, columns, "attention"));
207
217
  }
208
- if (p.toolsOnly) out.push(...detail("tools-only", flat(p.toolsOnly).replace(/^tools-only:\s*/, ""), columns, "attention"));
218
+ if (p.toolsOnly) out.push(...detail(id === "kimi" && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
219
+ if (p.pullOnly && id === "kimi") out.push(...detail("kimi last read", typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
220
+ if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
209
221
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
210
222
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
211
223
  return out;
@@ -213,6 +225,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
213
225
  if (peerRows.length) out.push([], ...rows(head, peerRows, columns, peerDetails));
214
226
  else out.push(...lines("no peers attached yet (ahub claude | ahub codex | ahub kimi)", columns));
215
227
  if (settling) out.push([], ...lines("settling: the adapter has not confirmed these deliveries yet (Claude: reply or hub_delivery_done). Task state is independent.", columns, "muted"));
228
+ if (data.taskWip) for (const [peer, value] of Object.entries(data.taskWip)) out.push(...detail(`${peer} planner WIP`, `${value.wip}/${value.wipLimit}`, columns));
216
229
  if (data.models?.backends?.length) {
217
230
  out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
218
231
  for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
@@ -225,7 +238,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
225
238
  export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
226
239
  if (!tasks.length) return [[span("no tasks")]];
227
240
  const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
228
- const stages = allTasks === null ? undefined : new Map(taskProgress(allTasks).stages.map(stage => [stage.id, stage]));
241
+ const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
229
242
  const data = tasks.map(task => {
230
243
  const row = cells(s, task, false, now, stages);
231
244
  row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
@@ -245,6 +258,34 @@ export function renderBudget(data: BudgetOutput, columns?: number, now = Date.no
245
258
  for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
246
259
  return out;
247
260
  }
261
+ function boardColumnContext(tasks: any[], allTasks: any[] | null): any[] {
262
+ if (allTasks === null) throw new Error("board column dependency context unavailable; retry with a complete snapshot");
263
+ const first = new Map(tasks.map(task => [task.id, task])), context = allTasks.map(task => first.get(task.id) ?? task);
264
+ const included = new Set(context.map(task => task.id));
265
+ for (const task of tasks) if (!included.has(task.id)) context.push(task);
266
+ return context;
267
+ }
268
+ export function boardColumnModel(tasks: any[], peers: Record<string, BoardPeerInput>, allTasks: any[] | null = tasks) {
269
+ const visible = new Set(tasks.map(task => task.id)), context = boardColumnContext(tasks, allTasks);
270
+ const model = boardColumns(context.map(task => ({ ...task, urgent: task.signals?.includes("urgent") === true })), peers);
271
+ return { columns: model.columns.map(column => ({ ...column, ready: column.ready.filter(card => visible.has(card.id)), waiting: column.waiting.filter(card => visible.has(card.id)) })), unknown: model.unknown.filter(id => visible.has(id)) };
272
+ }
273
+ export function renderBoardColumns(tasks: any[], peers: Record<string, BoardPeerInput>, columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
274
+ const visible = new Set(tasks.map(task => task.id)), byId = new Map(tasks.map(task => [task.id, task]));
275
+ const context = boardColumnContext(tasks, allTasks), model = boardColumnModel(tasks, peers, context), out: Span[][] = [];
276
+ for (const column of model.columns) {
277
+ const ready = column.ready.filter(card => visible.has(card.id)), waiting = column.waiting.filter(card => visible.has(card.id));
278
+ if (out.length) out.push([]); out.push([span(column.label, "strong")]);
279
+ if (!ready.length && !waiting.length) { out.push([span("(empty)", "muted")]); continue; }
280
+ if (column.id === "todo") out.push([span("Ready", "strong")]);
281
+ if (ready.length) out.push(...renderBoard(ready.map(card => byId.get(card.id)), columns, now, full, context));
282
+ if (waiting.length) { out.push([span("Waiting", "strong")]); out.push(...renderBoard(waiting.map(card => byId.get(card.id)), columns, now, full, context)); for (const card of waiting) out.push(...lines(`#${card.id}: ${card.waiting.join("; ")}`, columns)); }
283
+ }
284
+ const unknown = model.unknown.filter(id => visible.has(id));
285
+ if (unknown.length) out.push([], [span("Unknown state", "strong")], ...renderBoard(unknown.map(id => byId.get(id)), columns, now, full, context));
286
+ return out;
287
+ }
288
+
248
289
  export function renderDoctor(checks: DoctorCheck[], columns?: number, _now = Date.now(), _full = false): Span[][] {
249
290
  const out: Span[][] = [];
250
291
  const tone = { ok: "success", warn: "attention", fail: "failure", unknown: "muted" } as const;
@@ -0,0 +1 @@
1
+ export { loadPetConfig, type PetConfig } from "../pet/config.ts";
package/src/cli/pet.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
4
+ import { processLiveness } from "../pi/process-signature.ts";
5
+ import { characterAttentionLine } from "./attention.ts";
6
+ import type { CharacterId } from "../pet/frame.ts";
7
+ import type { NeedItem, NeedsSummary } from "../ui/needs.ts";
8
+
9
+ const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
10
+ const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
11
+
12
+ /** Parsed status metadata is copied by allowlist; source text and extra fields are never rendered. */
13
+ export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
14
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
15
+ const row = raw as Record<string, unknown>;
16
+ if (!count(row.act) || !count(row.watch) || typeof row.state !== "string" || !["waiting", "blocked", "review", "working", "idle", "sleeping"].includes(row.state)) return;
17
+ let first: NeedItem | undefined;
18
+ if (row.first !== undefined) {
19
+ if (!row.first || typeof row.first !== "object" || Array.isArray(row.first)) return;
20
+ const item = row.first as Record<string, unknown>;
21
+ if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "storage", "recovery", "delivery", "review", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
22
+ if (item.peer !== undefined && !id(item.peer)) return;
23
+ if (item.id !== undefined && !id(item.id) && !(count(item.id) && item.id > 0)) return;
24
+ if (item.until !== undefined && !count(item.until) || item.since !== undefined && !count(item.since)) return;
25
+ first = { kind: item.kind as NeedItem["kind"], level: item.level as NeedItem["level"],
26
+ ...(item.peer !== undefined ? { peer: item.peer as string } : {}), ...(item.id !== undefined ? { id: item.id as string | number } : {}),
27
+ ...(item.until !== undefined ? { until: item.until as number } : {}), ...(item.since !== undefined ? { since: item.since as number } : {}) };
28
+ }
29
+ if ((row.act + row.watch > 0) !== !!first) return;
30
+ if (row.act > 0 && first?.level !== "act" || row.act === 0 && first?.level === "act") return;
31
+ if (row.act === 0 && ["waiting", "blocked", "review"].includes(row.state) || row.act > 0 && ["working", "idle", "sleeping"].includes(row.state)) return;
32
+ if (first?.level === "watch" && !["recovery", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
33
+ if (row.state === "waiting" && !["approval", "default"].includes(first?.kind ?? "")) return;
34
+ if (row.state === "blocked" && !["storage", "recovery", "delivery"].includes(first?.kind ?? "")) return;
35
+ if (row.state === "review" && first?.kind !== "review") return;
36
+ return { act: row.act, watch: row.watch, state: row.state as NeedsSummary["state"], ...(first ? { first } : {}) };
37
+ }
38
+
39
+ export interface PetView { summary?: NeedsSummary; unavailable: boolean; runnerUnknown?: boolean }
40
+ export interface PetRecoveryReader { lock(): string | undefined; runner(id: string): number | "unknown" | undefined }
41
+ const recoveryReader: PetRecoveryReader = { lock: recoveryLock, runner: recoveryRunner };
42
+
43
+ /** A recovery lock is read-only evidence; this function never resumes or changes an operation. */
44
+ export function petView(status: unknown, reader: PetRecoveryReader = recoveryReader): PetView {
45
+ const row = status && typeof status === "object" && !Array.isArray(status) ? status as Record<string, unknown> : undefined;
46
+ const base = parseNeedsSummary(row?.needs);
47
+ let operation: string | undefined;
48
+ try { operation = reader.lock(); }
49
+ catch { return { summary: base, unavailable: true, runnerUnknown: true }; }
50
+ if (!operation) return { summary: base, unavailable: !base };
51
+ if (!id(operation)) return { summary: base, unavailable: true, runnerUnknown: true };
52
+ let runner: number | "unknown" | undefined;
53
+ try { runner = reader.runner(operation); } catch { runner = "unknown"; }
54
+ const active = runner !== undefined;
55
+ const item: NeedItem = { kind: "recovery", level: active ? "watch" : "act", id: operation };
56
+ const reported = row?.recovery;
57
+ const included = base && reported && typeof reported === "object" && (reported as Record<string, unknown>).operationId === operation && base.watch > 0 ? 1 : 0;
58
+ const act = (base?.act ?? 0) + (active ? 0 : 1), watch = (base?.watch ?? 0) - included + (active ? 1 : 0);
59
+ const first = base?.first;
60
+ const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "storage"].includes(first.kind));
61
+ const selected = precedes || first?.kind === "recovery" && first.id === operation ? item : first ?? item;
62
+ const state = !active && base?.state !== "waiting" ? "blocked" : base?.state ?? "sleeping";
63
+ return { summary: { act, watch, state, first: selected }, unavailable: !base, ...(runner === "unknown" ? { runnerUnknown: true } : {}) };
64
+ }
65
+
66
+ export function readPet(stateDir: string, reader: PetRecoveryReader = recoveryReader, alive = processLiveness): PetView {
67
+ let status: unknown;
68
+ try {
69
+ const row = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
70
+ const summary = parseNeedsSummary(row.needs);
71
+ const expired = summary?.first?.kind === "approval" && summary.first.until !== undefined && summary.first.until <= Date.now();
72
+ if (!expired && Number.isSafeInteger(row.pid) && row.pid > 0 && alive(row.pid, row.pidSignature) === "live") status = row;
73
+ } catch { /* Missing, malformed or unconfirmed owner data is unavailable, never an empty live hub. */ }
74
+ return petView(status, reader);
75
+ }
76
+
77
+ export function renderPet(view: PetView, short = false, character: CharacterId = "stai"): string {
78
+ if (short) return view.summary?.act ? `ahub!${view.summary.act}` : "";
79
+ if (!view.summary) return "attention unavailable";
80
+ const value = view.summary;
81
+ const line = characterAttentionLine({ ...value, items: value.first ? [value.first] : [] }, character, 0, false, false);
82
+ return line + (view.unavailable ? " (status unavailable)" : "") + (view.runnerUnknown ? " (runner unknown)" : "");
83
+ }
@@ -1,3 +1,5 @@
1
+ import { jcodeCommand } from "../adapters/jcode.ts";
2
+ import { qwenSessionId } from "../hub/crash.ts";
1
3
  import { readFileSync } from "node:fs";
2
4
  import { join, resolve } from "node:path";
3
5
  import { loadConfig } from "../hub/daemon.ts";
@@ -5,7 +7,8 @@ import { childEnv } from "../hub/child-process.ts";
5
7
  import { relayModelIds } from "../models/relay.ts";
6
8
  import { buildPiLaunch } from "../pi/launch.ts";
7
9
  import { startModeOf } from "../hub/start-mode.ts";
8
- import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
10
+ import { buildLaunch, buildKimiLaunch, buildQwenLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
11
+ import { kimiMcpPlan } from "./kimi-mcp.ts";
9
12
 
10
13
  const REDACTED = "[redacted]";
11
14
  const UNRESOLVED = "[unresolved]";
@@ -19,11 +22,12 @@ function nativeCommand(command: string[], trusted: string[]): string[] {
19
22
  return command.map((value, i) => value === trusted[i] ? value : REDACTED);
20
23
  }
21
24
  /** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
22
- export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
25
+ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
23
26
  const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
24
27
  const config = loadConfig(cwd);
25
28
  const unresolved: { field: string; reason: string }[] = [];
26
29
  let launch: Launch;
30
+ let kimiMetadata: { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } } | undefined;
27
31
  let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
28
32
  const value = (flag: string) => {
29
33
  const at = args.indexOf(flag);
@@ -58,10 +62,39 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
58
62
  const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
59
63
  launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
60
64
  unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
65
+ } else if (tool === "jcode") {
66
+ const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
67
+ if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
68
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
69
+ if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
70
+ const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
71
+ launch = { cmd: cmd[0]!, args: cmd.slice(1) };
72
+ unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
73
+ unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
74
+ } else if (tool === "qwen") {
75
+ const mode = value("--mode") ?? "headless", model = value("--model");
76
+ if (mode !== "headless") throw new Error("Qwen TUI is not offered before verification");
77
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
78
+ const sessionId = value("--session-id");
79
+ if (sessionId !== undefined && !qwenSessionId(sessionId)) throw new Error("invalid Qwen session identity");
80
+ if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
81
+ launch = buildQwenLaunch(nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model ? REDACTED : undefined);
82
+ unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
61
83
  } else if (tool === "kimi") {
62
84
  const model = value("--model");
63
- launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
64
- unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
85
+ const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
86
+ if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
87
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
88
+ if (mode === "headless") {
89
+ launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
90
+ unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
91
+ } else {
92
+ const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
93
+ launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
94
+ envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
95
+ }
96
+ const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(import.meta.dir, "../../plugins/agent-hub/server.js"), mode);
97
+ kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
65
98
  } else {
66
99
  const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
67
100
  const backend = value("--backend") ?? config.pi.backend;
@@ -88,5 +121,5 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
88
121
  unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
89
122
  unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
90
123
  }
91
- return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
124
+ return { tool, ...(kimiMetadata ?? {}), cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
92
125
  }
@@ -1,7 +1,7 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
5
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -8,6 +8,7 @@ export function renderTailEvent(e: BusEvent): string {
8
8
  }
9
9
  if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
10
10
  if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
11
+ if (e.t === "off_roster") return ` . ${e.env.id} retained off roster for ${e.peers.join(", ")}`;
11
12
  if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
12
13
  const { env } = e;
13
14
  const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
@@ -1,3 +1,4 @@
1
+ import { qwenSessionId } from "../hub/crash.ts";
1
2
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
2
3
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -238,6 +239,10 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
238
239
  const reconnectOnly: string[] = [];
239
240
  for (const peer of source.peers) {
240
241
  if (peer.state === "offline") continue;
242
+ if (peer.id === "kimi" && peer.args?.mode === "tui") {
243
+ blockers.push("kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)");
244
+ continue;
245
+ }
241
246
  if (peer.id === "claude") {
242
247
  // #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
243
248
  // that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
@@ -265,14 +270,17 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
265
270
  continue;
266
271
  }
267
272
  }
273
+ if (peer.id === "jcode") { blockers.push("jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects"); continue; }
268
274
  if (peer.id === "codex" || peer.id === "claude") {
269
275
  const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
270
276
  if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
271
277
  else sessions[peer.id] = session;
278
+ } else if (peer.id === "qwen" || peer.id === "jcode") {
279
+ if (!qwenSessionId(peer.sessionId)) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
272
280
  } else if (peer.id === "pi" && peer.args?.mode === "tui") {
273
281
  if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
274
282
  else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
275
- } else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
283
+ } else if (peer.id !== "kimi" && peer.id !== "qwen" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
276
284
  }
277
285
  const terminals = await inspectTerminals(project.root, sessions, {
278
286
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
@@ -449,6 +457,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
449
457
  // A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
450
458
  if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
451
459
  if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
460
+ if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
461
+ const catalog = join(target.root, "src/hub/peer-catalog.ts");
462
+ const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
463
+ if (!peers || !/["']qwen["']/.test(peers)) throw new FinalRefusal("target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged");
464
+ }
465
+ if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
466
+ const catalog = join(target.root, "src/hub/peer-catalog.ts");
467
+ const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
468
+ if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
469
+ }
452
470
  // Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
453
471
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
454
472
  throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
@@ -512,14 +530,25 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
512
530
  const live = await inspectRecovery(planned.project);
513
531
  if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
514
532
  if (group === "native") {
515
- for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
533
+ for (const peer of planned.source.peers.filter((p) => ["kimi", "qwen", "jcode", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
516
534
  const current = live.peers.find((p) => p.id === peer.id);
517
535
  if (current && current.state !== "offline") continue;
518
536
  const args = [peer.id];
519
- for (const key of ["mode", "backend", "model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
537
+ if (peer.id === "kimi" && peer.args?.mode === "tui") throw new Error("Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan");
538
+ for (const key of ["mode", "backend", "model", "route"]) if (!(peer.id === "kimi" && key === "mode") && typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
539
+ if (peer.id === "kimi") {
540
+ // ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
541
+ // an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
542
+ const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
543
+ const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
544
+ if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
545
+ }
520
546
  // #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
521
547
  // one is filtered out above), so a snapshot that recorded no mode still restores it headless.
522
548
  if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
549
+ if (peer.id === "qwen" && peer.sessionId) args.push("--session-id", peer.sessionId);
550
+ if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
551
+ if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
523
552
  if (peer.id === "pi") {
524
553
  const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
525
554
  if (typeof sessionFile === "string") args.push("--session-file", sessionFile);