@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,30 @@
1
+ import { sanitize, type Kind } from "./envelope.ts";
2
+
3
+ export const DELIVERY_RETRY_WARNING = sanitize("Hub redelivery warning: An earlier attempt of this delivery is uncertain; check for its effects before acting.");
4
+ export interface HeldDeliveryHints {
5
+ count: number;
6
+ rows: { id: string; senders: string[]; kinds: Kind[]; createdAt: number }[];
7
+ truncated: boolean;
8
+ }
9
+ export function heldDeliveryHints(rows: readonly HeldDeliveryHints["rows"][number][], count = rows.length): HeldDeliveryHints {
10
+ const result: HeldDeliveryHints = { count, rows: [], truncated: count > rows.length };
11
+ for (const row of rows) {
12
+ const value = { ...row, senders: row.senders.map(sanitize) };
13
+ if (result.rows.length >= 32 || Buffer.byteLength(JSON.stringify({ ...result, rows: [...result.rows, value] }), "utf8") > 3500) { result.truncated = true; break; }
14
+ result.rows.push(value);
15
+ }
16
+ return result;
17
+ }
18
+ export function renderHeldHint(value: unknown, now = Date.now(), recipientRetry = true): string {
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "";
20
+ const held = value as HeldDeliveryHints;
21
+ const kinds = new Set(["chat", "task", "review", "status", "budget", "presence"]);
22
+ try {
23
+ if (!Number.isSafeInteger(held.count) || held.count < 1 || !Array.isArray(held.rows) || held.rows.length > 32 || held.rows.length > held.count || typeof held.truncated !== "boolean" || Buffer.byteLength(JSON.stringify(held), "utf8") > 4096) return "";
24
+ if (held.rows.some(row => !row || typeof row !== "object" || typeof row.id !== "string" || !row.id.length || row.id.length > 256 || !Number.isFinite(row.createdAt) || !Array.isArray(row.senders) || !Array.isArray(row.kinds) || row.senders.some(sender => typeof sender !== "string" || sender.length > 256) || row.kinds.some(kind => !kinds.has(kind)))) return "";
25
+ } catch { return ""; }
26
+ const rows = held.rows.map(row => `${sanitize(row.id)} from ${row.senders.map(sanitize).join(",")} kind ${row.kinds.join(",")} age ${Math.max(0, Math.floor((now - row.createdAt) / 1000))}s`);
27
+ const action = recipientRetry ? "Use hub_delivery_retry {delivery_id} for one retry of your own uncertain row; check for earlier effects. A repeated uncertain retry needs the person." : "Ask the person to resolve these held deliveries; this delivery-only peer cannot initiate recipient retry.";
28
+ const out = `Held hub deliveries: ${held.count}${held.truncated ? " (metadata truncated)" : ""}\n${rows.join("\n")}\n${action}`;
29
+ return Buffer.byteLength(out, "utf8") <= 4096 ? out : `Held hub deliveries: ${held.count} (metadata truncated; ask the person to inspect the queue).`;
30
+ }
@@ -2,7 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
- import type { Envelope } from "./envelope.ts";
5
+ import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
6
+ import { heldDeliveryHints } from "./delivery-hints.ts";
6
7
  import { processLiveness } from "../pi/process-signature.ts";
7
8
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
9
 
@@ -15,6 +16,7 @@ export type JournalDeliveryState =
15
16
  | "failed"
16
17
  | "discarded";
17
18
  export type JournalResolution = "completed" | "retry" | "discard";
19
+ export const MAX_ENVELOPE_BODY = 1_000_000;
18
20
  const SCHEMA_VERSION = 1,
19
21
  TERMINAL_CAP = 2048,
20
22
  MAX_ROWS = 100_000,
@@ -29,6 +31,8 @@ const STATES = new Set<JournalDeliveryState>([
29
31
  "discarded",
30
32
  ]);
31
33
  const ACTIONS = new Set<JournalResolution>(["completed", "retry", "discard"]);
34
+ // Hub presence ids survive context merges; they are not immutable attempted work.
35
+ const retryOriginals = (originals: readonly Envelope[]): Envelope[] => originals.filter(env => env.from !== HUB || env.kind !== "presence");
32
36
 
33
37
  export interface JournalDelivery {
34
38
  id: string;
@@ -108,7 +112,7 @@ function validateEnvelope(value: unknown, name: string, seen = false): Envelope
108
112
  text(e.trace, `${name}.trace`, 256);
109
113
  text(e.from, `${name}.from`, 256);
110
114
  integer(e.hop, `${name}.hop`, 0, seen ? Number.MAX_SAFE_INTEGER : 3);
111
- if (typeof e.body !== "string" || e.body.length > 1_000_000) fail(`${name}.body is invalid`);
115
+ if (typeof e.body !== "string" || e.body.length > MAX_ENVELOPE_BODY) fail(`${name}.body is invalid`);
112
116
  integer(e.ts, `${name}.ts`);
113
117
  if (
114
118
  !new Set(["chat", "task", "review", "status", "budget", "presence"]).has(
@@ -249,7 +253,7 @@ export class DeliveryJournal {
249
253
  if (!meta)
250
254
  this.db
251
255
  .query(
252
- "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id) VALUES (?, ?, 0, ?, ?)",
256
+ "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor) VALUES (?, ?, 0, ?, ?, 0)",
253
257
  )
254
258
  .run(
255
259
  this.projectId,
@@ -273,6 +277,9 @@ export class DeliveryJournal {
273
277
  );
274
278
  // A new daemon cannot infer completion of an old process's handoffs, including
275
279
  // controlled restarts with silent native/bridge receipts still outstanding.
280
+ for (const row of this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateOfferRead(row);
281
+ this.recoverRetryHistory(meta);
282
+ this.recoverRetryEvidence();
276
283
  this.recoverInterrupted();
277
284
  })();
278
285
  }
@@ -296,6 +303,8 @@ export class DeliveryJournal {
296
303
  ["project_root", "TEXT"],
297
304
  ["schema_version", "INTEGER NOT NULL DEFAULT 1"],
298
305
  ["operation_id", "TEXT"],
306
+ ["retry_unknown_ids", "TEXT NOT NULL DEFAULT '{}'"],
307
+ ["retry_history_cursor", "INTEGER NOT NULL DEFAULT 0"],
299
308
  ] as const) {
300
309
  const columns = this.db
301
310
  .query("PRAGMA table_info(delivery_meta)")
@@ -308,9 +317,58 @@ export class DeliveryJournal {
308
317
  this.db.run(
309
318
  `CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
310
319
  );
320
+ this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
311
321
  this.db.run(
312
322
  `CREATE TABLE IF NOT EXISTS resolution_history (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, delivery_id TEXT NOT NULL, observed_revision INTEGER NOT NULL, action TEXT NOT NULL, reason TEXT NOT NULL, result_revision INTEGER NOT NULL, result_state TEXT NOT NULL, UNIQUE(project_id, delivery_id, observed_revision, action, reason))`,
313
323
  );
324
+ this.db.run("CREATE TABLE IF NOT EXISTS delivery_retry_evidence (project_id TEXT NOT NULL, peer TEXT NOT NULL, original_id TEXT NOT NULL, proof_id TEXT NOT NULL, warning INTEGER NOT NULL CHECK(warning IN (0,1)), PRIMARY KEY(project_id, peer, original_id))");
325
+ this.db.transaction(() => {
326
+ const resolutionColumns = this.db.query("PRAGMA table_info(resolution_history)").all() as { name: string }[];
327
+ if (!resolutionColumns.some((column) => column.name === "actor")) this.db.run("ALTER TABLE resolution_history ADD COLUMN actor TEXT NOT NULL DEFAULT 'user'");
328
+ if (!resolutionColumns.some((column) => column.name === "peer")) this.db.run("ALTER TABLE resolution_history ADD COLUMN peer TEXT NOT NULL DEFAULT ''");
329
+ if (!resolutionColumns.some((column) => column.name === "original_ids")) this.db.run("ALTER TABLE resolution_history ADD COLUMN original_ids TEXT NOT NULL DEFAULT '[]'");
330
+ this.db.run("CREATE INDEX IF NOT EXISTS resolution_retry_peer ON resolution_history(project_id, peer, action)");
331
+ this.db.run("CREATE INDEX IF NOT EXISTS deliveries_peer ON deliveries(project_id, peer, state)");
332
+ })();
333
+ }
334
+ private recoverRetryHistory(meta: Record<string, unknown>): void {
335
+ const cursor = Number(meta.retry_history_cursor ?? 0);
336
+ const latest = Number((this.db.query("SELECT COALESCE(MAX(id), 0) AS n FROM resolution_history WHERE project_id = ?").get(this.projectId) as { n: number }).n);
337
+ if (latest > cursor) {
338
+ const legacy = this.db.query("SELECT r.id,d.peer,d.originals FROM resolution_history r LEFT JOIN deliveries d ON d.id = r.delivery_id AND d.project_id = r.project_id WHERE r.project_id = ? AND r.peer = '' AND r.id > ?").all(this.projectId, cursor) as { id: number; peer: string | null; originals: string | null }[];
339
+ for (const row of legacy) if (row.peer && row.originals) this.db.query("UPDATE resolution_history SET peer = ?, original_ids = ? WHERE project_id = ? AND id = ?").run(row.peer, JSON.stringify(retryOriginals(JSON.parse(row.originals) as Envelope[]).map(env => env.id)), this.projectId, row.id);
340
+ // A pruned pre-feature retry cannot be mapped back to its originals. Fence current legacy work only.
341
+ if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND action = 'retry' AND peer = '' AND id > ? LIMIT 1").get(this.projectId, cursor)) {
342
+ this.markRetryUnknown(this.list(), JSON.parse(String(meta.bus_snapshot || '{}')));
343
+ }
344
+ this.db.query("UPDATE delivery_meta SET retry_history_cursor = ? WHERE project_id = ?").run(latest, this.projectId);
345
+ }
346
+ }
347
+ private validateRetryProof(row: Record<string, unknown>): void {
348
+ text(row.peer, "retry proof peer", 256); text(row.original_id, "retry proof original", 256); text(row.proof_id, "retry proof id", 256);
349
+ integer(row.warning, "retry proof warning", 0, 1);
350
+ }
351
+ private retryProof(peer: string, originals: readonly Envelope[]): { proof: string; warning: boolean } | undefined {
352
+ originals = retryOriginals(originals);
353
+ if (!originals.length) return undefined;
354
+ const rows = this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ? AND peer = ? AND original_id IN (SELECT value FROM json_each(?))").all(this.projectId, peer, JSON.stringify(originals.map(env => env.id))) as Record<string, unknown>[];
355
+ for (const row of rows) this.validateRetryProof(row);
356
+ return rows.length ? { proof: String(rows[0]!.proof_id), warning: rows.some(row => row.warning === 1) } : undefined;
357
+ }
358
+ private recordRetryEvidence(peer: string, originals: readonly Envelope[], proof: string, warning: boolean): void {
359
+ const put = this.db.query("INSERT INTO delivery_retry_evidence (project_id,peer,original_id,proof_id,warning) VALUES (?,?,?,?,?) ON CONFLICT(project_id,peer,original_id) DO UPDATE SET warning = MAX(warning,excluded.warning)");
360
+ for (const id of new Set(retryOriginals(originals).map(env => env.id))) put.run(this.projectId, peer, id, proof, warning ? 1 : 0);
361
+ }
362
+ private recoverRetryEvidence(): void {
363
+ for (const row of this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateRetryProof(row);
364
+ // An older writer can append attempt-bearing rows while this writer is absent.
365
+ const rows = this.db.query("SELECT d.id,d.peer,d.originals FROM deliveries d WHERE d.project_id = ? AND d.attempt > 0 AND EXISTS (SELECT 1 FROM json_each(d.originals) env WHERE NOT EXISTS (SELECT 1 FROM delivery_retry_evidence e WHERE e.project_id = d.project_id AND e.peer = d.peer AND e.original_id = json_extract(env.value,'$.id')))").all(this.projectId) as { id: string; peer: string; originals: string }[];
366
+ for (const row of rows) {
367
+ const originals = retryOriginals(JSON.parse(row.originals) as Envelope[]);
368
+ if (!originals.length) continue;
369
+ const known = this.retryProof(row.peer, originals) || this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(JSON.stringify(originals.map(env => env.id)), this.projectId, row.peer);
370
+ this.recordRetryEvidence(row.peer, originals, row.id, this.retryWarning(row.peer, originals) || !known);
371
+ }
314
372
  }
315
373
  private readMeta(): Record<string, unknown> | null {
316
374
  return this.db
@@ -536,11 +594,20 @@ export class DeliveryJournal {
536
594
  updatedAt: delivery.createdAt,
537
595
  });
538
596
  return this.db.transaction(() => {
539
- const ids = new Set(delivery.originals.map((env) => env.id));
597
+ const evidence = this.retryEvidence(delivery.peer, delivery.originals);
598
+ if (evidence === "used") {
599
+ // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
600
+ this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
601
+ delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
602
+ } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
603
+ const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
540
604
  for (const row of this.list(delivery.peer)) {
541
605
  if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
542
606
  this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
543
- delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
607
+ if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
608
+ delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
609
+ if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
610
+ }
544
611
  }
545
612
  const revision = this.bump();
546
613
  const now = Date.now();
@@ -611,6 +678,32 @@ export class DeliveryJournal {
611
678
  return { ...input, revision, updatedAt };
612
679
  })();
613
680
  }
681
+ private validateOfferRead(row: Record<string, unknown>): void {
682
+ text(row.peer, "offer read peer", 256); text(row.envelope_id, "offer read envelope", 256); text(row.delivery_id, "offer read delivery", 256);
683
+ integer(row.offer_revision, "offer read revision", 1);
684
+ if (!["adapter", "reply", "pull"].includes(String(row.source))) fail("offer read source is invalid");
685
+ }
686
+ /** Positive delivery provenance only; generic transitions and operator resolutions never call this. */
687
+ completeDelivered(deliveryId: string, peer: string, source: "adapter" | "reply" | "pull", reason?: string): JournalDelivery {
688
+ this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
689
+ if (peer === HUB || peer === USER || !["adapter", "reply", "pull"].includes(source)) throw new Error("invalid delivered completion source");
690
+ if (reason !== undefined) text(reason, "delivery completion reason", 1_000_000);
691
+ return this.db.transaction((): JournalDelivery => {
692
+ const row = this.get(deliveryId);
693
+ if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
694
+ const completed = this.transition(deliveryId, "completed", reason, row.revision);
695
+ const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
696
+ for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
697
+ return completed;
698
+ })();
699
+ }
700
+ /** Exact immutable offer id, independent of queue snapshots or generic completed rows. */
701
+ offerRead(peer: string, envelopeId: string): boolean {
702
+ this.ensureOpen(); text(peer, "offer read peer", 256); text(envelopeId, "offer read envelope", 256);
703
+ const row = this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ? AND peer = ? AND envelope_id = ?").get(this.projectId, peer, envelopeId) as Record<string, unknown> | null;
704
+ if (!row) return false;
705
+ this.validateOfferRead(row); return true;
706
+ }
614
707
  transition(
615
708
  id: string,
616
709
  state: JournalDeliveryState,
@@ -663,12 +756,85 @@ export class DeliveryJournal {
663
756
  const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
664
757
  return rows.map(parseDelivery);
665
758
  }
759
+ heldDeliveryHints(peer: string) {
760
+ this.ensureOpen();
761
+ const count = Number((this.db.query("SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state = 'needs_review'").get(this.projectId, peer) as { n: number }).n);
762
+ const rows = this.db.query("SELECT d.id, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; created_at: number; senders: string; kinds: string }[];
763
+ return heldDeliveryHints(rows.map(row => ({ id: row.id, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
764
+ }
765
+ /** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
766
+ retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
767
+ this.ensureOpen();
768
+ originals = retryOriginals(originals);
769
+ if (!originals.length) return "unused";
770
+ const ids = JSON.stringify([...new Set(originals.map(env => env.id))]);
771
+ const proof = this.retryProof(peer, originals);
772
+ if (this.retryUnknown(peer, new Set(originals.map(env => env.id)))) return "unknown";
773
+ if (proof) return "used";
774
+ if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer)) return "used";
775
+ const wanted = new Set(originals.map(env => env.id));
776
+ type Ancestor = { id: string; peer: string; attempt: number | null; previous_id: string | null; originals: string };
777
+ const related = this.db.query("SELECT d.id, d.peer, d.attempt, d.previous_id, d.originals FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state IN ('queued','dispatching','accepted','needs_review') AND EXISTS (SELECT 1 FROM json_each(d.originals) env, json_each(?) ids WHERE json_extract(env.value, '$.id') = ids.value)").all(this.projectId, peer, ids) as Ancestor[];
778
+ const retried = (id: string) => !!this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND action = 'retry' LIMIT 1").get(this.projectId, id);
779
+ const inspected = new Set<string>();
780
+ let unknown = false;
781
+ for (const row of related) {
782
+ const chain = new Set<string>();
783
+ let current: Ancestor | undefined = row;
784
+ while (current) {
785
+ if (!retryOriginals(JSON.parse(current.originals) as Envelope[]).some(env => wanted.has(env.id))) break;
786
+ if (chain.has(current.id)) { unknown = true; break; }
787
+ if (inspected.has(current.id)) break;
788
+ chain.add(current.id); inspected.add(current.id);
789
+ if ((current.attempt ?? 0) > 0 || retried(current.id)) return "used";
790
+ if (!current.previous_id) break;
791
+ const parent = current.previous_id;
792
+ current = this.db.query("SELECT id, peer, attempt, previous_id, originals FROM deliveries WHERE project_id = ? AND id = ?").get(this.projectId, parent) as Ancestor | undefined;
793
+ if (!current || current.peer !== peer) { unknown = true; break; }
794
+ }
795
+ }
796
+ return unknown || this.retryUnknown(peer, wanted) ? "unknown" : "unused";
797
+ }
798
+ /** Console retries consume the allowance too, but only recipient-requested/unknown lineage adds a warning. */
799
+ retryWarning(peer: string, originals: readonly Envelope[]): boolean {
800
+ this.ensureOpen();
801
+ originals = retryOriginals(originals);
802
+ if (!originals.length) return false;
803
+ const wanted = new Set(originals.map(env => env.id));
804
+ const ids = JSON.stringify([...wanted]);
805
+ if (this.retryProof(peer, originals)?.warning) return true;
806
+ if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND r.actor = ? AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer, `peer:${peer}`)) return true;
807
+ return this.retryUnknown(peer, wanted) || this.retryEvidence(peer, originals) === "unknown";
808
+ }
809
+ private retryUnknown(peer: string, wanted: ReadonlySet<string>): boolean {
810
+ try {
811
+ const map = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, string[]>;
812
+ if (!map || typeof map !== "object" || Array.isArray(map)) return true;
813
+ const own = Object.hasOwn(map, peer) ? map[peer] : [];
814
+ return !Array.isArray(own) || own.some(id => wanted.has(id));
815
+ } catch { return true; }
816
+ }
817
+ private markRetryUnknown(deliveries: readonly JournalDelivery[], bus: Partial<BusSnapshot>): void {
818
+ const map: Record<string, Set<string>> = Object.create(null);
819
+ const existing = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, unknown>;
820
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) throw new Error("retry lineage metadata unavailable");
821
+ for (const [peer, ids] of Object.entries(existing)) {
822
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string")) throw new Error("retry lineage metadata unavailable");
823
+ map[peer] = new Set(ids as string[]);
824
+ }
825
+ const add = (peer: string, envs: readonly Envelope[]) => { for (const env of retryOriginals(envs)) (map[peer] ??= new Set()).add(env.id); };
826
+ for (const row of deliveries) add(row.peer, row.originals);
827
+ for (const [peer, envs] of Object.entries(bus.queues ?? {})) add(peer, envs);
828
+ for (const [peer, env] of Object.entries(bus.prefaces ?? {})) add(peer, [env]);
829
+ this.db.query("UPDATE delivery_meta SET retry_unknown_ids = ? WHERE project_id = ?").run(JSON.stringify(Object.fromEntries(Object.entries(map).map(([peer, ids]) => [peer, [...ids]]))), this.projectId);
830
+ }
666
831
  resolve(
667
832
  id: string,
668
833
  revision: number,
669
834
  action: JournalResolution,
670
835
  reason: string,
671
836
  requestRevision = revision,
837
+ actor: string = "user",
672
838
  ): JournalDelivery {
673
839
  this.ensureOpen();
674
840
  text(reason, "resolution reason", 1_000_000);
@@ -678,19 +844,20 @@ export class DeliveryJournal {
678
844
  const resolved = this.resolution(id);
679
845
  if (resolved) {
680
846
  const current = this.get(id);
681
- if (current && resolved.action === action && resolved.reason === reason && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
847
+ if (current && resolved.action === action && resolved.reason === reason && resolved.actor === actor && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
682
848
  throw new Error("stale delivery revision or conflicting operator resolution");
683
849
  }
684
850
  const prior = this.db
685
851
  .query(
686
- "SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ?",
852
+ "SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ? AND actor = ?",
687
853
  )
688
- .get(this.projectId, id, requestRevision, action, reason) as {
854
+ .get(this.projectId, id, requestRevision, action, reason, actor) as {
689
855
  result_revision: number;
690
856
  } | null;
691
857
  if (prior) return this.get(id)!;
692
858
  const current = this.get(id);
693
859
  if (!current) throw new Error(`unknown delivery ${id}`);
860
+ if (actor.startsWith("peer:") && (action !== "retry" || current.peer !== actor.slice(5) || current.state !== "needs_review" || this.retryEvidence(current.peer, current.originals) !== "unused")) throw new Error("recipient retry unavailable; inspect the held delivery or ask the person");
694
861
  if (current.revision !== revision)
695
862
  throw new Error(`stale delivery revision for ${id}`);
696
863
  if (
@@ -715,7 +882,7 @@ export class DeliveryJournal {
715
882
  }
716
883
  this.db
717
884
  .query(
718
- "INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state) VALUES (?, ?, ?, ?, ?, ?, ?)",
885
+ "INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state, actor, peer, original_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
719
886
  )
720
887
  .run(
721
888
  this.projectId,
@@ -725,14 +892,18 @@ export class DeliveryJournal {
725
892
  reason,
726
893
  result.revision,
727
894
  result.state,
895
+ actor,
896
+ current.peer,
897
+ JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
728
898
  );
899
+ if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
729
900
  this.pruneTerminal();
730
901
  return result;
731
902
  })();
732
903
  }
733
- resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number } | undefined {
734
- const row = this.db.query("SELECT action,reason,observed_revision FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number } | null;
735
- return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision } : undefined;
904
+ resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number; actor: string } | undefined {
905
+ const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
906
+ return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
736
907
  }
737
908
  private pruneTerminal(): void {
738
909
  const count = Number(
@@ -801,6 +972,8 @@ export class DeliveryJournal {
801
972
  row.attempt ?? null,
802
973
  row.previousId ?? null,
803
974
  );
975
+ // Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
976
+ this.markRetryUnknown(snapshot.deliveries, snapshot.bus);
804
977
  return true;
805
978
  })();
806
979
  }
@@ -0,0 +1,52 @@
1
+ import type { Envelope } from "./envelope.ts";
2
+ import type { Task } from "./board.ts";
3
+
4
+ /** Evidence only, never task text: a mixed/unknown/live row requires a person's decision. */
5
+ export function supersededDeliveryReason(
6
+ originals: readonly Envelope[],
7
+ board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
8
+ recipient: string,
9
+ ): string | undefined {
10
+ if (!originals.length) return undefined;
11
+ const reasons = new Set<string>();
12
+ for (const env of originals) {
13
+ if (env.from !== "hub") return undefined;
14
+ if (env.kind === "presence") { reasons.add("presence"); continue; }
15
+ if (env.kind !== "task" && env.kind !== "review") return undefined;
16
+ if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) return undefined;
17
+ const id = Number(env.refs.task);
18
+ if (!Number.isSafeInteger(id)) return undefined;
19
+ const task = board.get(id);
20
+ if (!task) return undefined;
21
+ if (task.state === "cancelled") {
22
+ if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
23
+ reasons.add(`task #${task.id} cancelled`);
24
+ } else if (task.state === "approved") reasons.add(`task #${task.id} approved`);
25
+ else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) reasons.add(`task #${task.id} review moved`);
26
+ else if (env.kind === "task" && env.refs.taskRecipient === "owner" && task.owner !== recipient) reasons.add(`task #${task.id} owner moved`);
27
+ else return undefined; // Untagged legacy task notices and planner coordination carry no proof of supersession.
28
+ }
29
+ return `superseded: ${[...reasons].join("; ")}`;
30
+ }
31
+
32
+
33
+ /** Informational context for every referenced task, including peer/user and mixed rows. Never authorizes settlement. */
34
+ export function deliveryTaskContext(
35
+ originals: readonly Envelope[],
36
+ board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
37
+ recipient: string,
38
+ ): string | undefined {
39
+ const context = new Set<string>();
40
+ for (const env of originals) {
41
+ if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) continue;
42
+ const id = Number(env.refs.task);
43
+ if (!Number.isSafeInteger(id)) continue;
44
+ const task = board.get(id);
45
+ if (!task) continue;
46
+ if (task.state === "cancelled") context.add(`task #${task.id} cancelled`);
47
+ else if (task.state === "approved") context.add(`task #${task.id} approved`);
48
+ else if (task.owner !== recipient && task.reviewer !== recipient) context.add(`task #${task.id} moved`);
49
+ else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) context.add(`task #${task.id} review moved`);
50
+ }
51
+ return context.size ? [...context].join("; ") : undefined;
52
+ }
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -14,9 +14,9 @@ export const EVENTS_SCHEMA = 1;
14
14
  */
15
15
  export type HubEvent =
16
16
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
17
- /** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
18
17
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
- | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
18
+ /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
19
+ | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
20
20
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
21
21
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
22
22
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -0,0 +1,11 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import { realPath } from "./project.ts";
3
+
4
+ /** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
5
+ export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
6
+ const found = Bun.which(name, { PATH: path });
7
+ if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
8
+ const target = realPath(found), inside = relative(realPath(projectDir), target);
9
+ if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
10
+ return target;
11
+ }
@@ -41,7 +41,14 @@ const tool = (name: string, description: string, properties: Record<string, unkn
41
41
  inputSchema: { type: "object", properties, required, additionalProperties: false },
42
42
  });
43
43
 
44
+ export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
45
+
44
46
  export const TASK_TOOLS: HubTool[] = [
47
+ tool("hub_task_spec", "Read complete assigned GitHub issue contracts through the bounded hub boundary. Owner/reviewer only; no repository or URL input. Refuses withheld or oversized specs.", { id }, ["id"]),
48
+ tool("hub_check_start", "Run an exact-head isolated Linux check for your in-progress task. Trusted opt-in runner only; no host shell or credentials. Tests require one to 32 test/*.test.ts paths. Receipt is Linux evidence.", { id, check: { type: "string", enum: ["test", "typecheck"] }, files: { type: "array", minItems: 1, maxItems: 32, items: str } }, ["id", "check"]),
49
+ tool("hub_check_result", "Read your current task-bound isolated check receipt. Stale ownership/generation refuses; output is policy screened.", { run: str }, ["run"]),
50
+ tool("hub_check_cancel", "Cancel your task-bound isolated check; only its owner or the person may cancel.", { run: str }, ["run"]),
51
+ tool("hub_delivery_retry", "Request one redelivery of your own needs_review delivery. An earlier attempt may have effects: inspect those effects before acting. Refuses another peer's row or any lineage already explicitly retried; a repeated uncertain row needs the person. Never completes/discards a delivery or changes task state.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 } }, ["delivery_id"]),
45
52
  tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
46
53
  tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
47
54
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
@@ -51,10 +58,11 @@ export const TASK_TOOLS: HubTool[] = [
51
58
 
52
59
  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, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, 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" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
53
60
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
61
+ tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
54
62
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
55
63
  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"]),
56
64
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
57
- 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" } }),
65
+ tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
58
66
  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, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, 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"]),
59
67
  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"]),
60
68
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
155
155
  return { ok: true };
156
156
  }
157
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
160
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
161
  let nominalMs = 10_000;
@@ -1,4 +1,8 @@
1
- /** Native peers the hub knows; membership checks share this list. */
1
+ import { definitionOf } from "../adapters/registry.ts";
2
+
3
+ // ponytail: older staged coordinators read these literal declarations to probe target support.
4
+ // A2 replaces that source probe with a published capability before deleting the compatibility exports.
5
+ /** Native peers the hub knows; membership checks share the registry. */
2
6
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
3
7
  /** Optional production peers are added explicitly; legacy defaults remain stable. */
4
8
  export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
@@ -6,23 +10,19 @@ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
6
10
  export type KnownPeer = typeof SUPPORTED_PEERS[number];
7
11
 
8
12
  export const isKnownPeer = (value: unknown): value is KnownPeer =>
9
- typeof value === "string" && (KNOWN_PEERS as readonly string[]).includes(value);
13
+ typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
10
14
 
11
15
  export const isSupportedPeer = (value: unknown): value is KnownPeer =>
12
- typeof value === "string" && (SUPPORTED_PEERS as readonly string[]).includes(value);
16
+ typeof value === "string" && !!definitionOf(value);
13
17
 
14
18
  // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
15
- const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
16
- claude: ["planner", "reviewer"],
17
- codex: ["implementer"],
18
- kimi: ["implementer", "verifier"],
19
- local: ["implementer", "verifier"],
20
- pi: ["implementer", "verifier"],
21
- qwen: ["implementer", "verifier"],
22
- jcode: ["implementer", "verifier"],
23
- };
19
+ const ROLE_ORDER: readonly KnownPeer[] = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"];
24
20
 
25
21
  /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
26
22
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
- return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
23
+ return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
24
+ .map(peer => [peer, [...definitionOf(peer)!.roles]]));
28
25
  }
26
+
27
+ /** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
28
+ export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
package/src/hub/peers.ts CHANGED
@@ -44,18 +44,28 @@ export abstract class BasePeer implements PeerAdapter {
44
44
  onDelivery?: (receipt: DeliveryReceipt) => void;
45
45
  private _state: PeerState = "offline";
46
46
  private timer: ReturnType<typeof setTimeout> | undefined;
47
+ private nextWatchdogMs: number | undefined;
47
48
 
48
49
  constructor(
49
50
  readonly id: PeerId,
50
- protected readonly watchdogMs = DEFAULT_WATCHDOG_MS,
51
+ protected watchdogMs = DEFAULT_WATCHDOG_MS,
51
52
  ) {}
52
53
 
53
54
  get state(): PeerState {
54
55
  return this._state;
55
56
  }
56
57
 
58
+ /** Keep the active turn's inactivity bound; a changed setting belongs to the next busy turn. */
59
+ configureWatchdog(ms: number): void {
60
+ if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) throw new Error("invalid watchdog interval");
61
+ this.nextWatchdogMs = ms;
62
+ }
63
+
57
64
  protected setState(next: PeerState): void {
58
- if (next === "busy") this.touch();
65
+ if (next === "busy") {
66
+ if (this._state !== "busy" && this.nextWatchdogMs !== undefined) this.watchdogMs = this.nextWatchdogMs;
67
+ this.touch();
68
+ }
59
69
  else this.clearWatchdog();
60
70
  if (next === this._state) return;
61
71
  this._state = next;
@@ -1,12 +1,13 @@
1
1
  import { isSupportedPeer } from "./peer-catalog.ts";
2
+ import { definitionOf } from "../adapters/registry.ts";
2
3
  import { lstatSync } from "node:fs";
3
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
5
 
5
6
  /** Operator-selected approval frequency; agent sandboxes remain separate. */
6
7
  export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
7
8
  export type PermissionMode = typeof PERMISSION_MODES[number];
8
- export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
9
- export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
9
+ export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
10
+ export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
10
11
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
11
12
  export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
12
13
  if (value === undefined) return {};
@@ -29,9 +30,10 @@ export function permissionBoundary(peer: string): string {
29
30
  }
30
31
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
31
32
  export function permissionGrant(peer: string, mode: PermissionMode): string {
33
+ const grant = definitionOf(peer)?.permission.grants?.[mode];
34
+ if (grant !== undefined) return grant;
32
35
  if (peer === "jcode") return "no verified hub mode mapping; native policy only";
33
36
  if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
34
- if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
35
37
  if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
36
38
  if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
37
39
  if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";