@staix/agent-hub 0.25.0 → 0.27.0

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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/daemon.md +7 -5
  6. package/docs/agent-notes/tasks.md +7 -6
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +7 -1
  9. package/docs/operations.md +160 -17
  10. package/docs/profiles.md +1 -0
  11. package/docs/quickstart.md +18 -4
  12. package/docs/sdlc.md +3 -3
  13. package/docs/security.md +52 -9
  14. package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
  15. package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
  16. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  17. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  18. package/docs/verified.json +28 -16
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +190 -101
  22. package/src/adapters/channel-peer.ts +4 -2
  23. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  24. package/src/adapters/definitions/qwen.ts +2 -0
  25. package/src/adapters/registry-types.ts +2 -0
  26. package/src/adapters/registry.ts +1 -0
  27. package/src/cli/conductor-recovery.ts +3 -2
  28. package/src/cli/help.ts +9 -8
  29. package/src/cli/identity.ts +5 -5
  30. package/src/cli/main.ts +68 -24
  31. package/src/cli/recovery-package.ts +94 -4
  32. package/src/cli/reset.ts +14 -4
  33. package/src/cli/runtime-version-warning.ts +28 -0
  34. package/src/cli/start-agents-run.ts +125 -0
  35. package/src/cli/study-output.ts +56 -0
  36. package/src/cli/study.ts +26 -3
  37. package/src/cli/task-output.ts +5 -0
  38. package/src/cli/upgrade-interactive.ts +4 -4
  39. package/src/cli/upgrade-runtime.ts +105 -57
  40. package/src/cli/upgrade.ts +90 -28
  41. package/src/hub/action-policy.ts +5 -3
  42. package/src/hub/autonomy-defaults.ts +32 -7
  43. package/src/hub/board.ts +129 -13
  44. package/src/hub/budget.ts +3 -4
  45. package/src/hub/child-process.ts +4 -3
  46. package/src/hub/conductor.ts +3 -4
  47. package/src/hub/config-apply.ts +13 -2
  48. package/src/hub/config-edit-host.ts +1 -0
  49. package/src/hub/config-tier.ts +3 -0
  50. package/src/hub/config-trust.ts +10 -2
  51. package/src/hub/control-client.ts +30 -10
  52. package/src/hub/daemon.ts +103 -26
  53. package/src/hub/db-schema.ts +143 -0
  54. package/src/hub/delivery-journal.ts +3 -4
  55. package/src/hub/events.ts +2 -1
  56. package/src/hub/execution-budget.ts +3 -4
  57. package/src/hub/folder-trigger.ts +3 -4
  58. package/src/hub/hub-tools.ts +7 -5
  59. package/src/hub/invocations.ts +3 -4
  60. package/src/hub/lifecycle.ts +87 -29
  61. package/src/hub/manager.ts +49 -33
  62. package/src/hub/no-writes.ts +1 -1
  63. package/src/hub/orca-env.ts +19 -0
  64. package/src/hub/project-runtime-state.ts +164 -0
  65. package/src/hub/project-runtime.ts +62 -0
  66. package/src/hub/protocol-window.ts +19 -0
  67. package/src/hub/recovery-store.ts +102 -12
  68. package/src/hub/registry.ts +5 -0
  69. package/src/hub/research-experiments.ts +33 -4
  70. package/src/hub/research-runner.ts +172 -0
  71. package/src/hub/restart.ts +9 -0
  72. package/src/hub/runtime-capture-prune.ts +415 -0
  73. package/src/hub/runtime-prune-health.ts +112 -0
  74. package/src/hub/schedules.ts +3 -4
  75. package/src/hub/sdlc-merge.ts +7 -0
  76. package/src/hub/sdlc-queue.ts +13 -1
  77. package/src/hub/sdlc-release-authorization.ts +11 -5
  78. package/src/hub/session-identity.ts +1 -1
  79. package/src/hub/settings.ts +4 -0
  80. package/src/hub/snapshots.ts +3 -4
  81. package/src/hub/start-agents.ts +44 -0
  82. package/src/hub/task-approval.ts +23 -0
  83. package/src/hub/tasks.ts +133 -34
  84. package/src/hub/usage-rollup.ts +3 -4
  85. package/src/pi/tool-receipts.ts +3 -4
  86. package/templates/presets/confirmation-matrix.json +5 -5
  87. package/templates/release-guard.block.md +2 -1
@@ -8,7 +8,7 @@ import { NOTE_KINDS } from "./envelope.ts";
8
8
 
9
9
  /** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
10
10
  /** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
11
- export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_study_handoff"]);
11
+ export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
12
12
 
13
13
  export const IMPLEMENTER_PERMISSION_TOOL = {
14
14
  name: "hub_permission_request",
@@ -82,7 +82,7 @@ const tool = (name: string, description: string, properties: Record<string, unkn
82
82
  });
83
83
 
84
84
  /** #601: a campaign's contract and handoff are model-written text about its sources. The write tools' arguments and every study result stay out of memory capture. */
85
- export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_study_handoff"]);
85
+ export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
86
86
  /** #604: a handoff's arguments cross to another project, so neither tool's arguments nor result are captured to memory. */
87
87
  export const HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
88
88
  export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
@@ -103,6 +103,8 @@ export const TASK_TOOLS: HubTool[] = [
103
103
  tool("hub_study_draft", "Store a research campaign contract as a DRAFT revision 1 on an approved parent task. Configured planners only; it creates no task, dispatches nothing and never approves (only the person approves, with ahub study approve). The contract is data: question, scope, exclusions, sourceIds, units, deliverableSections, stopPolicy and an optional budget.", { parentTaskId: id, contract: { type: "object" } }, ["parentTaskId", "contract"]),
104
104
  tool("hub_study_revise", "Store the next DRAFT revision of a research campaign with the revision you read. Configured planners only; a stale revision refuses, and a draft holds older bound work until the person approves it. Never approves.", { id: str, revision: { type: "integer", minimum: 1 }, contract: { type: "object" } }, ["id", "revision", "contract"]),
105
105
  tool("hub_study_show", "Read a research campaign's coverage and stop projection: per unit round, state, handoff completeness and budget support, and why dispatch is held. Read-only. A private campaign, task or source shows ids, counts and states only, never its question, scope, path or excerpt. Give no id to list this project's campaigns, at most 50 per call: when the reply carries `next`, pass it as `after` for the following page.", { id: str, after: str }),
106
+ tool("hub_experiment_record", "Record experiment metadata for your current in-progress campaign unit: protocol, attempt, event or comparison plan, or relay a signed runner receipt. The hub validates ownership, sources, campaign and signatures. It runs no command, starts no experiment and contacts no runner. Refused during a PII turn.", { kind: { type: "string", enum: ["protocol", "attempt", "event", "plan", "receipt"] }, taskId: id, protocol: { type: "object" }, attempt: { type: "object" }, attemptId: str, idempotencyKey: str, event: { type: "object" }, plan: { type: "object" }, receipt: { type: "object" }, signature: str }, ["kind"]),
107
+ tool("hub_experiment_show", "Read the experiment attempts of a research campaign, one attempt, or one baseline/variant comparison under a recorded plan. Read-only: it runs no command and reads no file. Every metric, hash and setting says where it came from (declared by an agent, host-observed, runner-attested or person-attested); a declared value is never shown as observed, failed, cancelled and unknown attempts stay in the counts, and a repeat is never an independent reproduction. A private campaign, task or source shows ids, counts and states only. Give campaignId (with after to page, at most 50 rows), or attemptId, or planHash with baselineAttemptId and variantAttemptId.", { campaignId: str, attemptId: str, after: { type: "integer", minimum: 0 }, eventAfter: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 50 }, planHash: str, baselineAttemptId: str, variantAttemptId: str }),
106
108
  tool("hub_task_handoff", "Hand work that belongs to ANOTHER project that runs agent-hub to that project's board. Use it instead of implementing it from this session: a feature or behavior change first needs a spec issue in that project (pass its number as issue); a mechanical chore (an exact version pin, a regenerated file) goes straight to a task. The target is a registered project (its root or id) whose hub is running; it is not queued. Planner, conductor or person only. Never for a [pii] task or during a private turn. You learn the task's state changes as kind task messages.", { project: str, title: str, class: { type: "string", description: "task class, default implement" }, detail: str, paths: { type: "array", items: str, description: "repository-relative paths in the TARGET project" }, issue: { type: "integer", minimum: 1, description: "the target repository's issue number" }, task: { type: "integer", minimum: 1, description: "your own task in THIS project that this work comes from; a [pii] task is refused" } }, ["project", "title"]),
107
109
  tool("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
108
110
  tool("hub_study_handoff", "Record the handoff for the research unit you currently own and have in progress: evidence per source, unresolved questions, contradictions and limitations, or insufficientEvidence. Only the bound owner; every required field must be present. Not a task completion.", { handoff: { type: "object" } }, ["handoff"]),
@@ -194,7 +196,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
194
196
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
195
197
  }
196
198
 
197
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
199
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
198
200
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
199
201
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
200
202
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -240,7 +242,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
240
242
  properties.receipt = { ...receipt, properties: fields };
241
243
  }
242
244
  if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
243
- if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
245
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
244
246
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
245
247
  const summaryProp = properties.summary as { type: string; description: string };
246
248
  properties.summary = {
@@ -255,7 +257,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
255
257
  const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
256
258
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
257
259
  }
258
- if (criteria && properties.plan) {
260
+ if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
259
261
  const current = properties.plan as { properties: Record<string, unknown> };
260
262
  properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
261
263
  }
@@ -1,9 +1,9 @@
1
1
  // #445 C2: read invocations. A peer asks the hub to have another peer run one capability the person shared; the target runs it in its own turn with its own
2
2
  // permissions, and the hub returns a bounded, sanitized, hub-headed result to the caller only. This file is the pure part (the gates, the argument check, the
3
3
  // two texts the hub writes) and the ledger (an ids-only `invocations` table in hub.db). It never reads a peer, a socket or the bus.
4
- import { Database } from "bun:sqlite";
4
+ import type { Database } from "./db-schema.ts";
5
+ import { openHubDatabase } from "./db-schema.ts";
5
6
  import { createHash } from "node:crypto";
6
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
7
  import { MAX_HOP, sanitize } from "./envelope.ts";
8
8
  import type { FlatSchema, ShareState, SideEffect } from "./capabilities.ts";
9
9
 
@@ -230,8 +230,7 @@ function validateRelay(row: InvocationRow, relay: Omit<InvocationRelay, "local"
230
230
  export class InvocationLedger {
231
231
  private readonly db: Database;
232
232
  constructor(path: string, private readonly now: () => number = Date.now) {
233
- this.db = new Database(path, { create: true });
234
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
233
+ this.db = openHubDatabase(path, { create: true });
235
234
  this.db.run("PRAGMA journal_mode = WAL");
236
235
  this.db.run(`CREATE TABLE IF NOT EXISTS invocations (
237
236
  id TEXT PRIMARY KEY, trace TEXT NOT NULL, caller TEXT NOT NULL, target TEXT NOT NULL, capability TEXT NOT NULL, digest TEXT NOT NULL,
@@ -1,15 +1,23 @@
1
+ import { resolveProjectRuntime, recordProjectRuntime, confirmProjectRuntime, readProjectRuntimeForCaller, projectRuntimeRefusal, type ProjectRuntimeSelection } from "./project-runtime.ts";
2
+ import { scheduleRuntimeCapturePrune } from "./runtime-capture-prune.ts";
3
+ import { createProjectRuntimeAccess, type ProjectRuntimeAccess } from "./project-runtime-state.ts";
1
4
  import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
2
5
  import { spawn } from "node:child_process";
3
6
  import { randomUUID } from "node:crypto";
4
7
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
8
+ import { join, resolve } from "node:path";
6
9
  import { createServer, type Server } from "node:net";
7
- import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
10
+ import { ControlClient, PROTOCOL, PROTOCOL_MIN, controlProtocolWindow, readControl } from "./control-client.ts";
11
+ import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
12
+ import { assertHubDbCompatible } from "./db-schema.ts";
13
+ import { verifyPackage, verifyRecoveryTarget } from "../cli/recovery-package.ts";
14
+ import { VERSION } from "../version.ts";
8
15
  import { startDaemon } from "./daemon.ts";
9
16
  import { terminalOpener } from "./terminal-open.ts";
10
17
  import { Registry, type Project } from "./registry.ts";
11
18
  import { CODEX_APP, CODEX_PROXY, CONTROL } from "./ports.ts";
12
- import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
19
+ import { assertLifecycleAvailable, readOperation, recoveryLock } from "./recovery-store.ts";
20
+ import { realPath } from "./project.ts";
13
21
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
14
22
 
15
23
  export type ProjectInspection = {
@@ -25,7 +33,9 @@ export async function inspectProject(project: Project, timeoutMs = 3000, readerP
25
33
  const control = readControl(project.stateDir);
26
34
  if (control) {
27
35
  if (processLiveness(control.pid, control.pidSignature) === "gone") return { state: "stopped" };
28
- if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
36
+ const advertised = controlProtocolWindow(control);
37
+ if ((control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined)
38
+ && (!advertised || negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, advertised) === undefined)) {
29
39
  return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
30
40
  }
31
41
  if ((control.projectId && control.projectId !== project.id) || control.cwd !== project.root) {
@@ -87,9 +97,24 @@ async function available(base: number): Promise<boolean> {
87
97
  /** The signals the project daemon stops on (see `runProjectDaemon`). */
88
98
  export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
89
99
 
100
+ function startupRuntime(project: Project, env: NodeJS.ProcessEnv, registry: Registry, access: ProjectRuntimeAccess) {
101
+ const sourceRoot = resolve(import.meta.dir, "../.."), id = env.AGENTHUB_RECOVERY_OPERATION;
102
+ if (id && recoveryLock(undefined, project.root) === id) {
103
+ const op = readOperation<{ targetRoot?: string; targetDigest?: string; plan: { kind: "restart" | "upgrade"; version: string; projects: { project: Project }[] } }>(id);
104
+ if (!op.targetRoot || !op.targetDigest || op.plan.version !== VERSION || realPath(op.targetRoot) !== realPath(sourceRoot)
105
+ || !op.plan.projects.some(row => row.project.id === project.id && row.project.root === project.root && row.project.stateDir === project.stateDir))
106
+ throw new Error("recovery startup does not match its retained target runtime");
107
+ // Authority binds this exact target path. Do not re-stage it at a different, byte-identical path.
108
+ return { root: sourceRoot, digest: op.targetDigest, version: VERSION, kind: op.plan.kind, check: () => verifyRecoveryTarget(op.plan.kind, sourceRoot, VERSION) };
109
+ }
110
+ const retained = resolveProjectRuntime(project, VERSION, sourceRoot, registry, access);
111
+ return { ...retained, check: () => verifyRecoveryTarget(retained.kind, retained.root, retained.version) };
112
+ }
113
+
90
114
  /** Called only in the detached daemon process. A competing starter exits without touching state. */
91
115
  export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
92
- assertLifecycleAvailable();
116
+ assertLifecycleAvailable(project.root);
117
+ assertHubDbCompatible(join(project.stateDir, "hub.db"));
93
118
  const registry = new Registry();
94
119
  const instanceId = randomUUID();
95
120
  let claimed = false;
@@ -108,11 +133,19 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
108
133
  forcedExit.unref();
109
134
  };
110
135
  try {
136
+ const runtimeAccess = createProjectRuntimeAccess(project);
111
137
  const before = await inspectProject(registry.get(project.id) ?? project);
112
138
  if (before.state === "running" || before.state === "starting") return;
113
139
  if (before.state !== "stopped") throw new Error(before.error ?? before.state);
114
140
  claimed = registry.claim(project.id, instanceId, process.pid, processSignature(process.pid));
115
141
  if (!claimed) return;
142
+ registry.runtimeCaptureLocked(() => {
143
+ const runtime = startupRuntime(project, process.env, registry, runtimeAccess);
144
+ // Direct internal daemon entry must not bypass the selected runtime. Normal up already spawned its verified capture.
145
+ if (realPath(resolve(import.meta.dir, "../..")) !== runtime.root || verifyPackage(resolve(import.meta.dir, "../.."), VERSION) !== runtime.digest)
146
+ throw projectRuntimeRefusal(runtime.version, "start with ahub up so the recorded runtime is used");
147
+ recordProjectRuntime(project, runtime, instanceId, registry, runtimeAccess);
148
+ });
116
149
  let base = registry.get(project.id)!.basePort;
117
150
  for (let attempt = 0; attempt < 100; attempt++) {
118
151
  if (await available(base)) {
@@ -145,44 +178,69 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
145
178
 
146
179
  export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void } = {}): Promise<any> {
147
180
  options.current?.();
148
- assertLifecycleAvailable();
181
+ assertLifecycleAvailable(project.root);
149
182
  const registry = new Registry();
150
183
  try {
151
184
  const current = registry.get(project.id);
152
185
  if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
186
+ const runtimeAccess = createProjectRuntimeAccess(current);
187
+ const admitRunningRuntime = () => {
188
+ const fresh = registry.get(project.id);
189
+ if (!fresh || fresh.root !== project.root || fresh.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
190
+ readProjectRuntimeForCaller(fresh, VERSION, runtimeAccess);
191
+ };
153
192
  const before = await inspectProject(current);
154
193
  options.current?.();
155
- if (before.state === "running") return before.status;
194
+ if (before.state === "running") { admitRunningRuntime(); return before.status; }
156
195
  if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
157
196
  if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
197
+ let launchedRuntime: ProjectRuntimeSelection | undefined;
158
198
  if (before.state === "stopped") {
159
- mkdirSync(project.stateDir, { recursive: true });
160
- const marker = join(project.stateDir, "project.json");
161
- const temporary = `${marker}.${randomUUID()}.tmp`;
162
- writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
163
- renameSync(temporary, marker);
164
- const log = openSync(join(project.stateDir, "hub.log"), "a");
165
- try {
166
- const launchEnv = { ...(options.env ?? process.env) };
167
- // Native sessions can outlive the operation which launched them. An expired
168
- // operation ID must not make a later ordinary startup load a retired snapshot.
169
- if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
199
+ const launchEnv = { ...(options.env ?? process.env) };
200
+ // Expired operation ids must not make an ordinary startup load a retired snapshot.
201
+ if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock(undefined, project.root)) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
202
+ registry.runtimeCaptureLocked(() => {
203
+ const claim = registry.get(project.id);
204
+ if (!claim || claim.root !== project.root || claim.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
205
+ // A competing starter owns readiness now. Never replace its selection or create a second native start.
206
+ if (claim.instanceId && processLiveness(claim.pid, claim.pidSignature) !== "gone") return;
207
+ const runtime = startupRuntime(project, launchEnv, registry, runtimeAccess);
170
208
  options.current?.();
171
- const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
172
- ...(options.unattended ? ["--unattended"] : [])], {
173
- cwd: project.root, detached: true, stdio: ["ignore", log, log],
174
- env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
175
- AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
176
- });
177
- child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
178
- child.unref();
179
- } finally { closeSync(log); }
209
+ assertLifecycleAvailable(project.root);
210
+ if (runtime.check() !== runtime.digest) throw new Error("staged startup runtime changed");
211
+ // The selected runtime's own preflight decides schema compatibility; an older invoking CLI cannot preflight for it.
212
+ recordProjectRuntime(project, runtime, null, registry, runtimeAccess);
213
+ launchedRuntime = runtime;
214
+ mkdirSync(project.stateDir, { recursive: true });
215
+ const marker = join(project.stateDir, "project.json");
216
+ const temporary = `${marker}.${randomUUID()}.tmp`;
217
+ writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
218
+ renameSync(temporary, marker);
219
+ const log = openSync(join(project.stateDir, "hub.log"), "a");
220
+ try {
221
+ options.current?.();
222
+ const child = spawn(process.execPath, [join(runtime.root, "src/cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
223
+ ...(options.unattended ? ["--unattended"] : [])], {
224
+ cwd: project.root, detached: true, stdio: ["ignore", log, log],
225
+ env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
226
+ AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
227
+ });
228
+ child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
229
+ child.unref();
230
+ } finally { closeSync(log); }
231
+ });
180
232
  }
181
233
  const deadline = Date.now() + 12_000;
182
234
  while (Date.now() < deadline) {
183
235
  const observed = await inspectProject(registry.get(project.id) ?? project);
184
236
  options.current?.();
185
- if (observed.state === "running") return observed.status;
237
+ if (observed.state === "running") {
238
+ admitRunningRuntime();
239
+ if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, observed.status, registry, runtimeAccess);
240
+ // Unknown references defer maintenance; cache cleanup never changes a confirmed startup outcome.
241
+ try { scheduleRuntimeCapturePrune(); } catch { /* retain captures on unreadable inventory or cleanup failure */ }
242
+ return observed.status;
243
+ }
186
244
  if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
187
245
  await Bun.sleep(100);
188
246
  }
@@ -200,7 +258,7 @@ function claimHeld(id: string, instanceId: string): boolean {
200
258
  }
201
259
 
202
260
  export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
203
- assertLifecycleAvailable();
261
+ assertLifecycleAvailable(project.root);
204
262
  const inspection = await inspectProject(project);
205
263
  if (inspection.state === "stopped") return;
206
264
  if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
@@ -1,13 +1,14 @@
1
+ import { assertExecutableRuntimeRoot } from "./project-runtime-state.ts";
1
2
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
2
3
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
3
4
  import { Database } from "bun:sqlite";
4
5
  import { randomBytes, randomUUID } from "node:crypto";
5
6
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
+ import { join, resolve } from "node:path";
7
8
  import { spawn } from "node:child_process";
8
- import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
9
+ import { ControlClient, PROTOCOL, PROTOCOL_MIN, RECOVERY_SOURCE_PROTOCOLS, controlProtocolWindow } from "./control-client.ts";
9
10
  import { Registry, type Project } from "./registry.ts";
10
- import { hubHome } from "./project.ts";
11
+ import { hubHome, realPath } from "./project.ts";
11
12
  import { startDashboard, dashboardView } from "./ui.ts";
12
13
  import { projectChain } from "@staix/agent-mem";
13
14
  import type { ProjectInspection } from "./lifecycle.ts";
@@ -22,10 +23,12 @@ export type Lifecycle = {
22
23
  stopProject(project: Project, expectedInstance?: string): Promise<void>;
23
24
  };
24
25
  export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
26
+ /** #608: runs after the dashboard's "start" has the hub ready, as the person's `ahub up` would, never for a live-test session. Its returned lines and any failure go to manager.log, one line each. Never part of a dashboard request. */
27
+ afterStart?: (project: Project) => Promise<string[]>;
25
28
  /** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
26
29
  forwardTimeoutMs?: (action: string, nominalMs: number) => number };
27
30
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
28
- type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
31
+ type Manifest = { port: number; protocol: number; protocolMin?: number; protocolMax?: number; instanceId: string; pid: number; pidSignature?: string; runtimeRoot?: string };
29
32
  const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
30
33
  const CONTROL_TIMEOUT = 3000;
31
34
  // A current CLI may authenticate the previous protocol-9 manager while
@@ -80,7 +83,7 @@ function removeManifest(home: string, instanceId: string) {
80
83
  }
81
84
 
82
85
  /** The manager's project listing and forwarding (#935): shared by `ahub ui --all` and the acon plugin, never widened for either. */
83
- export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"] }) {
86
+ export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"]; afterStart?: ManagerOptions["afterStart"] }) {
84
87
  const { registry, lifecycle, home } = options;
85
88
  const aliases = new Map<string, { at: number; names: string[] }>();
86
89
  const list = async () => {
@@ -102,7 +105,7 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
102
105
  }));
103
106
  return { ok: true, mode: "all", projects: rows };
104
107
  };
105
- const act = async (input: Record<string, unknown>) => {
108
+ const act = async (input: Record<string, unknown>, session?: { liveTest?: true }) => {
106
109
  if (options.stopping?.()) return { ok: false, error: "manager is stopping" };
107
110
  const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
108
111
  if (!project) return { ok: false, error: "unknown project" };
@@ -110,6 +113,14 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
110
113
  try {
111
114
  if (input.action === "start") {
112
115
  const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
116
+ // The agents start in the background: the dashboard's answer is the hub's readiness, and a terminal that is slow to open must not hold it.
117
+ // A live-test dashboard session is an agent borrowing the person's console: it never opens the person's default agents. The session, not this process, carries that flag.
118
+ if (options.afterStart && session?.liveTest !== true) {
119
+ const stamp = (line: string) => `${new Date().toISOString()} default agents for ${project.id}: ${line.replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 600)}`;
120
+ void options.afterStart(project).then(
121
+ lines => { for (const line of lines) console.error(stamp(line)); },
122
+ error => { console.error(stamp(`not started: ${safeError(error)}`)); });
123
+ }
113
124
  return { ok: true, projectId: project.id, instanceId: status.instanceId };
114
125
  }
115
126
  if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
@@ -181,31 +192,36 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
181
192
  cleanup(); resolveStopped();
182
193
  };
183
194
  try {
184
- owner = claimOwner(f.owner, instanceId, pidSignature);
185
- const { list, act } = projectConsole({ registry, lifecycle, home, stopping: () => stopping, forwardTimeoutMs: options.forwardTimeoutMs });
186
- dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
187
- server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
188
- async fetch(req) {
189
- if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
190
- if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
191
- if (req.method !== "POST") return new Response("POST required", { status: 405 });
192
- const expected = req.headers.get("x-agenthub-instance");
193
- if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
194
- if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
195
- const path = new URL(req.url).pathname;
196
- if (path === "/open") {
197
- const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
198
- return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
199
- }
200
- if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
201
- return new Response("not found", { status: 404 });
202
- },
203
- });
204
- writeFileSync(f.token, token, { mode: 0o600 });
205
- chmodSync(f.token, 0o600);
206
- const temp = `${f.status}.${instanceId}.tmp`;
207
- writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}) }));
208
- renameSync(temp, f.status);
195
+ const publicationRegistry = typeof registry.runtimeCaptureLocked === "function" && typeof registry.path === "string"
196
+ && resolve(registry.path) === resolve(join(home, "registry.db")) ? registry : new Registry(join(home, "registry.db"));
197
+ try { publicationRegistry.runtimeCaptureLocked(() => {
198
+ assertExecutableRuntimeRoot(realPath(resolve(import.meta.dir, "../..")));
199
+ owner = claimOwner(f.owner, instanceId, pidSignature);
200
+ const { list, act } = projectConsole({ registry, lifecycle, home, stopping: () => stopping, forwardTimeoutMs: options.forwardTimeoutMs, ...(options.afterStart && !options.liveTest ? { afterStart: options.afterStart } : {}) });
201
+ dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
202
+ server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
203
+ async fetch(req) {
204
+ if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
205
+ if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
206
+ if (req.method !== "POST") return new Response("POST required", { status: 405 });
207
+ const expected = req.headers.get("x-agenthub-instance");
208
+ if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
209
+ if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
210
+ const path = new URL(req.url).pathname;
211
+ if (path === "/open") {
212
+ const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
213
+ return Response.json({ ok: true, instanceId, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
214
+ }
215
+ if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
216
+ return new Response("not found", { status: 404 });
217
+ },
218
+ });
219
+ writeFileSync(f.token, token, { mode: 0o600 });
220
+ chmodSync(f.token, 0o600);
221
+ const temp = `${f.status}.${instanceId}.tmp`;
222
+ writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}), runtimeRoot: realPath(resolve(import.meta.dir, "../..")) }), { mode: 0o600 });
223
+ renameSync(temp, f.status);
224
+ }); } finally { if (publicationRegistry !== registry) publicationRegistry.close(); }
209
225
  // The manager outlives whoever opened it; a deleted AGENTHUB_HOME (a test suite's temp
210
226
  // home, issue #56) must not leave it serving a registry that no longer exists.
211
227
  orphanWatch = setInterval(() => {
@@ -222,7 +238,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
222
238
  async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
223
239
  const status = readManifest(home);
224
240
  if (!status) throw new Error("manager manifest is unavailable");
225
- if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
241
+ if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol) || !controlProtocolWindow(status)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
226
242
  const token = readFileSync(files(home).token, "utf8").trim();
227
243
  if (!token) throw new Error("manager token is unavailable");
228
244
  const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
@@ -275,7 +291,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
275
291
  export async function refreshManager(options: ManagerOptions = {}): Promise<string> {
276
292
  const home = options.home ?? hubHome();
277
293
  const manifest = readManifest(home);
278
- if (manifest && manifest.protocol !== PROTOCOL && RECOVERY_SOURCE_PROTOCOLS.includes(manifest.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) {
294
+ if (manifest && (controlProtocolWindow(manifest)?.v ?? Infinity) < PROTOCOL && RECOVERY_SOURCE_PROTOCOLS.includes(manifest.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) {
279
295
  await managerRequest(home, "/stop");
280
296
  const deadline = Date.now() + CONTROL_TIMEOUT;
281
297
  while (Date.now() < deadline && readManifest(home)) await Bun.sleep(25);
@@ -37,7 +37,7 @@ export class NoWriteTurns {
37
37
  }
38
38
 
39
39
 
40
- const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff"]);
40
+ const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff", "hub_experiment_record"]);
41
41
  /** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
42
42
  export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
43
43
  return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * #613 (agent-hub#1048): the one source of truth for the Orca terminal environment. No imports, so every layer can read it
3
+ * (the launcher, the hub's terminal opener, the test preload and the gate's comment).
4
+ *
5
+ * The Orca launcher decides whether to open a terminal in the person's running Orca from these four names, so a process
6
+ * that inherits them reaches that Orca. A hub or a test run started from an Orca pane inherits them, so tests and the gate
7
+ * start with the whole `ORCA_` prefix removed and a test that wants one sets it itself.
8
+ */
9
+ export const ORCA_LAUNCHER_ENV: readonly string[] = Object.freeze(["ORCA_CLI_COMMAND", "ORCA_DEV_REPO_ROOT", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]);
10
+
11
+ /** Every variable an Orca terminal exports: the launcher's four and the rest of the pane's environment. */
12
+ export const isOrcaEnvName = (name: string): boolean => name.startsWith("ORCA_");
13
+
14
+ /** A copy of `env` with every `ORCA_` variable removed. */
15
+ export function withoutOrcaEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
16
+ const out: NodeJS.ProcessEnv = { ...env };
17
+ for (const name of Object.keys(out)) if (isOrcaEnvName(name)) delete out[name];
18
+ return out;
19
+ }