@staix/agent-hub 0.12.28 → 0.12.30

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +13 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +237 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +39 -2
  18. package/docs/smoke.md +65 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
  20. package/docs/verification/2026-10-16-0.12.29.md +25 -0
  21. package/docs/verification/2026-10-16-273-live-verification.md +150 -0
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verified.json +13 -13
  24. package/package.json +5 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +64 -44
  27. package/src/cli/console-state.ts +92 -20
  28. package/src/cli/console.ts +25 -6
  29. package/src/cli/help.ts +4 -2
  30. package/src/cli/identity-audit.ts +2 -2
  31. package/src/cli/identity.ts +15 -1
  32. package/src/cli/main.js +2 -2
  33. package/src/cli/main.ts +78 -39
  34. package/src/cli/memory.ts +87 -187
  35. package/src/cli/output.ts +6 -0
  36. package/src/cli/recovery-package.ts +59 -10
  37. package/src/cli/upgrade-runtime.ts +60 -18
  38. package/src/cli/upgrade.ts +8 -0
  39. package/src/hub/board.ts +2 -1
  40. package/src/hub/bus.ts +33 -12
  41. package/src/hub/daemon.ts +141 -31
  42. package/src/hub/envelope.ts +30 -0
  43. package/src/hub/events.ts +1 -1
  44. package/src/hub/git.ts +2 -38
  45. package/src/hub/hub-tools.ts +5 -1
  46. package/src/hub/lifecycle.ts +2 -2
  47. package/src/hub/manager.ts +15 -11
  48. package/src/hub/memory-candidates.ts +103 -0
  49. package/src/hub/memory-fail.ts +25 -0
  50. package/src/hub/memory-observations.ts +47 -0
  51. package/src/hub/memory-plan-guard.ts +33 -0
  52. package/src/hub/memory-policy.ts +42 -0
  53. package/src/hub/memory-profile-check.ts +170 -0
  54. package/src/hub/memory-profile.ts +192 -0
  55. package/src/hub/memory-staleness.ts +55 -0
  56. package/src/hub/path-policy.ts +8 -0
  57. package/src/hub/project.ts +4 -37
  58. package/src/hub/read-tools.ts +127 -0
  59. package/src/hub/tasks.ts +201 -41
  60. package/src/hub/ui.ts +10 -7
  61. package/src/local/deny.ts +4 -17
  62. package/src/memory/brief.ts +8 -5
  63. package/src/memory/capture.ts +2 -1
  64. package/src/memory/recall.ts +50 -110
  65. package/src/pi/process-signature.ts +2 -42
  66. package/src/memory/import.ts +0 -90
  67. package/src/memory/store.ts +0 -805
  68. package/src/memory/sync.ts +0 -145
package/src/cli/memory.ts CHANGED
@@ -1,203 +1,103 @@
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";
1
+ import { detectCliIdentity } from "./identity.ts";
2
+ import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
+ import { looksSecret, type MemoryStore, type StoreOptions } from "@staix/agent-mem";
4
+ import type { SyncOptions } from "@staix/agent-mem";
5
+ import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
6
+ import { memoryProjectKey } from "@staix/agent-mem";
7
+ import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
4
8
  import { currentRouting, detectSignals } from "../hub/routing.ts";
5
- import { memoryProjectKey, mentionsPrivatePath, projectChain, promotionScreen, withoutPii } from "../memory/recall.ts";
6
9
  import { loadConfig } from "../hub/daemon.ts";
10
+ import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
7
11
 
12
+ export { GOAL_USAGE } from "@staix/agent-mem";
13
+ export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
14
+
15
+ const fail = (message: string): never => { throw new Error(message); };
8
16
  /** The project's config, or none when there is no valid one (reading memory needs none). */
9
17
  const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
10
18
 
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] [--restart] | dedupe [--apply] | 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
19
 
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];
20
+ /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
21
+ function commandStores(provided?: MemoryStore, options: StoreOptions = {}) {
22
+ try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
23
+ catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
24
+ }
25
+ function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
26
+ if (!profile) return text;
27
+ if (profile.state !== "ready") throw new Error(MEMORY_PROFILE_UNAVAILABLE);
28
+ if (json) return text;
29
+ return `${text}\nmemory profile: ${profile.profile}${profile.generation ? ` ${person ? profile.generation : profile.generation.slice(0, 8)}` : ""}${person && profile.skippedRows ? `; skipped malformed legacy rows: ${profile.skippedRows}` : ""}`;
23
30
  }
24
31
 
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
+ /** Hub wrapper: current project policy and TTL stay here; parsing and storage commands belong to the core. */
33
+ export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote">; now?: () => number; person?: boolean } = {}): Promise<string> {
34
+ const person = opts.person ?? detectCliIdentity(process.env).role === "console";
32
35
  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 "dedupe": {
89
- const apply = rest.includes("--apply");
90
- if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
91
- const result = store.dedupe(project, apply);
92
- if (json) return JSON.stringify(result, null, 2);
93
- return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
94
- ...result.groups.map(g => `${g.members.map(v => v.id.slice(0, 8)).join(", ")} -> ${g.target.slice(0, 8)}: ${g.members[0]!.text}`)].join("\n");
95
- }
96
- case "import": {
97
- const db = flag("--db"), limit = flag("--limit");
98
- const restart = rest.includes("--restart");
99
- if (restart) rest.splice(rest.indexOf("--restart"), 1);
100
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
101
- if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
102
- const routing = currentRouting(cwd);
103
- const policy = privatePaths(); // a note about a private path never reaches the synced log
104
- const key = memoryProjectKey(cwd);
105
- const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
106
- const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
107
- if (json) return JSON.stringify(r, null, 2);
108
- return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; other candidates were empty, malformed or already imported)${r.more ? "\nmore remain: run it again" : ""}`;
109
- }
110
- case "suggestions": {
111
- const pending = store.suggestions(project).filter((x) => x.status === "suggested");
112
- if (json) return JSON.stringify(pending, null, 2);
113
- 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";
114
- }
115
- case "confirm": case "reject": {
116
- 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)`);
117
- return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
118
- }
119
- case "machine": {
120
- const label = flag("--label");
121
- const m = label ? setMachineLabel(store.dir, label) : store.machine;
122
- return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
123
- }
124
- 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`; }
125
- default: return fail(MEMORY_USAGE);
36
+ const rest = args.filter(arg => arg !== "--json");
37
+ if (rest[0] === "decay") {
38
+ if (!person) fail("memory decay requires person authority");
39
+ let days = 90;
40
+ if (rest.length !== 1) {
41
+ if (rest.length !== 3 || rest[1] !== "--days" || !/^[1-9][0-9]*$/.test(rest[2]!)) fail("usage: ahub memory decay [--days N] [--json] (N must be a positive integer)");
42
+ days = Number(rest[2]);
126
43
  }
127
- } finally { if (!opts.store) store.close(); }
128
- }
129
-
130
- const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
131
- function leaseText(g: GoalView, me: string): string {
132
- const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
133
- 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)` : ""}`;
134
- return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
44
+ const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
45
+ if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
46
+ const cutoff = new Date(cutoffMs).toISOString();
47
+ const project = memoryProjectKey(cwd), routing = currentRouting(cwd), paths = privatePaths();
48
+ // Review must not promote a held source while reading it: only explicit person actions may append rows.
49
+ const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
50
+ const store = context.store;
51
+ try {
52
+ context.binding.assertCurrent();
53
+ const candidates = store.withDeferredPromotions(() => {
54
+ const goals = store.goals(project).filter(goal => goal.state !== "done" && goal.state !== "dropped");
55
+ const linkedTasks = new Set(goals.flatMap(goal => goal.links.map(link => link.task)));
56
+ const openGoals = new Set(goals.map(goal => `goal:${goal.id}`));
57
+ return withoutPii(store.list({ project, tier: "long", limit: Number.MAX_SAFE_INTEGER }).filter(note =>
58
+ note.project === project && Date.parse(note.at) < cutoffMs
59
+ && !(note.task && linkedTasks.has(note.task))
60
+ && !note.about.some(node => openGoals.has(node) || (node.startsWith("task:") && linkedTasks.has(node.slice(5))))), routing, cwd, paths);
61
+ });
62
+ const profile = context.binding.status(store);
63
+ if (args.includes("--json")) return profileOutput(JSON.stringify({ days, cutoff, candidates, memoryProfile: profile }), true, profile, person);
64
+ const text = candidates.length
65
+ ? [`${candidates.length} decay candidate${candidates.length === 1 ? "" : "s"} older than ${days} days (review only):`, ...candidates.map(note => `mem:${note.id} ${note.at} ${note.kind}: ${note.title ?? note.text}`)].join("\n")
66
+ : `no decay candidates older than ${days} days`;
67
+ return profileOutput(text, false, profile, person);
68
+ } finally { if (context.owned) store.close(); }
69
+ }
70
+ let showStale = false;
71
+ const context = commandStores(opts.store, { ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
+ screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), privatePaths) });
73
+ try {
74
+ context.binding.assertCurrent();
75
+ const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
76
+ readable: notes => {
77
+ const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
78
+ if (args[0] !== "show" || args.includes("--json")) return visible;
79
+ const stale = createStalenessProbe({ cwd, privatePaths: paths, deny: loadConfigSafe(cwd)?.local.deny ?? [] });
80
+ showStale = visible.some(note => stale(note.about));
81
+ return visible;
82
+ },
83
+ importPolicy: () => {
84
+ const routing = currentRouting(cwd), policy = privatePaths();
85
+ return text => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd);
86
+ },
87
+ });
88
+ return profileOutput(showStale ? `${text}\n(stale?)` : text, args.includes("--json"), context.binding.status(context.store), person);
89
+ } finally { if (context.owned) context.store.close(); }
135
90
  }
136
91
 
137
- /**
138
- * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
139
- * person's. A peer links its own board task with hub_task_propose's `goal`.
140
- */
141
- export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string> } = {}): Promise<string> {
142
- const json = args.includes("--json");
143
- const rest = args.filter((a) => a !== "--json");
144
- 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; };
145
- const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
146
- const store = opts.store ?? new MemoryStore();
147
- const project = memoryProjectKey(cwd);
148
- const me = store.machine.id;
149
- // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
150
- 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)`);
151
- // Agent shells may read goals and a goal syncs to every machine: titles and milestones pass the same screen as notes
152
- // (PII patterns, pii.paths; a config that does not load fails closed).
92
+ export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
93
+ const person = opts.person ?? detectCliIdentity(process.env).role === "console";
153
94
  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`); } };
154
- 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;
95
+ const context = commandStores(opts.store);
155
96
  try {
156
- switch (sub) {
157
- case "list": {
158
- const all = rest.includes("--all");
159
- const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
160
- const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
161
- if (json) return JSON.stringify(goals, null, 2);
162
- return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
163
- ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
164
- }
165
- case "show": {
166
- const g = need(rest[0]);
167
- 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`);
168
- if (json) return JSON.stringify(g, null, 2);
169
- return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
170
- ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
171
- ...(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");
172
- }
173
- case "new": {
174
- const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
175
- const shared = rest.includes("--shared");
176
- const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
177
- 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");
178
- const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
179
- return `long-term task ${id.slice(0, 8)} created: ${title}`;
180
- }
181
- case "link": {
182
- const g = need(rest[0]);
183
- const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
184
- if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
185
- return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
186
- }
187
- case "state": case "done": {
188
- const g = need(rest[0]);
189
- const state = (sub === "done" ? "done" : rest[1]) as GoalState;
190
- if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
191
- store.goalState(g.id, state, "user");
192
- return `long-term task ${g.id.slice(0, 8)} is ${state}`;
193
- }
194
- case "take": {
195
- const g = need(rest[0]);
196
- const r = store.take(g.id, "user", { force: rest.includes("--force") });
197
- 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`);
198
- return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
199
- }
200
- default: return fail(GOAL_USAGE);
201
- }
202
- } finally { if (!opts.store) store.close(); }
97
+ context.binding.assertCurrent();
98
+ const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
99
+ clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
100
+ });
101
+ return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
102
+ } finally { if (context.owned) context.store.close(); }
203
103
  }
package/src/cli/output.ts CHANGED
@@ -7,6 +7,7 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
7
7
 
8
8
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
9
9
  export interface StatusOutput {
10
+ memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
10
11
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
11
12
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
12
13
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
@@ -184,6 +185,11 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
184
185
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
185
186
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
186
187
  if (data.cwd) out.push(...lines(data.cwd, columns));
188
+ if (data.memoryProfile) {
189
+ const memory = data.memoryProfile;
190
+ const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
191
+ out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
192
+ }
187
193
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
188
194
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
189
195
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
@@ -1,8 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { hubHome } from "../hub/project.ts";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { hubHome, realPath } from "../hub/project.ts";
6
7
 
7
8
  export type CommandResult = { code: number; stdout: string; stderr: string };
8
9
  export type RunCommand = (args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
@@ -32,28 +33,76 @@ export function exactVersion(version: string): string {
32
33
  return version;
33
34
  }
34
35
 
35
- export function packageDigest(root: string): string {
36
+ const MEMORY_ASSETS = ["src", "bin", "package.json", "README.md", "LICENSE"] as const;
37
+
38
+ /** The sole runtime dependency is a bounded first-party package, never an arbitrary dependency tree. */
39
+ function memoryRuntime(root: string, dependencyRoot?: string): string | undefined {
40
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
41
+ const deps = pkg.dependencies ?? {};
42
+ if (!Object.keys(deps).length) return undefined;
43
+ const version = deps["@staix/agent-mem"];
44
+ if (Object.keys(deps).length !== 1 || typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error("unsupported runtime dependencies in recovery package");
45
+ let dir = dependencyRoot ? join(dependencyRoot, "node_modules/@staix/agent-mem")
46
+ : dirname(createRequire(join(root, "package.json")).resolve("@staix/agent-mem"));
47
+ if (dependencyRoot && !existsSync(dir)) throw new Error("memory runtime dependency is unavailable");
48
+ if (dependencyRoot) for (const name of ["node_modules", "node_modules/@staix", "node_modules/@staix/agent-mem"]) {
49
+ if (lstatSync(join(dependencyRoot, name)).isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
50
+ }
51
+ for (;;) {
52
+ const manifest = join(dir, "package.json");
53
+ if (existsSync(manifest)) {
54
+ const memory = JSON.parse(readFileSync(manifest, "utf8"));
55
+ if (memory.name === "@staix/agent-mem") {
56
+ if (memory.version !== version || Object.keys(memory.dependencies ?? {}).length) throw new Error("memory runtime dependency identity/version mismatch");
57
+ return realPath(dir);
58
+ }
59
+ }
60
+ const parent = dirname(dir);
61
+ if (dependencyRoot || parent === dir) throw new Error("memory runtime dependency is unavailable");
62
+ dir = parent;
63
+ }
64
+ }
65
+
66
+ export function preserveRuntimeDependencies(root: string, destination: string): void {
67
+ const memory = memoryRuntime(root);
68
+ if (!memory) return;
69
+ const target = join(destination, "node_modules/@staix/agent-mem");
70
+ mkdirSync(target, { recursive: true });
71
+ for (const name of MEMORY_ASSETS) {
72
+ if (lstatSync(join(memory, name)).isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
73
+ cpSync(join(memory, name), join(target, name), { recursive: true, dereference: false });
74
+ }
75
+ }
76
+
77
+ export function packageDigest(root: string, options: { ownDependencies?: boolean; dependencyRoot?: string } = {}): string {
36
78
  const hash = createHash("sha256");
79
+ const file = (path: string, name: string) => { hash.update(name); hash.update("\0"); hash.update(readFileSync(path)); hash.update("\0"); };
37
80
  const visit = (directory: string, prefix = "") => {
38
81
  for (const item of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
39
82
  if (item.isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
40
83
  const name = `${prefix}${item.name}`;
41
84
  if (item.isDirectory()) visit(join(directory, item.name), `${name}/`);
42
- else { hash.update(name); hash.update("\0"); hash.update(readFileSync(join(directory, item.name))); hash.update("\0"); }
85
+ else file(join(directory, item.name), name);
43
86
  }
44
87
  };
45
- // Only runtime assets; never copy or hash a checkout's private state or dependencies.
88
+ // Only runtime assets and the exact first-party package; never private state or unrelated dependencies.
46
89
  for (const name of ["src", "plugins", "templates", ".claude-plugin"]) visit(join(root, name), `${name}/`);
47
90
  hash.update(readFileSync(join(root, "package.json")));
91
+ const memory = memoryRuntime(root, options.ownDependencies ? root : options.dependencyRoot);
92
+ if (memory) for (const name of MEMORY_ASSETS) {
93
+ const path = join(memory, name), stat = lstatSync(path), prefix = `node_modules/@staix/agent-mem/${name}`;
94
+ if (stat.isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
95
+ if (stat.isDirectory()) visit(path, `${prefix}/`); else file(path, prefix);
96
+ }
48
97
  return hash.digest("hex");
49
98
  }
50
99
 
51
- export function verifyPackage(root: string, version: string): string {
100
+ export function verifyPackage(root: string, version: string, options: { ownDependencies?: boolean; dependencyRoot?: string } = {}): string {
52
101
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
53
102
  const plugin = JSON.parse(readFileSync(join(root, "plugins/agent-hub/.claude-plugin/plugin.json"), "utf8"));
54
103
  if (pkg.name !== "@staix/agent-hub" || pkg.version !== version || plugin.version !== version ||
55
104
  !existsSync(join(root, "src/cli/main.js"))) throw new Error("staged package identity/version mismatch");
56
- return packageDigest(root);
105
+ return packageDigest(root, options);
57
106
  }
58
107
 
59
108
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
@@ -92,7 +141,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
92
141
  const root = join(destination, "node_modules/@staix/agent-hub");
93
142
  if (existsSync(destination)) {
94
143
  const saved = JSON.parse(readFileSync(join(destination, "verified.json"), "utf8"));
95
- const digest = verifyPackage(root, version);
144
+ const digest = verifyPackage(root, version, { dependencyRoot: destination });
96
145
  if (saved.integrity !== integrity || saved.digest !== digest) throw new Error("cached release changed; refusing it");
97
146
  return { root, digest };
98
147
  }
@@ -104,7 +153,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
104
153
  const install = await run([process.execPath, "install", "--ignore-scripts", "--cwd", temporary], { timeoutMs: 180_000 });
105
154
  if (install.code !== 0) throw new Error("package staging failed; installed runtime was not changed");
106
155
  const staged = join(temporary, "node_modules/@staix/agent-hub");
107
- const digest = verifyPackage(staged, version);
156
+ const digest = verifyPackage(staged, version, { dependencyRoot: temporary });
108
157
  // Bun's lock records the registry integrity actually used, not a later metadata query.
109
158
  const lock = JSON.parse(readFileSync(join(temporary, "bun.lock"), "utf8").replace(/,\s*([}\]])/g, "$1"));
110
159
  const row = lock.packages?.["@staix/agent-hub"];
@@ -8,7 +8,7 @@ import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from
8
8
  import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
9
9
  import type { Project } from "../hub/registry.ts";
10
10
  import { hubHome } from "../hub/project.ts";
11
- import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
11
+ import { packageDigest, preserveRuntimeDependencies, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
12
12
  import { inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
13
13
  import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
14
14
  import { readEvents } from "../hub/events.ts";
@@ -307,7 +307,7 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
307
307
  const base = join(home, "releases");
308
308
  const dest = join(base, `source-${plan.sourceDigest}`);
309
309
  if (existsSync(dest)) {
310
- if (packageDigest(dest) !== plan.sourceDigest) throw new Error("preserved source package changed");
310
+ if (packageDigest(dest, { ownDependencies: true }) !== plan.sourceDigest) throw new Error("preserved source package changed");
311
311
  return dest;
312
312
  }
313
313
  mkdirSync(base, { recursive: true, mode: 0o700 });
@@ -317,15 +317,16 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
317
317
  for (const name of ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"]) {
318
318
  cpSync(join(plan.sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
319
319
  }
320
- if (packageDigest(temporary) !== plan.sourceDigest) throw new Error("source changed while being preserved");
320
+ preserveRuntimeDependencies(plan.sourceRoot, temporary);
321
+ if (packageDigest(temporary, { ownDependencies: true }) !== plan.sourceDigest) throw new Error("source changed while being preserved");
321
322
  renameSync(temporary, dest);
322
323
  } finally { rmSync(temporary, { recursive: true, force: true }); }
323
324
  return dest;
324
325
  }
325
326
 
326
- export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver {
327
- const now = () => Date.now();
328
- const sleep = (ms: number) => Bun.sleep(ms);
327
+ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">): RecoveryDriver {
328
+ const now = clock?.now ?? (() => Date.now());
329
+ const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
329
330
  const env = (op: RecoveryOperation, project?: Project): NodeJS.ProcessEnv => {
330
331
  const value = { ...process.env };
331
332
  // Do not carry one project's gateway, auth overrides or unattended mode into another.
@@ -333,6 +334,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
333
334
  (project && /(?:_API_KEY|_ACCESS_TOKEN|_SECRET|_TOKEN)$/.test(key))) delete value[key];
334
335
  value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
335
336
  value.AGENTHUB_UNATTENDED = "0";
337
+ if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
336
338
  if (project) {
337
339
  value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
338
340
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
@@ -345,7 +347,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
345
347
  return value;
346
348
  };
347
349
  const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
348
- const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...args],
350
+ const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
349
351
  { env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
350
352
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
351
353
  return result;
@@ -433,21 +435,56 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
433
435
  if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
434
436
  progress.terminals[key] = true; save();
435
437
  };
436
- const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
437
- for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
438
- const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
439
- const id = peer === "codex" ? current?.threadId : current?.sessionId;
440
- if (id) return id;
441
- if (now() >= deadline) throw new Error(`${peer}: the new session reported no id within ${RECONNECT_WAIT_MS / 1000} s; wait until it attaches first`);
442
- await sleep(1000);
438
+ /** #414: an idle terminal is not an authenticated peer. Keep its receipt while waiting, including on resume. */
439
+ const awaitAttachment = async (planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, binding: TerminalBinding, save: () => void, expectedSession: string | null = binding.sessionId): Promise<string> => {
440
+ const deadline = now() + RECONNECT_WAIT_MS;
441
+ let live: Inspection;
442
+ for (;;) {
443
+ live = await inspectRecovery(planned.project);
444
+ if (live.state === "running" && (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id)) throw new Error(`${binding.peer}: target identity changed while awaiting attachment`);
445
+ const peer = live.peers.find((p) => p.id === binding.peer);
446
+ const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
447
+ const changed = !!session && expectedSession !== null && session !== expectedSession;
448
+ // #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
449
+ // at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
450
+ const zeroTurn = changed && binding.peer === "claude" && (planned.terminals as TerminalBinding[]).some((p) => p.peer === "claude" && p.sessionId === binding.sessionId) && !claudeTranscriptExists(binding);
451
+ if (changed && !zeroTurn) throw new Error(`${binding.peer}: a different conversation attached while awaiting ${binding.sessionId}`);
452
+ if (now() > deadline) break;
453
+ if (live.state === "running" && peer && ["idle", "busy", "paused"].includes(peer.state) && session) {
454
+ if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { claude: "zero-turn" });
455
+ return session;
456
+ }
457
+ if (now() >= deadline) break;
458
+ await sleep(Math.min(1000, deadline - now()));
443
459
  }
460
+ const terminal: NonNullable<ProjectProgress["attachmentTimeouts"]>[string]["terminal"] = { handle: binding.handle, incarnationId: binding.incarnationId, state: "unknown" };
461
+ try {
462
+ const result = await run([orcaExecutable(), "terminal", "show", "--terminal", binding.handle, "--json"], { timeoutMs: 3000 });
463
+ const value = JSON.parse(result.stdout), shown = value.result?.terminal;
464
+ if (result.code === 0 && value.ok === true && shown?.handle === binding.handle && shown.incarnationId === binding.incarnationId) {
465
+ if (["idle", "busy", "exited"].includes(shown.state)) terminal.state = shown.state;
466
+ if (typeof shown.connected === "boolean") terminal.connected = shown.connected;
467
+ if (typeof shown.orphaned === "boolean") terminal.orphaned = shown.orphaned;
468
+ }
469
+ } catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
470
+ const peer = live.peers.find((p) => p.id === binding.peer);
471
+ const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
472
+ (progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
473
+ hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
474
+ lastHubConnectAttempt: "unknown" };
475
+ save();
476
+ throw new Error(`${binding.peer}: attachment not verified within ${RECONNECT_WAIT_MS / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`);
444
477
  };
445
478
  return {
446
479
  now, sleep, inspect: inspectRecovery,
447
480
  stage: async (op) => {
448
- if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
449
- const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
481
+ if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
482
+ const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
450
483
  : await stageRelease(op.plan.version, op.plan.integrity!, run);
484
+ const identityPath = join(target.root, "src/cli/identity.ts");
485
+ if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
486
+ throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
487
+ }
451
488
  // #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
452
489
  // digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
453
490
  // changed bytes are the runner's final "staged target changed".
@@ -592,7 +629,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
592
629
  : `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session ${evidence.attached ? "with an id " : ""}attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
593
630
  const receipt = progress.terminals[key];
594
631
  if (receipt && receipt !== "pending" && receipt !== "failed") {
595
- progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
632
+ const binding = receipt as TerminalBinding;
633
+ const sessionId = await awaitAttachment(planned, progress, op, binding, save, fresh() && binding.sessionId === original.sessionId ? null : binding.sessionId);
634
+ progress.terminals[key] = { ...binding, sessionId }; save();
635
+ progress.terminals[key] = await revalidateTerminal(planned, progress, progress.terminals[key] as TerminalBinding, true); save();
596
636
  continue;
597
637
  }
598
638
  // #215: the pending row of the receipt table, used wherever a pending launch meets an attached session.
@@ -669,7 +709,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
669
709
  throw new Error(`${original.peer}: original session restoration needs manual verification${restored.blockers[0] ? ` (${restored.blockers[0].message})` : ""}; wait until it attaches, or end that launch and close its terminal${handle ? ` ${handle}` : ""} first`);
670
710
  }
671
711
  // The daemon's id is authoritative: a fresh session is recorded under the id it reports once attached.
672
- progress.terminals[key] = fresh() ? { ...restored.newBinding, sessionId: await attachedId(planned, original.peer, op) } : restored.newBinding; save();
712
+ progress.terminals[key] = restored.newBinding; save();
713
+ const sessionId = await awaitAttachment(planned, progress, op, restored.newBinding, save, fresh() ? null : restored.newBinding.sessionId);
714
+ progress.terminals[key] = { ...restored.newBinding, sessionId }; save();
673
715
  }
674
716
  if (group === "claude") for (const id of planned.reconnectOnly ?? []) {
675
717
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
@@ -53,6 +53,14 @@ export interface ProjectProgress {
53
53
  phase: "pending" | "prepared" | "stopped" | "started" | "peers-restored" | "verified";
54
54
  instanceId?: string;
55
55
  terminals: Record<string, unknown>;
56
+ /** #414: last bounded native-attachment timeout, metadata only; never terminal output or task text. */
57
+ attachmentTimeouts?: Record<string, {
58
+ at: number;
59
+ terminal: { handle: string; incarnationId: string; state: "unknown" | "idle" | "busy" | "exited"; connected?: boolean; orphaned?: boolean };
60
+ hub: { state: string; instanceId?: string; peerState?: string; sessionId?: string };
61
+ /** Client-side attempts are not recorded by the hub; an offline peer is not evidence of an attempt. */
62
+ lastHubConnectAttempt: "unknown";
63
+ }>;
56
64
  releaseRequested?: boolean;
57
65
  /** #215: peers the operator moved to a fresh session, with the session or thread id whose continuity is lost. */
58
66
  fresh?: Record<string, { lost: string; reason: string; at: number }>;