@staix/agent-hub 0.12.22 → 0.12.23

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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. package/src/ui/index.html +157 -10
@@ -1,8 +1,9 @@
1
+ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
5
- import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
+ import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
7
  import { hubHome } from "../hub/project.ts";
7
8
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
8
9
 
@@ -82,6 +83,7 @@ export interface RecoveryOperation {
82
83
  pluginInstalled?: boolean;
83
84
  globalInstalled?: boolean;
84
85
  error?: string;
86
+ failure?: { code: "database_busy"; retryable: true; site?: string[] };
85
87
  /** #215: every human disposition, in order. Operator reasons only; never task or message text. */
86
88
  audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
87
89
  disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
@@ -288,6 +290,7 @@ export function registeredProjects(home = hubHome()): Project[] {
288
290
  if (!existsSync(path)) return [];
289
291
  const db = new Database(path, { readonly: true });
290
292
  try {
293
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
291
294
  // #226: a registry no current release opened yet has no signature column, and this read never migrates.
292
295
  const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
293
296
  return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
@@ -433,10 +436,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
433
436
  if (op.disposition) {
434
437
  // The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
435
438
  op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
439
+ delete op.failure;
436
440
  op.phase = "blocked"; save();
437
441
  return op;
438
442
  }
439
- op.phase = "running"; delete op.error; save();
443
+ op.phase = "running"; delete op.error; delete op.failure; save();
440
444
  step("stage");
441
445
  const target = await driver.stage(op);
442
446
  if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
@@ -494,7 +498,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
494
498
  await driver.commit(project, id, planned.source.instanceId!);
495
499
  const deadline = driver.now() + 30_000;
496
500
  do {
497
- live = await driver.inspect(project);
501
+ try { live = await driver.inspect(project); }
502
+ catch (error) {
503
+ if (!isDatabaseBusy(error)) throw error;
504
+ // A committed source may still be stopping. Contention is an
505
+ // unconfirmed read here, not a refusal; keep the existing bound.
506
+ live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
507
+ }
498
508
  if (live.state === "stopped") break;
499
509
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
500
510
  await driver.sleep(100);
@@ -556,6 +566,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
556
566
  releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
557
567
  } catch (error) {
558
568
  // #215: every runner error ends with the choices status shows, read from every open source, never only this one.
569
+ if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
559
570
  if (error instanceof FinalRefusal) op.final = true;
560
571
  const next = nextActionsText(op, await liveProjects(op, driver.inspect));
561
572
  op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
@@ -575,7 +586,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
575
586
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
576
587
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
577
588
  ...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
578
- ...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
589
+ ...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
579
590
  }
580
591
 
581
592
  /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
@@ -605,8 +616,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
605
616
  const state = live[planned.project.id];
606
617
  if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
607
618
  }
619
+ delete op.error; delete op.failure;
608
620
  op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
609
621
  writeOperation(id, op, home); releaseRecoveryLock(id, home);
622
+ } catch (error) {
623
+ if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
624
+ throw error;
610
625
  } finally { releaseRunner(); }
611
626
  }
612
627
 
@@ -658,6 +673,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
658
673
  (progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
659
674
  }
660
675
  (op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
676
+ delete op.error; delete op.failure;
661
677
  op.updatedAt = at; writeOperation(id, op, home);
662
678
  return op;
663
679
  }
@@ -687,6 +703,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
687
703
  abandoned(op); // #227: in the same write as the disposition
688
704
  (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
689
705
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
706
+ delete op.failure;
690
707
  op.updatedAt = driver.now(); writeOperation(id, op, home);
691
708
  try {
692
709
  for (const { planned, live, act } of acts) {
@@ -699,12 +716,13 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
699
716
  op.updatedAt = driver.now(); writeOperation(id, op, home);
700
717
  }
701
718
  } catch (error) {
719
+ if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
702
720
  op.phase = "blocked"; op.updatedAt = driver.now();
703
721
  op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
704
722
  writeOperation(id, op, home);
705
723
  throw error;
706
724
  }
707
- delete op.error;
725
+ delete op.error; delete op.failure;
708
726
  op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
709
727
  writeOperation(id, op, home); releaseRecoveryLock(id, home);
710
728
  return op;
package/src/hub/board.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { PeerId } from "./envelope.ts";
3
3
  import type { SweepRecord } from "./task-sweep.ts";
4
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
4
5
 
5
6
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
6
7
  export type TaskClass = (typeof CLASSES)[number];
@@ -83,6 +84,7 @@ export class Board {
83
84
 
84
85
  constructor(path: string) {
85
86
  this.db = new Database(path, { create: true });
87
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
86
88
  this.db.run("PRAGMA journal_mode = WAL");
87
89
  this.db.run(`CREATE TABLE IF NOT EXISTS tasks (
88
90
  id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
package/src/hub/budget.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { PeerId } from "./envelope.ts";
3
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
3
4
 
4
5
  export interface UsageWindow {
5
6
  id: "5h" | "week" | "tokens";
@@ -80,10 +81,12 @@ const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
80
81
  export class Budget {
81
82
  private readonly db: Database;
82
83
  private readonly readings = new Map<PeerId, Map<string, Reading>>();
83
- private readonly pausing = new Set<PeerId>();
84
+ private readonly pausing = new Map<PeerId, object>();
84
85
  private readonly handingOff = new Set<PeerId>();
85
86
  /** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
86
87
  private readonly ignoreUntil = new Map<PeerId, number>();
88
+ /** Chosen fallback bounds belong to a window, never to every window of the peer. */
89
+ private readonly resetBounds = new Map<PeerId, Map<string, { at: number; explicit: boolean }>>();
87
90
  private closed = false;
88
91
  private recoveryHeld = false;
89
92
  private deferred = false;
@@ -96,6 +99,7 @@ export class Budget {
96
99
  private readonly now: () => number = Date.now,
97
100
  ) {
98
101
  this.db = new Database(dbPath, { create: true });
102
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
99
103
  this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
100
104
  reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
101
105
  }
@@ -153,6 +157,10 @@ export class Budget {
153
157
  /** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
154
158
  report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
155
159
  if (EXEMPT.has(peer) || this.closed) return;
160
+ // An override ignores soft readings, not a provider that has actually refused another turn.
161
+ if (hard && this.ignoreUntil.delete(peer)) {
162
+ try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
163
+ }
156
164
  try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
157
165
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
158
166
  this.readings.set(peer, mine);
@@ -172,7 +180,10 @@ export class Budget {
172
180
  const open = this.record(peer);
173
181
  if (!open) return false;
174
182
  this.ignoreUntil.set(peer, open.resetsAt);
183
+ const bounds = this.resetBounds.get(peer);
175
184
  this.resume(peer, "overridden by the console user");
185
+ // A new refusal may end the override before this same window has reset.
186
+ if (bounds) this.resetBounds.set(peer, bounds);
176
187
  return true;
177
188
  }
178
189
 
@@ -181,8 +192,14 @@ export class Budget {
181
192
  const fresh = this.fresh(peer);
182
193
  const over = fresh.filter((r) => r.used >= this.cfg.gate);
183
194
  const open = this.record(peer);
184
- if (open) {
195
+ // An expired restored record may be waiting for attachment on an unpaused bus. A new hard refusal
196
+ // starts a fresh pause immediately, retaining its list of moved tasks for the eventual resume notice.
197
+ if (open && !(hard && this.now() >= open.resetsAt)) {
198
+ if (hard && over.length) this.hooks.pause(peer); // confirm the persisted pause on the live bus, too
185
199
  // A later reading may finally carry the reset time the first one lacked.
200
+ const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
201
+ this.resetBounds.set(peer, bounds);
202
+ for (const reading of over) if (reading.resetsAt !== undefined) bounds.set(reading.id, { at: reading.resetsAt, explicit: true });
186
203
  const known = Math.max(0, ...over.map((r) => r.resetsAt ?? 0));
187
204
  if (known && known !== open.resetsAt) {
188
205
  // A wait was decided against the old reset (#36). If the new one is past wait_max_min (a week window crossed
@@ -199,22 +216,36 @@ export class Budget {
199
216
  if (newer.length && fresh.every((r) => r.used < this.cfg.gate - HYSTERESIS)) this.resume(peer, "usage is back under the gate");
200
217
  return;
201
218
  }
202
- if (!over.length || this.pausing.has(peer) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
203
- this.pausing.add(peer);
219
+ if (!over.length || (this.pausing.has(peer) && !hard) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
220
+ // A hard refusal supersedes a soft evaluation still awaiting its checkpoint.
221
+ const evaluation = {};
222
+ this.pausing.set(peer, evaluation);
204
223
  try {
205
224
  const worst = over.reduce((a, b) => (b.used > a.used ? b : a));
206
- const resetsAt = Math.max(...over.map((r) => r.resetsAt ?? this.now() + (r.windowMins ?? FALLBACK_WINDOW_MINS) * 60_000));
225
+ const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
226
+ this.resetBounds.set(peer, bounds);
227
+ const resetsAt = Math.max(...over.map((r) => {
228
+ const fallback = this.now() + (r.windowMins ?? FALLBACK_WINDOW_MINS) * 60_000;
229
+ const prior = bounds.get(r.id);
230
+ // Preserve an earlier future fallback for this window after an override.
231
+ // A provider's explicit reset, including a later one, remains authoritative.
232
+ const retained = prior !== undefined && prior.at > this.now() ? prior : undefined;
233
+ const reset = r.resetsAt ?? (retained ? retained.explicit ? retained.at : Math.min(retained.at, fallback) : fallback);
234
+ bounds.set(r.id, { at: reset, explicit: r.resetsAt !== undefined || retained?.explicit === true });
235
+ return reset;
236
+ }));
207
237
  const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
208
238
  // Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
209
239
  const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
240
+ if (this.pausing.get(peer) !== evaluation) return; // a later hard evaluation owns this pause
210
241
  if (this.recoveryHeld) { this.deferred = true; return; }
211
242
  if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
212
- this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
243
+ this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
213
244
  this.hooks.pause(peer);
214
245
  this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
215
246
  await this.finishHandoff(peer, summary);
216
247
  } finally {
217
- this.pausing.delete(peer);
248
+ if (this.pausing.get(peer) === evaluation) this.pausing.delete(peer);
218
249
  }
219
250
  }
220
251
 
@@ -285,6 +316,7 @@ export class Budget {
285
316
  if (!record) return;
286
317
  this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
287
318
  this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
319
+ this.resetBounds.delete(peer);
288
320
  // The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
289
321
  // before it touches anything that was waiting for it.
290
322
  this.hooks.resumed(record);
package/src/hub/bus.ts CHANGED
@@ -1,12 +1,8 @@
1
1
  import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
- import type { PeerAdapter } from "./peers.ts";
2
+ import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
 
5
- export interface DeliveryReceipt {
6
- id: string;
7
- state: "accepted" | "completed" | "failed_safe" | "needs_review";
8
- reason?: string;
9
- }
5
+ export type { DeliveryReceipt } from "./peers.ts";
10
6
 
11
7
  export type BusEvent =
12
8
  | { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
@@ -195,7 +191,7 @@ export class Bus {
195
191
  if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
196
192
  return heldBack ? hushed[0]!.reason : undefined;
197
193
  };
198
- peer.onFailed = (envs) => {
194
+ peer.onFailed = (envs, failure) => {
199
195
  // The adapter got the condensed list; what has to come back is what that list replaced.
200
196
  const last = this.lastDelivery.get(peer.id);
201
197
  const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
@@ -207,7 +203,7 @@ export class Bus {
207
203
  this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
208
204
  }
209
205
  }
210
- } else this.failed(peer.id, same ? last.originals : envs);
206
+ } else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
211
207
  };
212
208
  peer.onState = () => {
213
209
  if (this.closed) return;
@@ -423,7 +419,7 @@ export class Bus {
423
419
  this.journal.transition(id, state, receipt.reason);
424
420
  if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
425
421
  if (state === "failed") {
426
- if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
422
+ if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
427
423
  }
428
424
  if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
429
425
  if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
@@ -906,10 +902,12 @@ export class Bus {
906
902
  return [...this.activeDeliveries.values()].includes(peer) || this.draining.has(peer);
907
903
  }
908
904
 
909
- private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
905
+ private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
910
906
  if (!this.journal) this.deliveryFailed(id);
907
+ // A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
908
+ const quotaPaused = quota && this.paused.has(id);
911
909
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
912
- if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
910
+ if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
913
911
  this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
914
912
  }
915
913
  const keep = envs.filter((env) => {
@@ -918,6 +916,7 @@ export class Bus {
918
916
  this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
919
917
  return false;
920
918
  }
919
+ if (quotaPaused) return true;
921
920
  const key = `${id}:${env.id}`;
922
921
  const n = (this.attempts.get(key) ?? 0) + 1;
923
922
  if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
11
11
  return env;
12
12
  }
13
13
 
14
+ /**
15
+ * #269: the environment of a terminal the hub opens for a person: no agent marker and no hub-run flag, so `ahub`
16
+ * typed or started there has the console's identity, whatever started the hub.
17
+ */
18
+ export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
19
+ const env = childEnv(source);
20
+ // The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own.
21
+ for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]) delete env[name];
22
+ return env;
23
+ }
24
+
14
25
  /** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
15
26
  export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
16
27
  const env = childEnv(source);
@@ -4,9 +4,11 @@ import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
4
  import { HUB, USER } from "./envelope.ts";
5
5
  import { OWNERSHIP_EVENTS } from "./tasks.ts";
6
6
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
+ import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
7
8
  import type { HubEvent } from "./events.ts";
8
9
  import type { SupervisionFeed } from "./supervision.ts";
9
10
  import type { Budget } from "./budget.ts";
11
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
10
12
 
11
13
  /** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
12
14
  export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
@@ -65,6 +67,7 @@ export class ConductorHolds {
65
67
  private readonly db: Database;
66
68
  constructor(dbPath: string, private readonly now: () => number = Date.now) {
67
69
  this.db = new Database(dbPath, { create: true });
70
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
68
71
  this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
69
72
  this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
70
73
  }
@@ -100,13 +103,18 @@ export class ConductorHolds {
100
103
  close(): void { this.db.close(); }
101
104
  }
102
105
 
103
- export type ConductorStart = { peer: "local" | "kimi" | "pi"; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
104
- /** Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text. */
105
- export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect = false): ConductorStart {
106
- if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new (preEffect ? PreEffectToolRefusal : Error)("mode must be headless or tui");
107
- if (peer === "claude" || peer === "codex" || (peer === "pi" && mode === "tui")) return { peer, mode: "tui", command: preview(peer) };
108
- if ((peer === "local" || peer === "kimi" || peer === "pi") && mode !== "tui") return { peer, mode: "headless" };
109
- throw new (preEffect ? PreEffectToolRefusal : Error)("only local, kimi and headless pi may be started by the conductor");
106
+ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
107
+ /**
108
+ * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
109
+ * only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
110
+ */
111
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
112
+ const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
113
+ if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
114
+ if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local may be started by the conductor");
115
+ const own = startMode(peer);
116
+ if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
117
+ return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
110
118
  }
111
119
 
112
120
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -151,7 +159,10 @@ export interface ConductorHooks {
151
159
  assign(actor: string, id: number, peer: string): Promise<unknown>;
152
160
  escalate(actor: string, id: number): Promise<unknown>;
153
161
  preview(peer: "claude" | "codex" | "pi"): string;
154
- start(peer: "local" | "kimi" | "pi"): Promise<unknown>;
162
+ /** The mode a start of this peer takes: a person's setting. */
163
+ startMode(peer: StartablePeer): StartMode;
164
+ /** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
165
+ start(peer: StartablePeer): Promise<unknown>;
155
166
  known(peer: string): boolean;
156
167
  pause(peer: string): void;
157
168
  validateRelease?(peer: string): Promise<void>;
@@ -211,9 +222,10 @@ export class Conductor {
211
222
  return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
212
223
  }
213
224
  if (tool === "hub_peer_start") {
214
- const start = conductorStart(args.peer, args.mode, this.hooks.preview, true);
215
- if (start.mode === "headless") await this.hooks.start(start.peer);
216
- emit({ peer: start.peer }); return start;
225
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode);
226
+ const result = await this.hooks.start(start.peer);
227
+ const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
228
+ emit({ peer: start.peer }); return { ...start, ...opened };
217
229
  }
218
230
  const peer = peerId(args.peer, true);
219
231
  if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
@@ -1,7 +1,7 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { statSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { childEnv } from "./child-process.ts";
4
+ import { hubGitSync } from "./git.ts";
5
5
 
6
6
  /**
7
7
  * Fields of the project config that choose what the hub runs, which files it sends as credentials, where task text
@@ -26,6 +26,7 @@ export const MACHINE_LOCAL = [
26
26
  "local.read_allow",
27
27
  "local.bash_network",
28
28
  "local.network_allow", // the hosts the egress proxy opens (#65)
29
+ "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
29
30
  ] as const;
30
31
 
31
32
  /**
@@ -35,17 +36,28 @@ export const MACHINE_LOCAL = [
35
36
  * spellings git keeps apart (letter case, and folds such as "ſ" for "s").
36
37
  */
37
38
  export function configRefusal(cwd: string, name: string): string | undefined {
38
- const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
39
- if (r.status !== 0) return `git could not confirm that .agenthub/${name} is untracked`;
39
+ const tracked = configTracked(cwd, name);
40
+ return tracked === undefined ? `git could not confirm that .agenthub/${name} is untracked` : tracked ? `.agenthub/${name} is committed to git` : undefined;
41
+ }
42
+
43
+ /** Whether git tracks `.agenthub/<name>` (or `.agenthub` itself); undefined when git cannot say. */
44
+ export function configTracked(cwd: string, name: string): boolean | undefined {
45
+ const r = hubGitSync(["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
46
+ if (r.status !== 0) return undefined;
40
47
  let opened: { dev: number; ino: number } | undefined;
41
48
  try {
42
49
  opened = statSync(join(cwd, ".agenthub", name));
43
50
  } catch {
44
51
  // not there: only the directory entry can still matter
45
52
  }
46
- const tracked = r.stdout.split("\0").filter(Boolean).some((row) => {
53
+ return r.stdout.split("\0").filter(Boolean).some((row) => {
47
54
  const path = row.slice(row.indexOf("\t") + 1);
48
55
  if (path.toLowerCase() === ".agenthub") return true;
56
+ // A file that is not there has no identity to match: the index entry of this exact name is the one a write would
57
+ // change. Another spelling is another file where the disk keeps them apart.
58
+ // ponytail: on a disk that folds names, an entry tracked under another spelling and deleted from the work tree is
59
+ // not seen here; probe the disk's folding and compare folded names if that case ever matters.
60
+ if (!opened) return path === `.agenthub/${name}`;
49
61
  try {
50
62
  const s = statSync(join(cwd, path));
51
63
  return !!opened && s.dev === opened.dev && s.ino === opened.ino;
@@ -53,7 +65,6 @@ export function configRefusal(cwd: string, name: string): string | undefined {
53
65
  return false;
54
66
  }
55
67
  });
56
- return tracked ? `.agenthub/${name} is committed to git` : undefined;
57
68
  }
58
69
 
59
70
  const valueAt = (o: unknown, path: string): unknown => path.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { existsSync } from "node:fs";
3
3
  import type { Task } from "./board.ts";
4
4
  import { samePlace } from "./tasks.ts";
5
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
5
6
 
6
7
  /** A file an owner's open task changed, as the turn snapshots saw it (issue #32). */
7
8
  export interface Touch {
@@ -39,6 +40,7 @@ export function pathWarnings(dbFile: string, peer: string, file: { project: stri
39
40
  if (!existsSync(dbFile)) return [];
40
41
  const db = new Database(dbFile, { readonly: true });
41
42
  try {
43
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
42
44
  const rows = db.query("SELECT * FROM tasks WHERE owner IS NOT NULL AND owner != ? AND state IN ('proposed', 'in_progress', 'in_review', 'changes_requested') ORDER BY id").all(peer) as Record<string, string | number | null>[];
43
45
  let touches: Touch[] = [];
44
46
  try {
@@ -1,6 +1,6 @@
1
1
  /** Native active-context occupancy, separate from accumulated billable usage (#185). */
2
2
  export interface ContextReading {
3
- source: "claude_statusline" | "codex_token_usage";
3
+ source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
4
4
  sessionId: string;
5
5
  measuredAt: number;
6
6
  tokens: number | null;
@@ -37,6 +37,13 @@ export function codexContext(value: unknown, sessionId: string, measuredAt: numb
37
37
  const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
38
38
  return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
39
39
  }
40
+ /** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
41
+ export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
42
+ const window = positive(usage.size) ? usage.size : null;
43
+ const tokens = nonnegative(usage.used) ? usage.used : null;
44
+ // If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
45
+ return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
46
+ }
40
47
  export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
41
48
  /** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
42
49
  export class ContextWindows {
package/src/hub/crash.ts CHANGED
@@ -72,7 +72,8 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
72
72
  const fresh: Record<string, string> = {};
73
73
  for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
74
74
  const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
75
- if (!sessionFile) return { peer, fresh, how: "pi: no session file was recorded; start it again with ahub pi" };
75
+ // #269: a terminal Pi stays marked as one without a session file too, so nothing headless is started in its place.
76
+ if (!sessionFile) return { peer, fresh, ...(str(launch.mode) === "tui" ? { tui: true } : {}), how: "pi: no session file was recorded; start it again with ahub pi" };
76
77
  // A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
77
78
  if (str(launch.mode) === "tui") return { peer, fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
78
79
  const args: Record<string, string> = { sessionFile };