@staix/agent-hub 0.12.27 → 0.12.29

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.
@@ -99,7 +99,13 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
99
99
  const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
100
100
  const backend = value("--backend") ?? config.pi.backend;
101
101
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
102
- for (let i = 0; i < args.length; i += 2) if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!)) throw new Error("unknown Pi option");
102
+ if (args.includes("--replace-session") && (!value("--session-file") || value("--session-id"))) throw new Error("--replace-session requires --session-file, without --session-id");
103
+ for (let i = 0; i < args.length; i++) {
104
+ if (args[i] === "--replace-session") continue;
105
+ if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!) || !value(args[i]!)) throw new Error("unknown Pi option");
106
+ i++;
107
+ }
108
+ if (args.includes("--replace-session")) unresolved.push({ field: "session replacement", reason: "requires a person, a validated managed file and an offline settled owner at runtime; preview changes nothing" });
103
109
  const models = relayModelIds({ enableHubAuto: true, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast } });
104
110
  const pi = buildPiLaunch({
105
111
  stateDir, cmd: nativeCommand(config.pi.cmd, ["pi"]), mode: mode as "headless" | "tui", backend: backend as "auto" | "dgx" | "mlx",
@@ -323,7 +323,7 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
323
323
  return dest;
324
324
  }
325
325
 
326
- export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver {
326
+ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string): RecoveryDriver {
327
327
  const now = () => Date.now();
328
328
  const sleep = (ms: number) => Bun.sleep(ms);
329
329
  const env = (op: RecoveryOperation, project?: Project): NodeJS.ProcessEnv => {
@@ -333,6 +333,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
333
333
  (project && /(?:_API_KEY|_ACCESS_TOKEN|_SECRET|_TOKEN)$/.test(key))) delete value[key];
334
334
  value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
335
335
  value.AGENTHUB_UNATTENDED = "0";
336
+ if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
336
337
  if (project) {
337
338
  value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
338
339
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
@@ -345,7 +346,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
345
346
  return value;
346
347
  };
347
348
  const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
348
- const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...args],
349
+ const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
349
350
  { env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
350
351
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
351
352
  return result;
@@ -448,6 +449,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
448
449
  if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
449
450
  const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
450
451
  : await stageRelease(op.plan.version, op.plan.integrity!, run);
452
+ const identityPath = join(target.root, "src/cli/identity.ts");
453
+ if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
454
+ throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
455
+ }
451
456
  // #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
452
457
  // digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
453
458
  // changed bytes are the runner's final "staged target changed".
package/src/hub/bus.ts CHANGED
@@ -46,6 +46,11 @@ export interface BusOptions {
46
46
  * the console and the log see it as published.
47
47
  */
48
48
  silence?: (env: Envelope, peer: PeerId) => string | undefined;
49
+ /**
50
+ * Optional: an `important` envelope was just queued for a pull-only peer (issue #398). Advisory only: nothing was
51
+ * dequeued, steered or journaled for it, and the queue still waits for `Bus.pull`; a throw is swallowed.
52
+ */
53
+ onImportantQueued?: (peer: PeerId, counts: { queued: number; important: number }) => void;
49
54
  }
50
55
 
51
56
  /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
@@ -732,6 +737,10 @@ export class Bus {
732
737
  if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
733
738
  this.emit({ t: "overflow", env: lost!, peer: id });
734
739
  }
740
+ if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
741
+ // Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
742
+ try { this.opts.onImportantQueued?.(id, { queued: queue.length, important: queue.filter((e) => e.priority === "important").length }); } catch { /* a nudge never stops an enqueue */ }
743
+ }
735
744
  this.onQueues?.();
736
745
  if (!this.suppressDrain) this.persist();
737
746
  if (!this.suppressDrain) void this.drain(id);
package/src/hub/daemon.ts CHANGED
@@ -38,6 +38,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
38
38
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
39
39
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
40
40
  import { processSignature } from "../pi/process-signature.ts";
41
+ import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
41
42
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
42
43
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
43
44
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
@@ -489,6 +490,11 @@ class WsPeer extends BasePeer {
489
490
  if (deliveryId) this.delivered.add(deliveryId);
490
491
  this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
491
492
  }
493
+ /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
494
+ notifyQueued(counts: { queued: number; important: number }): void {
495
+ if (!this.sock || !this.pullOnly) return;
496
+ this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
497
+ }
492
498
  owns(sock: Sock): boolean { return this.sock === sock; }
493
499
  nativeStarted(session: string): void {
494
500
  if (!this.sock) return;
@@ -769,7 +775,8 @@ export async function startDaemon(opts: DaemonOptions) {
769
775
  const staleOff = config.experiments?.stale_notices === "deliver";
770
776
  if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
771
777
  const requestedPeerStops = new Set<string>();
772
- const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence });
778
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
779
+ onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
773
780
  startupCleanup.push(() => bus.closeJournal());
774
781
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
775
782
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -1452,9 +1459,12 @@ export async function startDaemon(opts: DaemonOptions) {
1452
1459
  supervision.checkRound();
1453
1460
  try {
1454
1461
  for (const audit of drainCliAudits(opts.stateDir)) {
1455
- const refused = audit.outcome !== "run";
1456
- event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused });
1457
- notify(refused ? `${audit.peer} refused ahub ${audit.command}` : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1462
+ const delegated = audit.outcome === "delegated";
1463
+ const refused = audit.outcome !== "run" && !delegated;
1464
+ event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) });
1465
+ notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
1466
+ ? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
1467
+ : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1458
1468
  }
1459
1469
  } catch { log("CLI audit outbox unavailable; inspect state before retrying an agent command"); }
1460
1470
  for (const peer of bus.knownPeers()) {
@@ -2406,6 +2416,7 @@ export async function startDaemon(opts: DaemonOptions) {
2406
2416
  let piAutoRestartAt = -Infinity;
2407
2417
  let piAutoRestartPending = false;
2408
2418
  const replacingPi = new Set<PiPeer>();
2419
+ const piReplacementAuthority = Symbol("console Pi replacement");
2409
2420
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
2410
2421
  const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
2411
2422
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
@@ -2414,7 +2425,8 @@ export async function startDaemon(opts: DaemonOptions) {
2414
2425
  replacingPi.add(pi);
2415
2426
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
2416
2427
  }
2417
- function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2428
+ function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, replacementAuthority?: symbol): Promise<Record<string, unknown>> {
2429
+ if (args.replaceSession !== undefined && (args.replaceSession !== true || replacementAuthority !== piReplacementAuthority || peer !== "pi")) return Promise.resolve({ ok: false, error: "Pi session replacement requires explicit person authorization" });
2418
2430
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
2419
2431
  if (!membershipAllowed(peer)) return Promise.resolve({ ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` });
2420
2432
  if (rosterChanging === peer) return Promise.resolve({ ok: false, error: `${peer} roster change is in progress` });
@@ -2544,7 +2556,7 @@ export async function startDaemon(opts: DaemonOptions) {
2544
2556
  };
2545
2557
  }
2546
2558
 
2547
- async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2559
+ async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2548
2560
  let unmute: (() => void) | undefined;
2549
2561
  try {
2550
2562
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
@@ -2553,7 +2565,7 @@ export async function startDaemon(opts: DaemonOptions) {
2553
2565
  }
2554
2566
  }
2555
2567
 
2556
- async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
2568
+ async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
2557
2569
  if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
2558
2570
  if (peer === "qwen" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" };
2559
2571
  if (peer === "kimi") {
@@ -2581,7 +2593,18 @@ export async function startDaemon(opts: DaemonOptions) {
2581
2593
  if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
2582
2594
  }
2583
2595
  const existing = bus.peers.get(peer);
2584
- if (peer === "pi" && existing instanceof PiPeer) {
2596
+ if (args.replaceSession) {
2597
+ if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
2598
+ if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
2599
+ if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceipts?.unsettled || [...permissions.values()].some(p => p.peer === "pi")) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
2600
+ const selected = validatePiSession(opts.stateDir, opts.cwd, args.sessionFile);
2601
+ const previous = existing.recoveryMetadata();
2602
+ recordPiReplacement(opts.stateDir, instanceId, previous, selected);
2603
+ args = { ...args, ...selected };
2604
+ mute(existing);
2605
+ await stopPiForReplacement(existing);
2606
+ }
2607
+ if (peer === "pi" && existing instanceof PiPeer && !args.replaceSession) {
2585
2608
  let saved = existing.recoveryMetadata();
2586
2609
  const launch = saved.launch as Record<string, unknown>;
2587
2610
  if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
@@ -3876,6 +3899,9 @@ export async function startDaemon(opts: DaemonOptions) {
3876
3899
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
3877
3900
  void ensurePreface(c.peer).finally(() => {
3878
3901
  if (sock.readyState === WebSocket.OPEN && membershipAllowed(c.peer!)) ws.attach(sock);
3902
+ // Important work queued while no session held the id gets its nudge now (#398); a push session ignores it.
3903
+ const important = ws.owns(sock) ? bus.queuedImportant(c.peer!) : 0;
3904
+ if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
3879
3905
  });
3880
3906
  }
3881
3907
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
@@ -3935,7 +3961,7 @@ export async function startDaemon(opts: DaemonOptions) {
3935
3961
  peer.lastInboxAt = Date.now();
3936
3962
  writeStatus();
3937
3963
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
3938
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id) });
3964
+ return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
3939
3965
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
3940
3966
  }
3941
3967
  case "delivery_complete": {
@@ -4181,7 +4207,7 @@ export async function startDaemon(opts: DaemonOptions) {
4181
4207
  writeStatus();
4182
4208
  // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
4183
4209
  if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
4184
- return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true), ...(msg.settings === true ? { settings: true } : {}) });
4210
+ return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true, msg.liveTest === true), ...(msg.settings === true ? { settings: true } : {}), ...(msg.liveTest === true ? { liveTest: true } : {}) });
4185
4211
  } catch {
4186
4212
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
4187
4213
  }
@@ -4255,7 +4281,7 @@ export async function startDaemon(opts: DaemonOptions) {
4255
4281
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
4256
4282
  if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
4257
4283
  if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
4258
- startPeer(String(msg.peer), msg.args ?? {})
4284
+ startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
4259
4285
  .catch((e: Error) => ({ ok: false, error: e.message }))
4260
4286
  .then((r) => {
4261
4287
  if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
package/src/hub/events.ts CHANGED
@@ -43,7 +43,7 @@ export type HubEvent =
43
43
  | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
44
44
  | { type: "native_turn_end"; peer: string; id?: string }
45
45
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
46
- | { type: "agent_cli"; peer: string; command: string; refused: boolean }
46
+ | { type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true }
47
47
  | { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
48
48
  /** Completed native turns that received supervision, not feed enqueues or inferred costs. */
49
49
  | { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
@@ -138,7 +138,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
138
138
  }
139
139
  }
140
140
 
141
- export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
141
+ export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean } = {}): Promise<any> {
142
142
  assertLifecycleAvailable();
143
143
  const registry = new Registry();
144
144
  try {
@@ -160,7 +160,7 @@ export async function startProject(project: Project, options: { unattended?: boo
160
160
  // Native sessions can outlive the operation which launched them. An expired
161
161
  // operation ID must not make a later ordinary startup load a retired snapshot.
162
162
  if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
163
- const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
163
+ const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
164
164
  ...(options.unattended ? ["--unattended"] : [])], {
165
165
  cwd: project.root, detached: true, stdio: ["ignore", log, log],
166
166
  env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
@@ -21,12 +21,12 @@ export type Lifecycle = {
21
21
  startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
22
22
  stopProject(project: Project, expectedInstance?: string): Promise<void>;
23
23
  };
24
- export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
24
+ export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
25
25
  /** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
26
26
  forwardTimeoutMs?: (action: string, nominalMs: number) => number };
27
27
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
28
28
  type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
29
- const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
29
+ const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
30
30
  const CONTROL_TIMEOUT = 3000;
31
31
  // A current CLI may authenticate the previous protocol-9 manager while
32
32
  // refreshing it, then launch the current manager implementation.
@@ -178,7 +178,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
178
178
  };
179
179
  dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
180
180
  server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
181
- fetch(req) {
181
+ async fetch(req) {
182
182
  if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
183
183
  if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
184
184
  if (req.method !== "POST") return new Response("POST required", { status: 405 });
@@ -186,7 +186,10 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
186
186
  if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
187
187
  if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
188
188
  const path = new URL(req.url).pathname;
189
- if (path === "/open") return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue() });
189
+ if (path === "/open") {
190
+ const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
191
+ return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
192
+ }
190
193
  if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
191
194
  return new Response("not found", { status: 404 });
192
195
  },
@@ -204,31 +207,32 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
204
207
  }, options.orphanWatchMs ?? 10_000);
205
208
  orphanWatch.unref?.();
206
209
  const handle = { stop, stopped };
207
- active.set(home, { handle, issue: () => dashboard!.issue() });
210
+ active.set(home, { handle, issue: (liveTest) => dashboard!.issue(false, liveTest) });
208
211
  return handle;
209
212
  } catch (error) { cleanup(); throw error; }
210
213
  }
211
214
 
212
- async function managerRequest(home: string, path: "/open" | "/stop"): Promise<{ status: Manifest; body: any }> {
215
+ async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
213
216
  const status = readManifest(home);
214
217
  if (!status) throw new Error("manager manifest is unavailable");
215
218
  if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
216
219
  const token = readFileSync(files(home).token, "utf8").trim();
217
220
  if (!token) throw new Error("manager token is unavailable");
218
221
  const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
219
- method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, signal: AbortSignal.timeout(CONTROL_TIMEOUT),
222
+ method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, ...(liveTest ? { body: JSON.stringify({ liveTest: true }) } : {}), signal: AbortSignal.timeout(CONTROL_TIMEOUT),
220
223
  });
221
224
  if (!response.ok) throw new Error(`manager refused request (${response.status}); ownership was not verified`);
222
225
  const body = await response.json() as { ok?: boolean; instanceId?: string; url?: string };
223
226
  if (!body.ok || body.instanceId !== status.instanceId) throw new Error("manager identity changed; refresh before retrying");
227
+ if (liveTest && (body as { liveTest?: boolean }).liveTest !== true) throw new Error("manager cannot restrict delegated dashboard content; use a live-test-capable manager");
224
228
  return { status, body };
225
229
  }
226
230
 
227
231
  export async function openManager(options: ManagerOptions = {}): Promise<string> {
228
232
  const home = options.home ?? hubHome();
229
233
  const local = active.get(home);
230
- if (local) return local.issue();
231
- try { return (await managerRequest(home, "/open")).body.url; }
234
+ if (local) return local.issue(options.liveTest);
235
+ try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
232
236
  catch (error) {
233
237
  const old = readManifest(home);
234
238
  if (old && processLiveness(old.pid, old.pidSignature) !== "gone") throw error;
@@ -246,7 +250,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
246
250
  const deadline = Date.now() + 8000;
247
251
  let error: unknown;
248
252
  while (Date.now() < deadline) {
249
- try { return (await managerRequest(home, "/open")).body.url; }
253
+ try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
250
254
  catch (caught) { error = caught; await Bun.sleep(100); }
251
255
  }
252
256
  throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
package/src/hub/ui.ts CHANGED
@@ -10,7 +10,7 @@ import { createHash, randomBytes } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
11
 
12
12
  /** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
13
- export interface DashboardSession { settings: boolean; settingsUntil?: number }
13
+ export interface DashboardSession { settings: boolean; settingsUntil?: number; liveTest?: true }
14
14
  export interface DashboardOptions {
15
15
  snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
16
16
  action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
@@ -30,8 +30,8 @@ const secret = () => randomBytes(32).toString("hex");
30
30
  /** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
31
31
  export function startDashboard(options: DashboardOptions) {
32
32
  const now = options.now ?? Date.now;
33
- const tickets = new Map<string, { expires: number; settings: boolean }>();
34
- const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
33
+ const tickets = new Map<string, { expires: number; settings: boolean; liveTest?: true }>();
34
+ const sessions = new Map<string, { expires: number; settingsUntil?: number; liveTest?: true }>();
35
35
  // Inject the same pure public-board model used by the console before computing CSP hashes.
36
36
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
37
37
  .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
@@ -57,7 +57,7 @@ export function startDashboard(options: DashboardOptions) {
57
57
  }
58
58
  const sessionOf = (cookie: string | undefined): DashboardSession => {
59
59
  const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
60
- return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
60
+ return { ...(until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false }), ...(cookie && sessions.get(cookie)?.liveTest ? { liveTest: true as const } : {}) };
61
61
  };
62
62
  const server = Bun.serve({
63
63
  hostname: "127.0.0.1",
@@ -99,7 +99,7 @@ export function startDashboard(options: DashboardOptions) {
99
99
  if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
100
100
  if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
101
101
  const session = secret();
102
- sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
102
+ sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}), ...(issued.liveTest ? { liveTest: true } : {}) });
103
103
  return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
104
104
  }
105
105
  if (path === "/projects") {
@@ -131,6 +131,9 @@ export function startDashboard(options: DashboardOptions) {
131
131
  }
132
132
  }
133
133
  try {
134
+ if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
135
+ return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
136
+ }
134
137
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
135
138
  else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
136
139
  return json(await options.action(input, sessionOf(cookie)));
@@ -149,11 +152,11 @@ export function startDashboard(options: DashboardOptions) {
149
152
  return {
150
153
  origin,
151
154
  /** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
152
- issue(settings = false): string {
155
+ issue(settings = false, liveTest = false): string {
153
156
  prune(tickets);
154
157
  if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
155
158
  const ticket = secret();
156
- tickets.set(ticket, { expires: now() + TICKET_MS, settings });
159
+ tickets.set(ticket, { expires: now() + TICKET_MS, settings, ...(liveTest ? { liveTest: true } : {}) });
157
160
  return `${origin}/#${ticket}`;
158
161
  },
159
162
  stop() {
@@ -0,0 +1,28 @@
1
+ import { readFileSync, openSync, closeSync, fsyncSync, mkdirSync } from "node:fs";
2
+ import { join, relative, resolve, dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { realPath } from "../hub/project.ts";
5
+ import { atomicPrivateJSON } from "../hub/recovery-store.ts";
6
+
7
+ /** One validation boundary for ordinary starts and explicit preserved-history selection. */
8
+ export function validatePiSession(stateDir: string, cwd: string, sessionFile: string, sessionId?: string): { sessionFile: string; sessionId: string } {
9
+ const file = realPath(sessionFile), sessions = realPath(join(stateDir, "pi-sessions"));
10
+ const rel = relative(sessions, file);
11
+ if (!rel || rel.startsWith("..") || resolve(sessions, rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
12
+ const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
13
+ if (header.type !== "session" || typeof header.id !== "string" || !header.id || typeof header.cwd !== "string" || realPath(header.cwd) !== realPath(cwd) || (sessionId && header.id !== sessionId)) throw new Error("Pi session header does not match the requested project/session");
14
+ return { sessionFile: file, sessionId: header.id };
15
+ }
16
+
17
+ /** The private selection record must reach storage before any prior owner is revoked. No transcript bodies. */
18
+ export function recordPiReplacement(stateDir: string, instanceId: string, previous: Record<string, unknown>, selected: { sessionFile: string; sessionId: string }): string {
19
+ const directory = join(stateDir, "pi-session-replacements");
20
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
21
+ const file = join(directory, `${randomUUID()}.json`);
22
+ atomicPrivateJSON(file, { schema: 1, at: Date.now(), instanceId, previous, selected });
23
+ for (const path of [file, directory, dirname(directory)]) {
24
+ const fd = openSync(path, "r");
25
+ try { fsyncSync(fd); } finally { closeSync(fd); }
26
+ }
27
+ return file;
28
+ }
@@ -64,6 +64,7 @@ export class PiToolReceipts {
64
64
  return work;
65
65
  }
66
66
 
67
+ get unsettled(): boolean { return this.pending.size > 0 || !!this.db.query("SELECT 1 FROM pi_tool_receipts WHERE state != 'done' LIMIT 1").get(); }
67
68
  get inFlight(): number { return this.pending.size; }
68
69
  async drain(): Promise<void> { await Promise.allSettled(this.pending.values()); }
69
70