@staix/agent-hub 0.12.24 → 0.12.25

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.
@@ -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/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
1
  import { lstatSync, opendirSync, 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,27 @@ 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
+ /**
29
+ * Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands (ß, fi), and
30
+ * the folded one catches NFD/NFC, compatibility and long-s aliases. Adding a match only ever adds protection.
31
+ */
24
32
  function segmentMatches(pattern: string, name: string): boolean {
25
- const chars = [...name.toLowerCase()];
33
+ return wildcard(pattern.toLowerCase(), name.toLowerCase()) || wildcard(foldSegment(pattern), foldSegment(name));
34
+ }
35
+ /** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
36
+ function wildcard(pattern: string, name: string): boolean {
37
+ const chars = [...name];
26
38
  let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
27
- for (const char of pattern.toLowerCase()) {
39
+ for (const char of pattern) {
28
40
  const next = new Array<boolean>(chars.length + 1).fill(false);
29
41
  next[0] = char === "*" && previous[0]!;
30
42
  for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
@@ -63,10 +75,17 @@ function globScope(root: string, lexical: string, actual: string, path: string,
63
75
  if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
64
76
  const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
65
77
  const real = policyRealPath(dir);
66
- if (states.has(pattern.length)) return beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual));
78
+ if (states.has(pattern.length)) {
79
+ if (beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual))) return true;
80
+ // An earlier `**` can still match deeper: an alias below this match (`ss/ß -> other` for `**/ß`) is searched too.
81
+ // A trailing `**` only re-matches everything below, so it never walks the subtree (as before, for paths outside it).
82
+ states = new Set([...states].filter(i => i !== pattern.length && !(i === pattern.length - 1 && pattern[i] === "**")));
83
+ if (!states.size) return false;
84
+ }
67
85
  if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
68
86
  if (!existing(dir)) return false;
69
- if (!beneath(real, root)) throw new Error("policy glob discovery outside project");
87
+ // Unfolded: a sibling whose folded spelling looks like the project is outside it, and discovery there fails closed.
88
+ if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
70
89
  const info = statSync(dir);
71
90
  if (!info.isDirectory()) return false;
72
91
  if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
package/src/hub/tasks.ts CHANGED
@@ -564,6 +564,10 @@ export class Tasks {
564
564
  private plannerPullRecord(task: Task): HistoryEntry["planning"] {
565
565
  return task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned")?.planning;
566
566
  }
567
+ private hasPersonReservation(task: Task): boolean {
568
+ const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
569
+ return !!task.reserved && reservation?.by === USER;
570
+ }
567
571
  wip(peer: PeerId, except?: number): number {
568
572
  return this.d.board.plannerWip(peer, except);
569
573
  }
@@ -604,8 +608,7 @@ export class Tasks {
604
608
  }
605
609
  const assignment = this.plannerAssignment(task);
606
610
  if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
607
- const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
608
- if (task.reserved && reservation?.by === USER && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
611
+ if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
609
612
  return { task, owner: assignment.owner };
610
613
  };
611
614
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
@@ -723,6 +726,7 @@ export class Tasks {
723
726
  if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
724
727
  if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
725
728
  if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
729
+ if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
726
730
  if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
727
731
  if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
728
732
  if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
@@ -1611,7 +1615,11 @@ export class Tasks {
1611
1615
  if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
1612
1616
  if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
1613
1617
  }
1614
- if (next.reviewedHead !== task.sdlc?.reviewedHead) this.mine(task, by, "reviewer", true);
1618
+ // Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
1619
+ // head supplied with a new PR identity is one even when its value is unchanged (#382).
1620
+ const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
1621
+ const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
1622
+ if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
1615
1623
  return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
1616
1624
  }
1617
1625
 
@@ -1816,7 +1824,7 @@ export class Tasks {
1816
1824
  }
1817
1825
  if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
1818
1826
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
1819
- if (!command) return this.complete(task, by, summary, refs, "", withheld);
1827
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
1820
1828
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
1821
1829
  this.checking.set(task.id, task.owner);
1822
1830
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
@@ -1942,12 +1950,15 @@ export class Tasks {
1942
1950
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
1943
1951
  }
1944
1952
 
1945
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
1953
+ /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
1954
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
1946
1955
  let reviewer = task.reviewer;
1947
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") {
1956
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
1948
1957
  await this.lifecycleGate(task, reviewer ? "review" : "approval");
1949
1958
  task = this.need(task.id, false, true);
1950
1959
  this.mine(task, by, "owner", true);
1960
+ // In every mode: another done may have landed during the readback.
1961
+ if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
1951
1962
  reviewer = task.reviewer;
1952
1963
  }
1953
1964
  const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { extname, isAbsolute, join, resolve } from "node:path";
3
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
4
4
 
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { sbplString } from "../local/deny.ts";
@@ -8,6 +8,7 @@ import { spawnSync } from "node:child_process";
8
8
  import { realPath } from "./project.ts";
9
9
  import { profile, sandboxAvailable } from "../local/sandbox.ts";
10
10
  export type ProjectionConfig = Record<string, string[]>;
11
+ const EMPTY_ROOT = "/var/empty";
11
12
  export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
12
13
  const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
13
14
  /** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
@@ -23,14 +24,18 @@ export function projectionConfig(raw: unknown): ProjectionConfig {
23
24
  }
24
25
  export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
25
26
  const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
26
- function commandFingerprint(cwd: string, argv: string[]): string {
27
- const parts = [JSON.stringify(argv)];
27
+ /** The program and script files an argv runs, resolved as the converter will see them. */
28
+ function programFiles(cwd: string, argv: string[]): { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] {
29
+ const out: { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] = [];
28
30
  for (const [index, arg] of argv.entries()) {
29
- if (index !== 0 && !/\.(?:sh|py|js|ts|rb)$/.test(arg)) continue;
31
+ if (index !== 0 && !/\.(?:sh|py|js|mjs|cjs|ts|rb|pl)$/.test(arg)) continue;
30
32
  const choices = isAbsolute(arg) ? [arg] : index === 0 && !arg.includes("/") ? (process.env.PATH ?? "/usr/bin:/bin").split(":").slice(0, 32).map(dir => join(dir, arg)) : [resolve(cwd, arg)];
31
- for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; parts.push(file, String(st.size), String(st.mtimeMs), String(st.ctimeMs)); break; } catch { /* another PATH entry */ }
33
+ for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; out.push({ index, file, size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs }); break; } catch { /* another PATH entry */ }
32
34
  }
33
- return hash(parts.join("\\n"));
35
+ return out;
36
+ }
37
+ function commandFingerprint(cwd: string, argv: string[]): string {
38
+ return hash([JSON.stringify(argv), ...programFiles(cwd, argv).flatMap(p => [p.file, String(p.size), String(p.mtimeMs), String(p.ctimeMs)])].join("\\n"));
34
39
  }
35
40
  function boundedCache(file: string): string {
36
41
  const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
@@ -47,8 +52,12 @@ function boundedCache(file: string): string {
47
52
  export class TextProjections {
48
53
  private readonly sandbox: string;
49
54
  private readonly warned = new Set<string>();
55
+ /** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
56
+ private readonly failed = new Set<string>();
50
57
  constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
51
- this.sandbox = Object.keys(options.config).length ? profile(options.cwd, false, options.readAllow, options.deny, ["."]) : "";
58
+ // Rooted at an empty system directory, not the project: the converter reads its snapshot and program files only.
59
+ // Only where conversion can run: /var/empty is a macOS directory, and a host without the sandbox never converts.
60
+ this.sandbox = Object.keys(options.config).length && sandboxAvailable() ? profile(EMPTY_ROOT, false, options.readAllow, options.deny, ["."]) : "";
52
61
  }
53
62
  private warn(ext: string): void {
54
63
  if (this.warned.has(ext)) return;
@@ -103,17 +112,33 @@ export class TextProjections {
103
112
  if (value.inputHash === inputHash && value.rawHash === rawHash && value.rawBlob === rawBlob && value.configHash === configHash && typeof value.text === "string" && Buffer.byteLength(value.text) <= MAX_TEXT) return value;
104
113
  }
105
114
  } catch { /* missing/invalid cache is not evidence */ }
115
+ if (this.failed.has(key)) throw new Error("conversion failed before");
106
116
  if (!sandboxAvailable()) throw new Error("sandbox unavailable");
107
117
  scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
108
118
  writeFileSync(snapshot, bytes, { mode: 0o400 });
119
+ // A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
120
+ // deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
121
+ // needs that directory's data), with its program and script arguments resolved to absolute paths.
122
+ const root = realPath(this.options.cwd), projectDeny = "\n(deny file-read-data (subpath " + sbplString(root) + "))";
123
+ const resolved = programFiles(this.options.cwd, argv);
124
+ // A module loader lists the directories above its script: grant those listings (not their files) up to the root.
125
+ const listings = new Set<string>();
126
+ for (const program of resolved) for (let dir = dirname(realPath(program.file)); dir.startsWith(root + "/") || dir === root; dir = dirname(dir)) { listings.add(dir); if (dir === root) break; }
127
+ const programs = resolved.map(p => "\n(allow file-read* file-read-data process-exec (literal " + sbplString(realPath(p.file)) + "))").join("")
128
+ + [...listings].map(dir => "\n(allow file-read-data (literal " + sbplString(dir) + "))").join("")
129
+ + "\n(allow file-read* file-read-data (literal " + sbplString(scratch) + "))";
130
+ const absolute = new Map(resolved.map(p => [p.index, p.file]));
109
131
  const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
110
- cwd: this.options.cwd, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
111
- input: JSON.stringify({ cwd: this.options.cwd, sandbox: this.sandbox + "\n(allow file-read* (literal " + sbplString(snapshot) + "))", argv: argv.map(arg => arg === "{input}" ? snapshot : arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
132
+ cwd: scratch, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
133
+ input: JSON.stringify({ cwd: scratch, sandbox: this.sandbox + projectDeny + "\n(allow file-read* file-read-data (literal " + sbplString(snapshot) + "))" + programs, argv: argv.map((arg, index) => arg === "{input}" ? snapshot : absolute.get(index) ?? arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
112
134
  env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
113
135
  });
114
- if (result.error || result.status !== 0) throw new Error("conversion failed");
136
+ // ponytail: in-memory for this process, cleared at 1024 keys; persist it if restarts make retries costly.
137
+ const failure = () => { if (deadline === undefined) { if (this.failed.size >= 1024) this.failed.clear(); this.failed.add(key); } return new Error("conversion failed"); };
138
+ if (result.error) throw new Error("conversion helper failed"); // a spawn or helper failure may be transient: not remembered
139
+ if (result.status !== 0) throw failure();
115
140
  const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
116
- if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw new Error("conversion failed");
141
+ if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw failure();
117
142
  verify?.();
118
143
  const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
119
144
  mkdirSync(cacheDir, { recursive: true });
@@ -1,5 +1,5 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { accessSync, closeSync, constants, fstatSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
5
5
  import { realPath } from "./project.ts";
@@ -32,8 +32,18 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
32
32
  /** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
33
33
  export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
34
34
 
35
+ /** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
36
+ const text = (file: string): string | undefined => {
37
+ let fd: number | undefined;
38
+ try {
39
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
40
+ const st = fstatSync(fd);
41
+ if (!st.isFile() || st.size > 1024 * 1024) return undefined;
42
+ return readFileSync(fd, "utf8");
43
+ } catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
44
+ };
35
45
  const json = (file: string): Record<string, unknown> | undefined => {
36
- try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
46
+ try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
37
47
  };
38
48
  /** A version string as data, never anything longer or stranger. */
39
49
  const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
@@ -58,12 +68,13 @@ function claudePlugins(root: string, cwd: string | undefined): Addon[] {
58
68
  }
59
69
 
60
70
  function codexPlugins(root: string): Addon[] {
61
- let text: string;
62
- try { text = readFileSync(join(root, "config.toml"), "utf8"); } catch { return []; }
63
- const found = new Map<string, string>(); // plugin name -> marketplace
64
- // `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust.
65
- for (const m of text.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!);
66
- for (const m of text.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) found.set(m[1]!, m[2]!);
71
+ const config = text(join(root, "config.toml"));
72
+ if (config === undefined) return [];
73
+ const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
74
+ // `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
75
+ // unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
76
+ for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
77
+ for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
67
78
  const out: Addon[] = [];
68
79
  for (const [plugin, market] of found) {
69
80
  const name = PLUGINS.get(plugin);
@@ -533,13 +533,18 @@ export class MemoryStore {
533
533
  return gone.size;
534
534
  }
535
535
 
536
+ /** Index deferred writes before any note, goal or suggestion read. */
537
+ private ensureFresh(): void {
538
+ // Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
539
+ if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
540
+ }
541
+
536
542
  /**
537
543
  * What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
538
544
  * read, and the index after it, never differ from a rebuild.
539
545
  */
540
546
  private live(): View[] {
541
- // Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
542
- if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
547
+ this.ensureFresh();
543
548
  this.prune(); this.promoteHeld(); return this.views();
544
549
  }
545
550
  /** A write whose index update failed: the next read refreshes first. */
@@ -677,6 +682,7 @@ export class MemoryStore {
677
682
 
678
683
  /** Every long-term task with its derived state, links and lease, newest first; `project` keeps its own and shared ones. */
679
684
  goals(project?: string): GoalView[] {
685
+ this.ensureFresh();
680
686
  const now = this.now();
681
687
  const rows = this.db.query("SELECT * FROM goals").all() as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
682
688
  const ops = this.db.query("SELECT op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id").all() as { op: string; at: string; machine: string; target: string; by: string | null }[];
@@ -726,6 +732,7 @@ export class MemoryStore {
726
732
  return s;
727
733
  }
728
734
  suggestions(project?: string): Suggestion[] {
735
+ this.ensureFresh();
729
736
  const visible = project ? new Set(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => v.id)) : undefined;
730
737
  const ops = this.db.query("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
731
738
  const verdict = new Map<string, "confirmed" | "rejected">();