@staix/agent-hub 0.12.24 → 0.12.26

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.
@@ -0,0 +1,31 @@
1
+ # 0.12.26 release verification
2
+
3
+ This record covers release preparation and source verification. It does not certify tagging, package publication, global installation or a live upgrade; those receipts are recorded separately.
4
+
5
+ ## Candidate
6
+
7
+ 0.12.26 carries the hub-collaboration round for issues #380, #385 and #386: PRs #389, #387 and #388. Control protocol remains 16.
8
+
9
+ - #389 (#380): claude-mem imports derive their graph nodes from the observation text without consulting the checkout, so one observation is one content-hashed entry on every machine and sync converges regardless of import order; the import scan filters the project in SQL and resumes from a persisted machine-local cursor (`--restart` re-scans), so unrelated projects cannot exhaust the window; `ahub memory dedupe [--apply]` reconciles pre-fix duplicate groups with append-only supersede operations onto the lexicographically smallest id.
10
+ - #387 (#385): an exact or trailing-`**` data-policy entry that matches a directory resolves that directory's direct-child symlinks and protects their targets, matching what `data/*` already did for a matched link; discovery of the on-disk root is case-compatible, the scan streams inside the shared 4096-entry budget without descending, a dangling link covers its lexical target, an unresolvable link loop aliases nothing, a root whose budget is exhausted contributes no aliases, and an unreadable protected root fails closed. The failure semantics for the new scan are the user's direct ruling of 2026-10-14. The spec paragraph in docs/specs/2026-09-19-agent-hub-design.md was amended in the same PR.
11
+ - #388 (#386): the text-projection converter comment and both docs state the project-scoped read guarantee and the 1024-entry, cleared-when-full in-memory failure record.
12
+
13
+ Every fix carries regression tests that failed before it. Review was adversarial and multi-round on the board (tasks #45-#52): the review of #387 found the case-sensitive exact-root miss (P1) and the unbounded readdir (P2), CI found the Linux EACCES-at-read edge (#51) and two seed guards that needed retargeting after the refactor (#52), each fixed and re-gated before merge.
14
+
15
+ ## Document review
16
+
17
+ Every stale document was rechecked against the ten source commits of the three PRs. README, Quickstart and Operations carry the 0.12.26 version and install/upgrade strings; Operations' converter failure-record wording landed with #388. The data-policy contract document (docs/data-policy.md) and docs/memory.md were updated inside their fix PRs. The adapters and daemon area notes were confirmed accurate against the `cliCommandLabel` addition, the tests note against the seed-selector repairs, and Security against the policy-matching change (it makes no per-form matching claim; the contract lives in data-policy.md). Budget, Bus, Benchmarks, Models, Tasks and the local-worker notes were not stale.
18
+
19
+ ## Recorded checks
20
+
21
+ - PR #388 head 483ca546: CI Linux, macOS and seeded guards passed; board review approved.
22
+ - PR #389 head d5738472: worktree `scripts/check.sh` 2702 pass, 0 fail, 189163 assertions, `check: OK`, no daemon leaks; CI green; board review approved.
23
+ - PR #387 head 5fbe5d3a: worktree full gate 2714 pass, `check: OK`; CI Linux, macOS and seeded guards passed; board review approved.
24
+ - Release candidate fbf96c08: local `scripts/check.sh` in an isolated worktree — 2728 pass, 0 fail, 189269 assertions, `check: OK`, no daemon leaks. Later commits on this branch change documentation only.
25
+ - Release head: CI, document freshness at the merge commit, and the release workflow are required before tagging is reported complete.
26
+
27
+ Note: `scripts/check.sh` in the main checkout fails 35/68 daemon tests on unmodified main because the live hub daemon serving the project interferes; the isolated worktree gate is the valid signal (recorded in shared memory #194264).
28
+
29
+ ## Not verified
30
+
31
+ Native, account and research legs remain open in #273 and #1. The installed runtime and live hubs are not upgraded by this release.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "22993206f309720e714cbd7c045aadaef9ac9ba1",
5
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "230833b4ef9df745552f292ffd6965e4b261ce24",
14
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "22993206f309720e714cbd7c045aadaef9ac9ba1",
21
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "22993206f309720e714cbd7c045aadaef9ac9ba1",
38
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "230833b4ef9df745552f292ffd6965e4b261ce24",
46
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -62,7 +62,7 @@
62
62
  ]
63
63
  },
64
64
  "docs/agent-notes/benchmarks.md": {
65
- "verifiedAgainst": "042c440e23452d62b5aaaf86330431fe0b16baba",
65
+ "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
66
66
  "paths": [
67
67
  "scripts/benchmarks/",
68
68
  "test/benchmarks/",
@@ -74,7 +74,7 @@
74
74
  ]
75
75
  },
76
76
  "docs/agent-notes/budget.md": {
77
- "verifiedAgainst": "6dcc7fe00c38f13613bcf53524fa3d1527a708e5",
77
+ "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
78
78
  "paths": [
79
79
  "src/hub/budget.ts",
80
80
  "src/cli/statusline-tee.ts",
@@ -86,7 +86,7 @@
86
86
  ]
87
87
  },
88
88
  "docs/agent-notes/bus.md": {
89
- "verifiedAgainst": "6dcc7fe00c38f13613bcf53524fa3d1527a708e5",
89
+ "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
90
90
  "paths": [
91
91
  "src/hub/bus.ts",
92
92
  "src/hub/envelope.ts",
@@ -98,7 +98,7 @@
98
98
  ]
99
99
  },
100
100
  "docs/agent-notes/daemon.md": {
101
- "verifiedAgainst": "230833b4ef9df745552f292ffd6965e4b261ce24",
101
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
102
102
  "paths": [
103
103
  "src/hub/daemon.ts",
104
104
  "src/hub/control-client.ts",
@@ -155,7 +155,7 @@
155
155
  ]
156
156
  },
157
157
  "docs/agent-notes/tasks.md": {
158
- "verifiedAgainst": "230833b4ef9df745552f292ffd6965e4b261ce24",
158
+ "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
159
159
  "paths": [
160
160
  "src/hub/tasks.ts",
161
161
  "src/hub/board.ts",
@@ -172,7 +172,7 @@
172
172
  ]
173
173
  },
174
174
  "docs/agent-notes/tests.md": {
175
- "verifiedAgainst": "8ffd8babb35b5970c881f81babe951ed70d3f387",
175
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
176
176
  "paths": [
177
177
  "test/",
178
178
  "scripts/check.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.24",
3
+ "version": "0.12.26",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.24",
3
+ "version": "0.12.26",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -16157,7 +16157,7 @@ import { join as join4 } from "path";
16157
16157
  // package.json
16158
16158
  var package_default = {
16159
16159
  name: "@staix/agent-hub",
16160
- version: "0.12.24",
16160
+ version: "0.12.26",
16161
16161
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
16162
16162
  license: "MIT",
16163
16163
  type: "module",
@@ -28,6 +28,10 @@ export interface PiExit {
28
28
  export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
29
29
  export interface PiToolResult { text: string; exitCode: number | null; }
30
30
  class PiToolSettlementFailure extends Error {}
31
+ class PiPromptNotStarted extends Error {
32
+ constructor(readonly promptId: string) { super("Pi native session changed before prompt insertion; reverify its retained terminal before retrying"); }
33
+ }
34
+ class PiPromptUncertain extends Error {}
31
35
  export interface PiOptions {
32
36
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
33
37
  model?: string;
@@ -101,6 +105,11 @@ export class PiPeer extends BasePeer {
101
105
  private stopping = true;
102
106
  private rosterDetached = false;
103
107
  private rosterEpoch = 0;
108
+ private rosterLifecycleRevision = 0;
109
+ private fencedPrompt = false;
110
+ private hubPromptId: string | undefined;
111
+ private hubPromptGeneration: number | undefined;
112
+ private pendingPromptId: string | undefined;
104
113
  /** A personal native turn observed on reattach has no hub reply audience. */
105
114
  private rosterUnownedRun = false;
106
115
  private exitReported = false;
@@ -319,6 +328,7 @@ export class PiPeer extends BasePeer {
319
328
  this.rosterDetached = false; this.rosterUnownedRun = false;
320
329
  this.requestedStopReason = undefined;
321
330
  this.approvalTurnAbort = false;
331
+ this.fencedPrompt = false; this.hubPromptId = undefined; this.hubPromptGeneration = undefined; this.pendingPromptId = undefined;
322
332
  this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
323
333
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
324
334
  mkdirSync(this.opts.stateDir, { recursive: true });
@@ -347,7 +357,13 @@ export class PiPeer extends BasePeer {
347
357
  if (url.pathname === "/ack") {
348
358
  const pending = this.tuiCommands.get(String(body.id));
349
359
  if (!pending) return Response.json({ ok: false }, { status: 404 });
350
- this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
360
+ this.tuiCommands.delete(String(body.id));
361
+ const refusal = body.refusal;
362
+ const boundRefusal = this.fencedPrompt && this.verifiedOwner() && pending.command.type === "prompt" && pending.command.sessionId === this.sessionId
363
+ && refusal?.promptId === pending.command.id && refusal.sessionId === this.sessionId
364
+ && Number.isSafeInteger(refusal.generation) && refusal.generation >= Number(pending.command.generation);
365
+ body.ok ? pending.resolve(body.result) : pending.reject(boundRefusal && refusal.kind === "prompt_not_started" ? new PiPromptNotStarted(String(body.id))
366
+ : boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
351
367
  return Response.json({ ok: true });
352
368
  }
353
369
  if (url.pathname === "/tool") return this.handleToolRequest(body);
@@ -437,10 +453,14 @@ export class PiPeer extends BasePeer {
437
453
  async resumeFromRoster(): Promise<void> {
438
454
  if (!this.rosterDetached) return;
439
455
  if (this.opts.mode !== "tui" || this.stopping || !this.rosterDetached || !this.server || !this.verifiedOwner()) throw new Error("Pi roster return has no verified live TUI owner");
440
- const epoch = this.rosterEpoch, session = this.sessionId, file = this.sessionFile;
456
+ if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
457
+ if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
458
+ const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
441
459
  const snapshot = await this.sendTui({ type: "get_session_state" });
442
460
  if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
461
+ if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
443
462
  this.rosterDetached = false;
463
+ this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
444
464
  this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
445
465
  this.setState(snapshot.idle ? "idle" : "busy");
446
466
  if (!snapshot.idle) this.clearWatchdog();
@@ -534,6 +554,7 @@ export class PiPeer extends BasePeer {
534
554
  this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
535
555
  const rosterEpoch = this.rosterEpoch;
536
556
  let attempted = false;
557
+ let promptId: string | undefined;
537
558
  try {
538
559
  const requested = await this.opts.selectModel?.(envs);
539
560
  if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
@@ -542,10 +563,32 @@ export class PiPeer extends BasePeer {
542
563
  this.noteActivity();
543
564
  attempted = true;
544
565
  if (deliveryId) this.activeDeliveryIds.add(deliveryId);
545
- if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
566
+ if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
546
567
  else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
568
+ if (promptId !== undefined && promptId !== this.pendingPromptId) return;
569
+ if (this.fencedPrompt && this.rosterDetached) {
570
+ if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
571
+ this.setState("offline");
572
+ return;
573
+ }
547
574
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
548
575
  } catch (error) {
576
+ if (promptId !== undefined && promptId !== this.pendingPromptId) return;
577
+ if (error instanceof PiPromptUncertain) { this.pendingPromptId = undefined; await this.detachForRoster(); return; }
578
+ if (error instanceof PiPromptNotStarted && error.promptId === this.pendingPromptId) {
579
+ this.pendingPromptId = undefined;
580
+ if (deliveryId) this.activeDeliveryIds.delete(deliveryId);
581
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error.message });
582
+ else this.onFailed?.(envs);
583
+ await this.detachForRoster();
584
+ return;
585
+ }
586
+ if (this.fencedPrompt && this.pendingPromptId && this.rosterDetached) {
587
+ this.pendingPromptId = undefined;
588
+ if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi prompt acknowledgement is uncertain after personal native activity" });
589
+ this.setState("offline");
590
+ return;
591
+ }
549
592
  if (rosterEpoch !== this.rosterEpoch) {
550
593
  if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
551
594
  return;
@@ -563,11 +606,13 @@ export class PiPeer extends BasePeer {
563
606
  this.delivery({ id: deliveryId, state: "failed_safe", reason });
564
607
  }
565
608
  await this.terminateFailedTurn(error instanceof Error ? error : new Error(reason));
609
+ } finally {
610
+ if (promptId !== undefined && this.pendingPromptId === promptId) this.pendingPromptId = undefined;
566
611
  }
567
612
  }
568
613
 
569
614
  async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
570
- if (this.state !== "busy" || !this.acceptingTools || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
615
+ if (this.state !== "busy" || !this.acceptingTools || (this.fencedPrompt && this.pendingPromptId && this.hubPromptGeneration === undefined) || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
571
616
  const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
572
617
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
573
618
  throw new Error(reason);
@@ -597,14 +642,39 @@ export class PiPeer extends BasePeer {
597
642
  }
598
643
 
599
644
  private handleBridgeEvent(event: any): void {
645
+ if (this.fencedPrompt && this.opts.mode === "tui" && !this.rosterUnownedRun && ["agent_start", "agent_end", "agent_settled"].includes(event.type)
646
+ && typeof event.promptId === "string" && event.promptId !== this.hubPromptId) return;
647
+ if (this.fencedPrompt && this.opts.mode === "tui" && (this.activeEnvs.length > 0 || this.pendingPromptId || this.hubPromptId) && !this.rosterDetached && !this.rosterUnownedRun && !this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) {
648
+ const bound = this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
649
+ if (!bound || !Number.isSafeInteger(event.generation) || event.generation < this.budgetGeneration) return;
650
+ const owned = this.hubPromptId !== undefined && event.promptId === this.hubPromptId;
651
+ if (event.type === "agent_start" && event.generation > this.budgetGeneration) {
652
+ if (owned) this.hubPromptGeneration = event.generation;
653
+ else {
654
+ if (this.pendingPromptId || this.activeEnvs.length) {
655
+ // Await the prompt's exact ACK before deciding whether its effect was absent or uncertain.
656
+ this.rosterDetached = true; this.rosterEpoch++; this.clearWatchdog();
657
+ this.activeEnvs = []; this.currentReply = undefined; this.settledText = ""; this.settledError = "";
658
+ this.abortTools();
659
+ if (!this.pendingPromptId) {
660
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi personal native activity replaced the accepted hub turn" });
661
+ this.activeDeliveryIds.clear(); this.setState("offline");
662
+ }
663
+ } else { this.rosterUnownedRun = true; this.setState("busy"); this.clearWatchdog(); }
664
+ }
665
+ } else if (!this.rosterUnownedRun && (!owned || event.generation !== this.hubPromptGeneration)) return;
666
+ }
600
667
  if (this.rosterDetached || this.rosterUnownedRun) {
601
668
  // The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
602
669
  // and still-verified owner bind that event to this session; an explicit mismatch never does.
603
670
  const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
604
671
  if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
672
+ this.rosterLifecycleRevision++;
605
673
  this.budgetGeneration = event.generation;
606
674
  this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
607
675
  }
676
+ // Snapshot ACKs and lifecycle events use separate requests, so their arrival order is not native state order.
677
+ if (bound && event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) this.rosterLifecycleRevision++;
608
678
  if (!bound && event.type !== "session_shutdown") return;
609
679
  }
610
680
  if (this.rosterDetached && event.type !== "session_shutdown") return;
@@ -619,6 +689,7 @@ export class PiPeer extends BasePeer {
619
689
  if (event.type === "session_start") {
620
690
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
621
691
  this.approvalTurnAbort = event.approvalTurnAbort === true;
692
+ this.fencedPrompt ||= event.fencedPrompt === true;
622
693
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
623
694
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
624
695
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
@@ -699,6 +770,7 @@ export class PiPeer extends BasePeer {
699
770
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
700
771
  this.activeDeliveryIds.clear();
701
772
  this.currentReply = undefined; this.activeEnvs = [];
773
+ this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
702
774
  this.executionAbort?.abort(); this.executionAbort = undefined;
703
775
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
704
776
  this.abortedToolIds.clear(); this.abortIdsOverflow = false;
@@ -717,7 +789,9 @@ export class PiPeer extends BasePeer {
717
789
  }
718
790
  private sendTui(command: Record<string, unknown>): Promise<any> {
719
791
  const id = `ahub-${++this.seq}`;
720
- const wire = { id, ...command };
792
+ const fenced = this.fencedPrompt && command.type === "prompt";
793
+ if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
794
+ const wire = { id, ...command, ...(fenced ? { sessionId: this.sessionId, generation: this.budgetGeneration } : {}) };
721
795
  return new Promise((resolvePromise, reject) => {
722
796
  const timer = setTimeout(() => {
723
797
  this.tuiCommands.delete(id);
package/src/cli/help.ts CHANGED
@@ -110,7 +110,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
110
110
  ["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
111
111
  ["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
112
112
  ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
113
- ["ahub memory import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
113
+ ["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
114
114
  ["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
115
115
  ["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
116
116
  ]],
@@ -61,7 +61,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
61
61
  if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
62
62
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
63
63
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
64
- if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
64
+ if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
65
65
  }
66
66
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
67
67
  }
package/src/cli/memory.ts CHANGED
@@ -12,7 +12,7 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
12
12
  * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
13
13
  * supersede and rebuild are a person's (the identity gate refuses agent shells).
14
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]";
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
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
17
 
18
18
  const fail = (message: string): never => { throw new Error(message); };
@@ -85,17 +85,27 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
85
85
  if (!r.ok) fail(`hub memory sync: ${r.error}`);
86
86
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
87
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
+ }
88
96
  case "import": {
89
97
  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]");
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]");
91
101
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
92
102
  const routing = currentRouting(cwd);
93
103
  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) } : {}) });
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) } : {}) });
97
107
  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)`;
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" : ""}`;
99
109
  }
100
110
  case "suggestions": {
101
111
  const pending = store.suggestions(project).filter((x) => x.status === "suggested");
package/src/hub/daemon.ts CHANGED
@@ -419,6 +419,8 @@ interface Client {
419
419
  peer?: PeerId;
420
420
  tail?: () => void;
421
421
  watch?: () => void;
422
+ /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
423
+ kimiAdmission?: string;
422
424
  }
423
425
  type Sock = ServerWebSocket<Client>;
424
426
 
@@ -655,7 +657,7 @@ export async function startDaemon(opts: DaemonOptions) {
655
657
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
656
658
  if (peer instanceof WsPeer) {
657
659
  peer.detach(sock);
658
- if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming) clearKimiAdmission();
660
+ if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
659
661
  }
660
662
  },
661
663
  },
@@ -3869,7 +3871,7 @@ export async function startDaemon(opts: DaemonOptions) {
3869
3871
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
3870
3872
  preparedSessionRoleIdentities.delete(c.peer);
3871
3873
  ws.claim(sock, msg.channel === false);
3872
- if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; }
3874
+ if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
3873
3875
  if (c.peer === "claude") logPermissionStart(c.peer);
3874
3876
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
3875
3877
  void ensurePreface(c.peer).finally(() => {
@@ -4154,7 +4156,9 @@ export async function startDaemon(opts: DaemonOptions) {
4154
4156
  if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
4155
4157
  if (msg.peer !== "kimi" && msg.peer !== "qwen") return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
4156
4158
  const peer = msg.peer as "kimi" | "qwen";
4157
- if (peer === "kimi" && (kimiSessionMode ?? startMode("kimi")) === "tui") return void reply({ ok: false, error: "Kimi is in pull-only TUI mode; use its native terminal or start headless" });
4159
+ // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
4160
+ const kimiOwner = bus.peers.get("kimi");
4161
+ if (peer === "kimi" && !(kimiOwner instanceof AcpPeer) && (kimiOwner instanceof WsPeer || (kimiSessionMode ?? startMode("kimi")) === "tui")) return void reply({ ok: false, error: "Kimi is in pull-only TUI mode; use its native terminal or start headless" });
4158
4162
  if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
4159
4163
  c.watch?.();
4160
4164
  c.watch = activityRings[peer].subscribe(item => {
@@ -1,6 +1,7 @@
1
- import { lstatSync, opendirSync, statSync } from "node:fs";
1
+ import { accessSync, constants, lstatSync, opendirSync, readlinkSync, statSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { realPath } from "./project.ts";
4
+ import { foldSegment } from "./permission-mode.ts";
4
5
  /** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
5
6
  export function policyPaths(raw: unknown, field: string): string[] {
6
7
  if (raw === undefined) return [];
@@ -15,16 +16,68 @@ export function policyRealPath(path: string): string {
15
16
  while (!existing(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) throw new Error("path cannot be resolved"); ancestor = parent; }
16
17
  return join(realPath(ancestor), relative(ancestor, path));
17
18
  }
18
- const beneath = (path: string, root: string) => { path = path.toLowerCase(); root = root.toLowerCase(); return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep); };
19
+ const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
20
+ // Either spelling protects, as for globs: lowercase as before, or folded like a case-insensitive disk opens names (NFD/NFC,
21
+ // compatibility forms, long s, ignorable code points). Folding is not a function of the lowercase form for every name.
22
+ const beneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase()) || within(foldSegment(path), foldSegment(root));
23
+ const lowerBeneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase());
19
24
  export function policyTargets(cwd: string, paths: string[]): string[] {
20
25
  const root = realPath(cwd);
21
26
  return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
22
27
  }
23
- /** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
28
+ type DiscoveryBudget = { entries: number };
29
+ /** A dangling direct child names a future lexical target; an ELOOP child opens no file. */
30
+ function directAliasTarget(path: string): string | undefined {
31
+ let target = path;
32
+ // Include the final target resolution after the last bounded redirect.
33
+ for (let hops = 0; hops <= 40; hops++) {
34
+ try { return policyRealPath(target); }
35
+ catch (error) {
36
+ const code = (error as NodeJS.ErrnoException).code;
37
+ if (code === "ELOOP") return undefined;
38
+ if (code !== "ENOENT") throw error;
39
+ }
40
+ let ancestor = target;
41
+ while (!existing(ancestor)) {
42
+ const parent = dirname(ancestor);
43
+ if (parent === ancestor) return undefined;
44
+ ancestor = parent;
45
+ }
46
+ if (!lstatSync(ancestor).isSymbolicLink()) return undefined;
47
+ target = resolve(dirname(ancestor), readlinkSync(ancestor), relative(ancestor, target));
48
+ }
49
+ return undefined;
50
+ }
51
+ /** Only a completed bounded scan contributes aliases; a truncated root contributes none. */
52
+ function aliasScope(dir: string, lexical: string, actual: string, includeParents: boolean, budget: DiscoveryBudget): boolean {
53
+ // Readability must not depend on reaching readSync after an exhausted-budget return.
54
+ accessSync(dir, constants.R_OK);
55
+ const handle = opendirSync(dir);
56
+ let matched = false;
57
+ try {
58
+ if (budget.entries >= 4096) return false;
59
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
60
+ if (budget.entries >= 4096) return false;
61
+ budget.entries++;
62
+ if (!entry.isSymbolicLink()) continue;
63
+ const target = directAliasTarget(join(dir, entry.name));
64
+ if (target !== undefined && (beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) matched = true;
65
+ }
66
+ } finally { handle.closeSync(); }
67
+ return matched;
68
+ }
69
+ /**
70
+ * Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands (ß, fi), and
71
+ * the folded one catches NFD/NFC, compatibility and long-s aliases. Adding a match only ever adds protection.
72
+ */
24
73
  function segmentMatches(pattern: string, name: string): boolean {
25
- const chars = [...name.toLowerCase()];
74
+ return wildcard(pattern.toLowerCase(), name.toLowerCase()) || wildcard(foldSegment(pattern), foldSegment(name));
75
+ }
76
+ /** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
77
+ function wildcard(pattern: string, name: string): boolean {
78
+ const chars = [...name];
26
79
  let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
27
- for (const char of pattern.toLowerCase()) {
80
+ for (const char of pattern) {
28
81
  const next = new Array<boolean>(chars.length + 1).fill(false);
29
82
  next[0] = char === "*" && previous[0]!;
30
83
  for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
@@ -57,23 +110,34 @@ function patternScope(pattern: string[], states: Set<number>, path: string, incl
57
110
  return includeParents && states.size > 0; // this scope can contain a future protected descendant
58
111
  }
59
112
  /** Resolve wildcard directory aliases without trusting a cached expansion. Bound all filesystem discovery. */
60
- function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: { entries: number }): boolean {
113
+ function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: DiscoveryBudget, roots: Set<string>, aliasDiscovery = false): boolean {
61
114
  const pattern = path.split("/").filter(p => p && p !== ".");
62
115
  const start = closure(pattern, new Set([0]));
63
116
  if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
64
117
  const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
65
118
  const real = policyRealPath(dir);
66
- if (states.has(pattern.length)) return beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual));
119
+ if (states.has(pattern.length)) {
120
+ if (beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual))) return true;
121
+ // Defer new child aliases until every pre-existing policy match has had its discovery budget.
122
+ if (pattern.at(-1) === "**" && existing(real) && statSync(real).isDirectory()) roots.add(real);
123
+ // An earlier `**` can still match deeper: an alias below this match (`ss/ß -> other` for `**/ß`) is searched too.
124
+ // A trailing `**` only re-matches everything below, so it never walks the subtree (as before, for paths outside it).
125
+ states = new Set([...states].filter(i => i !== pattern.length && !(i === pattern.length - 1 && pattern[i] === "**")));
126
+ if (!states.size) return false;
127
+ }
67
128
  if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
68
129
  if (!existing(dir)) return false;
69
- if (!beneath(real, root)) throw new Error("policy glob discovery outside project");
130
+ // Unfolded: a sibling whose folded spelling looks like the project is outside it, and discovery there fails closed.
131
+ if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
70
132
  const info = statSync(dir);
71
133
  if (!info.isDirectory()) return false;
134
+ if (aliasDiscovery && budget.entries >= 4096) return false;
72
135
  if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
73
136
  const chain = new Set(ancestors); chain.add(real);
74
137
  const handle = opendirSync(dir);
75
138
  try {
76
139
  for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
140
+ if (aliasDiscovery && budget.entries >= 4096) return false;
77
141
  if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
78
142
  const next = advance(pattern, states, entry.name);
79
143
  if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
@@ -83,14 +147,28 @@ function globScope(root: string, lexical: string, actual: string, path: string,
83
147
  };
84
148
  return visit(root, start, 0, new Set());
85
149
  }
86
- /** Compare declared spelling, real targets and wildcard aliases. Unknown discovery conservatively protects. */
150
+ /** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
87
151
  export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
88
152
  if (!paths.length) return false;
89
153
  try {
90
154
  if (path.length > 4096) return true;
91
155
  const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
92
156
  includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
93
- const budget = { entries: 0 };
94
- return paths.some(entry => /[?*]/.test(entry) ? globScope(root, lexical, actual, entry, includeParents, budget) : policyTargets(root, [entry]).some(target => beneath(lexical, target) || beneath(actual, target) || (includeParents && (beneath(target, lexical) || beneath(target, actual)))));
157
+ const budget = { entries: 0 }, roots = new Set<string>(), exact: string[] = [];
158
+ if (paths.some(entry => {
159
+ if (/[?*]/.test(entry)) return globScope(root, lexical, actual, entry, includeParents, budget, roots);
160
+ const targets = policyTargets(root, [entry]);
161
+ if (targets.some(target => beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) return true;
162
+ const real = targets.at(-1)!;
163
+ if (existing(real) && statSync(real).isDirectory()) roots.add(real);
164
+ exact.push(entry);
165
+ return false;
166
+ })) return true;
167
+ // New exact-root discovery cannot consume the budget before legacy wildcard matches are checked.
168
+ for (const entry of exact) {
169
+ if (globScope(root, lexical, actual, `${entry}/**`, includeParents, budget, roots, true)) return true;
170
+ }
171
+ for (const dir of roots) if (aliasScope(dir, lexical, actual, includeParents, budget)) return true;
172
+ return false;
95
173
  } catch { return true; }
96
174
  }