@staix/agent-hub 0.12.19 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +413 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +17 -10
  12. package/docs/smoke.md +58 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +56 -9
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
package/src/hub/bus.ts CHANGED
@@ -715,13 +715,69 @@ export class Bus {
715
715
  finally { this.steering--; this.onQueues?.(); }
716
716
  }
717
717
 
718
+ /** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
719
+ private held(id: PeerId): boolean {
720
+ return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id);
721
+ }
722
+
723
+ /** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
724
+ private remember(envs: Envelope[]): void {
725
+ for (const e of envs) { const kept = this.seen.get(e.id) ?? e; this.seen.delete(e.id); this.seen.set(e.id, kept); }
726
+ while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
727
+ }
728
+
729
+ /** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
730
+ private deliverable(id: PeerId): boolean {
731
+ return !this.held(id) && this.stateOf(id) === "idle" && !this.peers.get(id)?.pullOnly;
732
+ }
733
+
734
+ /**
735
+ * What one push delivery would take (the preface, then `take`'s batch), handed to a pull-only peer and recorded as
736
+ * completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
737
+ * `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
738
+ * are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
739
+ * back only this peer's queue and preface (every pause stays) and stops the bus like any other journal failure.
740
+ * ponytail: recorded completed at hand-out, before anything shows the model saw it: a reply lost on the way, a tool
741
+ * call cancelled (Esc) or a plugin that dies loses the batch, and `ahub queue resolve --action retry` refuses a
742
+ * terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
743
+ * steps if loss is ever observed.
744
+ */
745
+ pull(id: PeerId): Envelope[] | undefined {
746
+ if (this.storageError) throw new Error("delivery journal unavailable");
747
+ if (this.held(id)) return undefined;
748
+ const queue = this.queues.get(id) ?? [];
749
+ this.dropIrrelevant(id, queue);
750
+ const preface = this.prefaces.get(id);
751
+ if (!preface && !queue.length) return [];
752
+ const queued = [...queue];
753
+ const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
754
+ this.prefaces.delete(id);
755
+ // Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
756
+ this.remember(batch);
757
+ try {
758
+ const deliveryId = crypto.randomUUID();
759
+ this.journal?.transaction(() => {
760
+ this.durableHandoff(id, deliveryId, batch, batch); // its bus snapshot is the queue after this pull
761
+ this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
762
+ });
763
+ } catch (error) {
764
+ queue.splice(0, queue.length, ...queued);
765
+ if (preface) this.prefaces.set(id, preface);
766
+ this.storageError = "delivery journal unavailable";
767
+ throw error;
768
+ }
769
+ this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
770
+ this.onQueues?.();
771
+ return batch;
772
+ }
773
+
718
774
  private async drain(id: PeerId): Promise<void> {
719
775
  if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
720
776
  this.draining.add(id);
721
777
  try {
722
778
  const peer = this.peers.get(id)!;
723
779
  const queue = this.queues.get(id)!;
724
- while (!this.storageError && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && queue.length && this.stateOf(id) === "idle") {
780
+ while (!this.storageError && queue.length && this.deliverable(id)) {
725
781
  if (this.dropIrrelevant(id, queue) && !queue.length) break;
726
782
  const delay = this.wait(queue);
727
783
  if (delay > 0) { this.arm(id, delay); break; }
@@ -736,7 +792,8 @@ export class Bus {
736
792
  this.condensing += mayCondense ? 1 : 0;
737
793
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
738
794
  this.condensing -= mayCondense ? 1 : 0;
739
- if (this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.stateOf(id) !== "idle") {
795
+ // A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
796
+ if (!this.deliverable(id)) {
740
797
  if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
741
798
  break;
742
799
  }
@@ -751,8 +808,7 @@ export class Bus {
751
808
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
752
809
  this.prefaces.delete(id);
753
810
  }
754
- for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
755
- while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
811
+ this.remember(out);
756
812
  this.lastDelivery.set(id, { out, originals: delivery });
757
813
  this.preparing.delete(id); // from here on the transport/journal owns settlement
758
814
  const deliveryId = crypto.randomUUID();
@@ -17,6 +17,7 @@ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.e
17
17
  delete env.CLAUDECODE;
18
18
  delete env.CLAUDE_CODE_SESSION_ID;
19
19
  delete env.CODEX_THREAD_ID;
20
+ delete env.AGENTHUB_CHANNEL;
20
21
  delete env.AGENTHUB_PEER_ID;
21
22
  env.AGENTHUB_PEER_ID = peer;
22
23
  return env;
@@ -1,6 +1,8 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Task } from "./board.ts";
3
3
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
+ import { HUB, USER } from "./envelope.ts";
5
+ import { OWNERSHIP_EVENTS } from "./tasks.ts";
4
6
  import type { HubEvent } from "./events.ts";
5
7
  import type { SupervisionFeed } from "./supervision.ts";
6
8
  import type { Budget } from "./budget.ts";
@@ -45,10 +47,14 @@ export function conductorPeer(roles: unknown): string | null {
45
47
  /** The role check always comes first; default-allow capabilities cannot grant a role. */
46
48
  export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
47
49
  if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
48
- if (assign && Object.hasOwn(capabilities, peer)) {
49
- const caps = capabilities[peer];
50
- if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
51
- }
50
+ if (assign) requireAssign(peer, capabilities);
51
+ }
52
+
53
+ /** A peer with an explicit capabilities list needs `assign` in it to move work. */
54
+ function requireAssign(peer: string, capabilities: Record<string, unknown>): void {
55
+ if (!Object.hasOwn(capabilities, peer)) return;
56
+ const caps = capabilities[peer];
57
+ if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
52
58
  }
53
59
 
54
60
  export interface ConductorHold { peer: string; actor: string; since: number }
@@ -154,10 +160,35 @@ export interface ConductorHooks {
154
160
  audit(event: ConductEvent): void;
155
161
  }
156
162
 
163
+ /** A task id as `Tasks.need()` takes it: a whole number or a digit string. */
164
+ const taskId = (v: unknown): number | undefined => {
165
+ const n = typeof v === "number" ? v : typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : NaN;
166
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
167
+ };
168
+
157
169
  export class Conductor {
158
170
  constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
159
171
  async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
160
172
  if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new Error("unknown conductor tool");
173
+ if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
174
+ const id = taskId(args.id);
175
+ const task = id === undefined ? undefined : this.hooks.task(id);
176
+ // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
177
+ if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
178
+ // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
179
+ // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
180
+ // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
181
+ // moves are not decisions).
182
+ const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
183
+ const accepted = task?.history.some((h) => h.event === "accepted");
184
+ if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
185
+ const peer = peerId(args.peer);
186
+ if (peer !== actor) requireAssign(actor, this.hooks.capabilities());
187
+ await this.hooks.assign(actor, task.id, peer);
188
+ const updated = this.hooks.task(task.id);
189
+ return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
190
+ }
191
+ }
161
192
  requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate");
162
193
  const action = tool.slice(4);
163
194
  const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
@@ -165,8 +196,9 @@ export class Conductor {
165
196
  };
166
197
  if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
167
198
  if (tool.startsWith("hub_task_")) {
168
- if (typeof args.id !== "number" || !Number.isSafeInteger(args.id) || args.id <= 0) throw new Error("id must be a positive integer");
169
- const task = this.hooks.task(args.id);
199
+ const id = taskId(args.id);
200
+ if (id === undefined) throw new Error("id must be a positive integer");
201
+ const task = this.hooks.task(id);
170
202
  if (!task) throw new Error(`no task #${args.id}`);
171
203
  if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
172
204
  if (tool === "hub_task_assign") {
@@ -6,15 +6,17 @@ export function stateDirFor(cwd: string): string {
6
6
  return projectContext(cwd).stateDir;
7
7
  }
8
8
 
9
- /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 15;
9
+ /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
+ export const PROTOCOL = 16;
11
11
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
12
- export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
12
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
13
13
 
14
14
  export interface Hello {
15
15
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
16
16
  role: "peer" | "console" | "tools";
17
17
  peer?: string;
18
+ /** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
19
+ channel?: boolean;
18
20
  projectId?: string;
19
21
  instanceId?: string;
20
22
  projectRoot?: string;
package/src/hub/daemon.ts CHANGED
@@ -18,7 +18,7 @@ import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
18
18
  import { CodexPeer } from "../adapters/codex-appserver.ts";
19
19
  import { PiPeer } from "../adapters/pi.ts";
20
20
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
21
- import type { MlxOptions } from "../models/mlx.ts";
21
+ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
22
22
  import { PiToolReceipts } from "../pi/tool-receipts.ts";
23
23
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
24
24
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
@@ -42,7 +42,7 @@ import { SupervisionFeed } from "./supervision.ts";
42
42
  import { drainCliAudits } from "../cli/identity-audit.ts";
43
43
  import { launcherPreview } from "../cli/preview.ts";
44
44
  import { Tasks } from "./tasks.ts";
45
- import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./inference.ts";
45
+ import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
46
46
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
47
47
  import { currentRouting, detectSignals } from "./routing.ts";
48
48
  import { Bus } from "./bus.ts";
@@ -61,7 +61,7 @@ import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, typ
61
61
  import type { JournalDelivery } from "./delivery-journal.ts";
62
62
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
63
63
  import { changedPaths, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
64
- import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
64
+ import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
65
65
 
66
66
  import { ContextWindows, DEFAULT_CONTEXT, claudeContext, type ContextConfig } from "./context-window.ts";
67
67
 
@@ -306,10 +306,18 @@ class WsPeer extends BasePeer {
306
306
  private claimed: Sock | undefined;
307
307
  private nativeSession: string | undefined;
308
308
  private nativeActive = false;
309
+ /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
310
+ pullOnly = false;
311
+ private claimedPullOnly = false;
309
312
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
310
- claim(sock: Sock): void {
313
+ claim(sock: Sock, pullOnly = false): void {
311
314
  this.claimGeneration = crypto.randomUUID();
312
315
  this.claimed = sock;
316
+ this.claimedPullOnly = pullOnly;
317
+ }
318
+ /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
319
+ get channelHeld(): boolean {
320
+ return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
313
321
  }
314
322
  /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
315
323
  * for the id must not take it from a claimant that is still arriving. */
@@ -325,12 +333,14 @@ class WsPeer extends BasePeer {
325
333
  this.delivered.clear();
326
334
  this.nativeSession = undefined;
327
335
  this.nativeActive = false;
336
+ this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
328
337
  this.setState("idle");
329
338
  }
330
339
  detach(sock: Sock): void {
331
340
  if (this.sock !== sock) return;
332
341
  this.sock = undefined;
333
342
  this.nativeActive = false;
343
+ this.pullOnly = false;
334
344
  this.setState("offline");
335
345
  }
336
346
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
@@ -663,6 +673,9 @@ export async function startDaemon(opts: DaemonOptions) {
663
673
  }
664
674
  },
665
675
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
676
+ // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
677
+ piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model }),
678
+ recordScreen: (record) => event({ type: "pii_screen", ...record }),
666
679
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
667
680
  review: config.review,
668
681
  sweep: taskSweepConfig(config.task_sweep),
@@ -1280,6 +1293,11 @@ export async function startDaemon(opts: DaemonOptions) {
1280
1293
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
1281
1294
  return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
1282
1295
  };
1296
+ /** Why a paused peer gets nothing; the bus pause itself is the hold. */
1297
+ const pauseReason = (id: PeerId) => {
1298
+ const r = budget.record(id), hold = conductorHolds.get(id);
1299
+ return r ? `paused by the budget coordinator: ${r.reason}; to override: ahub budget resume ${id}` : manualPaused.has(id) ? `paused by the console user; ahub resume ${id}` : hold ? `held by the conductor ${hold.actor}; it releases it with hub_peer_release, or the console user with ahub resume ${id}` : "paused";
1300
+ };
1283
1301
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
1284
1302
  const recoveryReady = () => {
1285
1303
  if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
@@ -1293,7 +1311,10 @@ export async function startDaemon(opts: DaemonOptions) {
1293
1311
  // online at the snapshot is still offline, so skipping the comparison cannot hide an
1294
1312
  // identity change - it only keeps a detach from wedging readiness forever.
1295
1313
  if (now.state === "offline") return true;
1296
- if (saved.threadId && saved.threadId !== now.threadId) return false;
1314
+ // The coordinator waived this peer's id on the target: an unmanaged Claude session reconnects and can never report
1315
+ // the saved id here (#206), or the operator moved the peer to a fresh session (#215). It still has to reattach.
1316
+ const waived = () => recoveryPhase === "restored" && !!readRecoveryWaivers(opts.stateDir, recoveryOperationId!)[saved.id];
1317
+ if (saved.threadId && saved.threadId !== now.threadId && !waived()) return false;
1297
1318
  // Kimi/local rebuild a fresh worker with task context on the target; native
1298
1319
  // Claude and Pi sessions must keep their exact identities across restoration.
1299
1320
  const freshWorker = recoveryPhase === "restored" && (saved.id === "kimi" || saved.id === "local");
@@ -1302,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions) {
1302
1323
  // restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
1303
1324
  // written before this flag existed are re-derived from disk.
1304
1325
  const unpersistedClaude = saved.id === "claude" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
1305
- if (!unpersistedClaude) return false;
1326
+ if (!unpersistedClaude && !waived()) return false;
1306
1327
  }
1307
1328
  return true;
1308
1329
  });
@@ -1448,7 +1469,7 @@ export async function startDaemon(opts: DaemonOptions) {
1448
1469
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
1449
1470
  codexProxyPort: opts.codexProxyPort,
1450
1471
  peers: Object.fromEntries(
1451
- bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
1472
+ bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { toolsOnly: "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude" } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
1452
1473
  ),
1453
1474
  ...(sidecar ? { switchyard: sidecar.status } : {}),
1454
1475
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -1501,6 +1522,7 @@ export async function startDaemon(opts: DaemonOptions) {
1501
1522
  releasing = true;
1502
1523
  try {
1503
1524
  await tasks.releaseReady(); // #34: dependents a stop cut off between an approval and their assignment
1525
+ await tasks.rescreen().catch((e: Error) => log(`PII re-screen failed: ${e.message}`)); // #198: no verdict yet, nobody has it
1504
1526
  if (limit > 0) await releaseOwners(limit);
1505
1527
  } finally {
1506
1528
  releasing = false;
@@ -1908,6 +1930,8 @@ export async function startDaemon(opts: DaemonOptions) {
1908
1930
  const codex = new CodexPeer("codex", {
1909
1931
  onTokens: (added) => void addTokens("codex", added),
1910
1932
  onContext: (reading) => contexts.report("codex", reading, contextSession("codex")),
1933
+ // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
1934
+ onThread: (thread, fresh) => event({ type: "native_thread", peer: "codex", thread, fresh }),
1911
1935
  onTurn: (native) => {
1912
1936
  const open = turns.get("codex");
1913
1937
  if (open) turnLog?.native(open.id, native);
@@ -1975,7 +1999,14 @@ export async function startDaemon(opts: DaemonOptions) {
1975
1999
  if (problem) return { ok: false, error: problem };
1976
2000
  modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
1977
2001
  routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
2002
+ staySwitch: () => currentRouting(opts.cwd, log),
1978
2003
  onRoute: record => event({ type: "route", peer: "pi", ...record }),
2004
+ efficientWaitMs: currentRouting(opts.cwd, log).pi.efficient_wait_ms,
2005
+ underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
2006
+ onCooldown: record => {
2007
+ log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
2008
+ event({ type: "cooldown", peer: "pi", ...record });
2009
+ },
1979
2010
  });
1980
2011
  piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
1981
2012
  let piReply: Envelope | undefined;
@@ -2119,6 +2150,7 @@ export async function startDaemon(opts: DaemonOptions) {
2119
2150
  ...(route ? { route } : {}),
2120
2151
  ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
2121
2152
  hubRoutes: () => currentRouting(opts.cwd, log).hub_routes ?? {},
2153
+ staySwitch: () => currentRouting(opts.cwd, log),
2122
2154
  onRoute: record => event({ type: "route", peer: "local", ...record }),
2123
2155
  onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
2124
2156
  onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
@@ -2484,7 +2516,9 @@ export async function startDaemon(opts: DaemonOptions) {
2484
2516
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
2485
2517
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
2486
2518
  const ws = peer;
2487
- ws.claim(sock);
2519
+ // Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
2520
+ if (msg.channel === false && ws.channelHeld) return sock.close(4000, "a session with channel pushes holds this peer");
2521
+ ws.claim(sock, msg.channel === false);
2488
2522
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
2489
2523
  void ensurePreface(c.peer).finally(() => {
2490
2524
  if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
@@ -2516,9 +2550,22 @@ export async function startDaemon(opts: DaemonOptions) {
2516
2550
  case "delivery_receipt": {
2517
2551
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
2518
2552
  if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
2519
- bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
2553
+ const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
2554
+ bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
2520
2555
  return void reply({ ok: true });
2521
2556
  }
2557
+ case "inbox": {
2558
+ // A Claude session that cannot show channel pushes reads its queue here; nothing it reads is ever `accepted` (issue #205).
2559
+ const peer = c.peer ? bus.peers.get(c.peer) : undefined;
2560
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
2561
+ if (!peer.pullOnly) return void reply({ ok: false, error: "messages are pushed to this session; hub_inbox only drains failed pushes" });
2562
+ try {
2563
+ const envs = bus.pull(peer.id);
2564
+ if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
2565
+ if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
2566
+ return void reply({ ok: true, envs, waiting: bus.queued(peer.id) });
2567
+ } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
2568
+ }
2522
2569
  case "delivery_complete": {
2523
2570
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
2524
2571
  if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
package/src/hub/events.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import type { TaskAttribution } from "./attribution.ts";
3
+ import type { SwitchTrace } from "../models/route/stage.ts";
4
+ import type { ScreenRecord } from "./inference.ts";
3
5
 
4
6
  /** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
5
7
  export const EVENTS_SCHEMA = 1;
@@ -15,7 +17,8 @@ export type HubEvent =
15
17
  | { type: "quiet"; id: string; from: string; peers: string[] }
16
18
  | { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; named: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
17
19
  | { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
18
- | { type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default"; score: number; ms: number; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number }
20
+ | ({ type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin"; score: number; ms: number; stateless?: boolean; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number } & Partial<SwitchTrace>)
21
+ | { type: "cooldown"; peer: string; alias: string; event: "start" | "end"; failures: number; ms?: number }
19
22
  | { type: "route_outcome"; peer: string; decision: string; turnId: string; turn: "completed" | "failed"; task?: number; pii: boolean; latched: boolean; next?: { severity: number; tests: "pass" | "fail" | "none"; repeat: boolean }; advisor?: "approve" | "redo" | "failed" }
20
23
  | { type: "advisor"; peer: string; route: string; trigger: string; verdict: "approve" | "redo" | "failed"; discardedChars: number }
21
24
  | { type: "progress"; peer: string; task: number; severity: number; spinning: number; exploring: number; production: number }
@@ -24,6 +27,7 @@ export type HubEvent =
24
27
  | { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
25
28
  | { type: "state"; peer: string; state: string }
26
29
  | { type: "turn_start"; peer: string; turn: string }
30
+ | { type: "native_thread"; peer: string; thread: string; fresh: boolean }
27
31
  | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
28
32
  | { type: "native_turn_end"; peer: string; id?: string }
29
33
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
@@ -37,6 +41,8 @@ export type HubEvent =
37
41
  | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
38
42
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
39
43
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
44
+ /** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
45
+ | ({ type: "pii_screen" } & ScreenRecord)
40
46
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
41
47
  | { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
42
48
  | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
@@ -83,16 +83,25 @@ export class ExecutionBudget {
83
83
  return this.admitTasks(taskId === undefined ? [] : [taskId], peer, unit, amount);
84
84
  }
85
85
 
86
+ /** Whether any budget meters this peer's work on these tasks. It only reads: nothing is admitted or counted. */
87
+ applies(taskIds: number[], peer: string): boolean {
88
+ return this.scopes(taskIds, peer).length > 0;
89
+ }
90
+
91
+ private scopes(taskIds: number[], peer: string): BudgetRow[] {
92
+ const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
93
+ const taskRows = ids.length
94
+ ? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
95
+ : [];
96
+ const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
97
+ return [...taskRows, ...runRows];
98
+ }
99
+
86
100
  admitTasks(taskIds: number[], peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
87
101
  if (!UNITS.has(unit) || !Number.isSafeInteger(amount) || amount <= 0) throw new Error("execution budget admission is invalid");
88
102
  const transaction = this.db.transaction(() => {
89
103
  // Read scopes only after BEGIN IMMEDIATE so a concurrent configure/admission cannot evade a scope or overdraw it.
90
- const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
91
- const taskRows = ids.length
92
- ? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
93
- : [];
94
- const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
95
- const rows = [...taskRows, ...runRows].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
104
+ const rows = this.scopes(taskIds, peer).sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
96
105
  const pending: { cfg: ExecutionBudgetStatus; updates: Partial<Record<ExecutionUnit, number>>; decisions: ExecutionBudgetDecision[] }[] = rows.map((row) => {
97
106
  const cfg = fromRow(row), updates: Partial<Record<ExecutionUnit, number>> = {}, decisions: ExecutionBudgetDecision[] = [];
98
107
  const now = Date.now(), elapsedLimit = cfg.limits.elapsed_ms;
@@ -35,12 +35,12 @@ const tool = (name: string, description: string, properties: Record<string, unkn
35
35
  });
36
36
 
37
37
  export const TASK_TOOLS: HubTool[] = [
38
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
38
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
39
39
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
40
40
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
41
41
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
42
42
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
43
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
44
44
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
45
45
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
46
46
  ];
@@ -50,8 +50,8 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
50
50
  /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
51
51
  export const CONDUCTOR_TOOLS: HubTool[] = [
52
52
  tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
53
- tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
54
- tool("hub_task_assign", "Move a task to another peer, as the conductor. Requires assign capability when the conductor has an explicit capabilities list.", { id, peer: str }, ["id", "peer"]),
53
+ tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
54
+ tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
55
55
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
56
56
  tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
57
57
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),