@staix/agent-hub 0.12.33 → 0.12.35

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 (126) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +9 -3
  4. package/docs/agent-notes/budget.md +7 -3
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +7 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +2 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +80 -16
  12. package/docs/quickstart.md +10 -2
  13. package/docs/sdlc.md +59 -34
  14. package/docs/security.md +7 -4
  15. package/docs/smoke.md +1 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
  17. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  18. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  19. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  20. package/docs/verified.json +35 -19
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +23567 -16606
  24. package/src/adapters/acp.ts +3 -1
  25. package/src/adapters/claude-channel.ts +59 -29
  26. package/src/adapters/codex-appserver.ts +82 -12
  27. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  28. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  29. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  30. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  31. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  32. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  33. package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
  34. package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
  35. package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
  36. package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
  37. package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
  39. package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
  40. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  41. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  42. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  43. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  44. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  45. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  46. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  47. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  48. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  49. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  50. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  51. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  52. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  53. package/src/adapters/definitions/native-config-policy.ts +35 -0
  54. package/src/adapters/definitions/native-context.ts +35 -0
  55. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  56. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  57. package/src/adapters/definitions/native-display.ts +18 -0
  58. package/src/adapters/definitions/native-factories.ts +48 -0
  59. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  60. package/src/adapters/definitions/native-quota.ts +29 -0
  61. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  62. package/src/adapters/definitions/native-recovery.ts +88 -0
  63. package/src/adapters/definitions/qwen-native.ts +2 -2
  64. package/src/adapters/definitions/qwen.ts +8 -4
  65. package/src/adapters/local-worker.ts +3 -2
  66. package/src/adapters/pi.ts +3 -1
  67. package/src/adapters/registry-types.ts +100 -10
  68. package/src/adapters/registry.ts +127 -11
  69. package/src/cli/console-state.ts +5 -5
  70. package/src/cli/identity.ts +8 -7
  71. package/src/cli/jcode-mcp.ts +2 -58
  72. package/src/cli/launch.ts +41 -102
  73. package/src/cli/main.ts +126 -290
  74. package/src/cli/output.ts +3 -2
  75. package/src/cli/preview.ts +26 -126
  76. package/src/cli/recovery-guidance.ts +43 -22
  77. package/src/cli/setup.ts +2 -80
  78. package/src/cli/shell-quote.ts +3 -0
  79. package/src/cli/terminal-recovery.ts +81 -22
  80. package/src/cli/upgrade-interactive.ts +4 -2
  81. package/src/cli/upgrade-restore-state.ts +17 -7
  82. package/src/cli/upgrade-runtime.ts +126 -147
  83. package/src/cli/upgrade.ts +44 -27
  84. package/src/hub/budget.ts +59 -31
  85. package/src/hub/bus.ts +1 -1
  86. package/src/hub/conductor.ts +19 -9
  87. package/src/hub/config-apply.ts +22 -15
  88. package/src/hub/config-reload.ts +18 -3
  89. package/src/hub/config-trust.ts +1 -1
  90. package/src/hub/context-window.ts +3 -27
  91. package/src/hub/control-client.ts +1 -1
  92. package/src/hub/crash.ts +15 -39
  93. package/src/hub/daemon.ts +506 -341
  94. package/src/hub/execution-budget.ts +3 -2
  95. package/src/hub/hub-tools.ts +19 -8
  96. package/src/hub/instance-budget.ts +73 -0
  97. package/src/hub/instance-config.ts +47 -0
  98. package/src/hub/instance-execution.ts +50 -0
  99. package/src/hub/instances.ts +100 -0
  100. package/src/hub/lifecycle-schema.ts +7 -3
  101. package/src/hub/peer-catalog.ts +4 -13
  102. package/src/hub/permission-mode.ts +14 -17
  103. package/src/hub/recovery-continuity.ts +6 -5
  104. package/src/hub/report.ts +9 -8
  105. package/src/hub/roster.ts +9 -9
  106. package/src/hub/routing.ts +94 -19
  107. package/src/hub/sdlc-queue.ts +2 -2
  108. package/src/hub/sdlc.ts +84 -38
  109. package/src/hub/settings.ts +39 -10
  110. package/src/hub/settled-roles.ts +1 -1
  111. package/src/hub/snapshots.ts +40 -9
  112. package/src/hub/start-mode.ts +13 -8
  113. package/src/hub/status-roster.ts +8 -0
  114. package/src/hub/tasks.ts +60 -27
  115. package/src/hub/text-projection.ts +7 -3
  116. package/src/hub/tooling.ts +4 -60
  117. package/src/hub/ui.ts +3 -1
  118. package/src/hub/workflow.ts +30 -52
  119. package/src/memory/recall.ts +4 -5
  120. package/src/models/route/signals.ts +3 -1
  121. package/src/pi/launch.ts +5 -3
  122. package/src/ui/index.html +4 -3
  123. package/src/ui/office-model.ts +3 -1
  124. package/src/ui/peer-palette.ts +16 -6
  125. package/templates/AGENTS.block.md +2 -1
  126. package/templates/routing.toml +2 -2
@@ -1,3 +1,4 @@
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
3
4
 
@@ -41,7 +42,7 @@ const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
41
42
  /** Persistent, cross-turn execution meter. Each admission and increment is one SQLite transaction. */
42
43
  export class ExecutionBudget {
43
44
  private readonly db: Database;
44
- constructor(path: string) {
45
+ constructor(path: string, private readonly instances?: KindMap) {
45
46
  this.db = new Database(path, { create: true });
46
47
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
47
48
  this.db.run("PRAGMA journal_mode = WAL");
@@ -56,7 +57,7 @@ export class ExecutionBudget {
56
57
  if (input.kind === "task" && (!Number.isSafeInteger(input.taskId) || input.taskId! < 1)) throw new Error("task budget requires a positive task id");
57
58
  const peers = [...new Set(input.peers.filter((p) => typeof p === "string" && p.length > 0))].sort();
58
59
  if (!peers.length) throw new Error("execution budget requires at least one peer");
59
- const unsupported = peers.filter((peer) => peer !== "pi" && peer !== "local");
60
+ const unsupported = peers.filter((peer) => !definitionOf(peer, this.instances)?.budget?.instrumented);
60
61
  if (unsupported.length) throw new Error(`execution budgets require instrumented peers (pi, local); unsupported: ${unsupported.join(", ")}`);
61
62
  const limits = validLimits(input.limits ?? {});
62
63
  if (!Object.keys(limits).length) throw new Error("execution budget requires at least one supported limit");
@@ -1,10 +1,13 @@
1
1
  import { SDLC_STAGES } from "./sdlc.ts";
2
2
  import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
3
3
  import { catalogSection, type CatalogRole } from "./roles.ts";
4
- import { SUPPORTED_PEERS, defaultPeerRoles } from "./peer-catalog.ts";
4
+ import { CONDUCTOR_START_PEERS, defaultPeerRoles } from "../adapters/registry.ts";
5
5
  // The hub's task tools, defined once: the MCP server (Claude plugin, Kimi, Codex) and the local worker both read this.
6
6
  import { NOTE_KINDS } from "./envelope.ts";
7
7
 
8
+ /** Shared communication rule for channel and tool-only sessions. */
9
+ export const ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires action from the recipient. Use [FYI] only for progress or information that needs no action. hub_task_done and hub_review already notify the assigned recipients; no duplicate hub_send chat is needed.";
10
+
8
11
  const str = { type: "string" } as const;
9
12
  const id = { type: "integer", description: "task id" } as const;
10
13
  const refs = {
@@ -60,10 +63,10 @@ export const TASK_TOOLS: HubTool[] = [
60
63
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
61
64
  tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
62
65
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
63
- tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
66
+ tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
64
67
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
65
68
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
66
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
69
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
67
70
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
68
71
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
69
72
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
@@ -80,7 +83,7 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
80
83
  tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
81
84
  tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
82
85
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
83
- tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", ...SUPPORTED_PEERS.filter(peer => !["local", "kimi", "pi"].includes(peer))] } }, ["peer"]),
86
+ tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
84
87
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
85
88
  tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
86
89
  ];
@@ -105,16 +108,24 @@ export function roleContract(peer: string, roles: Record<string, string[]> = DEF
105
108
  }
106
109
 
107
110
  /** Profile schemas are data over the existing tools; dev retains the exact existing definitions. */
108
- export function profileTaskTools(profile: ProfileSelection = "dev", stages?: readonly string[]): HubTool[] {
111
+ /** `criteria`: an enabled lifecycle, whose receipts take the #481 S8 checklist whatever its stage list. */
112
+ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: readonly string[], criteria = false): HubTool[] {
109
113
  const customStages = stages && (stages.length !== SDLC_STAGES.length || stages.some((s, i) => s !== SDLC_STAGES[i]));
110
114
  const customProfile = !(profile === "dev" || profileData(profile) === projectProfile("dev"));
111
- if (!customStages && !customProfile) return TASK_TOOLS;
115
+ if (!customStages && !customProfile && !criteria) return TASK_TOOLS;
112
116
  const data = profileData(profile);
113
117
  return TASK_TOOLS.map(tool => {
114
118
  const properties = { ...tool.inputSchema.properties };
115
- if (customStages && tool.name === "hub_sdlc_record") {
119
+ if ((customStages || criteria) && tool.name === "hub_sdlc_record") {
116
120
  const receipt = properties.receipt as { type: string; properties: Record<string, unknown>; additionalProperties: boolean };
117
- properties.receipt = { ...receipt, properties: { ...receipt.properties, stage: { type: "string", enum: [...(stages ?? SDLC_STAGES)] } } };
121
+ let fields: Record<string, unknown> = { ...receipt.properties, ...(customStages ? { stage: { type: "string", enum: [...(stages ?? SDLC_STAGES)] } } : {}) };
122
+ // #481 S8: only an enabled lifecycle (whose daemon accepts it) advertises the criteria checklist; off and older daemons keep phase.
123
+ if (criteria) {
124
+ const { phase, ...current } = fields as { phase: { properties: Record<string, unknown> } } & Record<string, unknown>;
125
+ const { tasks: _retiredImport, ...checklist } = phase.properties;
126
+ fields = { ...current, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
127
+ }
128
+ properties.receipt = { ...receipt, properties: fields };
118
129
  }
119
130
  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])])] };
120
131
  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")])) };
@@ -0,0 +1,73 @@
1
+ import type { Budget, BudgetHooks, Moved, PauseRecord } from "./budget.ts";
2
+
3
+ export type BudgetAccountMembers = (account: string) => readonly string[];
4
+ type Headroom = ReturnType<Budget["headroom"]>;
5
+
6
+ /** Membership comes only from the host's startup-bound registry/declarations, never from model text. */
7
+ function memberIds(account: string, members: BudgetAccountMembers): string[] {
8
+ const ids = [...new Set(members(account))];
9
+ return ids.length ? ids : [account]; // unknown external peers retain their singleton behavior
10
+ }
11
+
12
+ /** Per task/role the latest move is authoritative; retrying a failed fanout cannot grow duplicate history. */
13
+ function mergeMoves(...groups: readonly Moved[][]): Moved[] {
14
+ return [...new Map(groups.flat().map(move => [JSON.stringify([move.id, move.role]), move])).values()];
15
+ }
16
+
17
+ /** Wrap the daemon's existing per-peer callbacks. Its canHandOff predicate must exclude account siblings. */
18
+ export function instanceBudgetHooks(base: BudgetHooks, members: BudgetAccountMembers, account: (peer: string) => string): BudgetHooks {
19
+ const ids = (peer: string) => memberIds(account(peer), members);
20
+ // A failed handoff leaves the core pause unmarked. Preserve completed work until its retry or resume notice;
21
+ // one entry per board task/role bounds this state by the host's task board, rather than by retry count.
22
+ const partialMoves = new Map<string, Moved[]>();
23
+ const summaries = async (peer: string, request: (member: string) => Promise<string | undefined>, deduplicate = false): Promise<string | undefined> => {
24
+ const peers = ids(peer);
25
+ if (peers.length === 1) return request(peers[0]!);
26
+ const parts = await Promise.all(peers.map(async member => {
27
+ const text = await request(member).catch(() => undefined);
28
+ return text === undefined || text.length === 0 ? undefined : { member, text };
29
+ }));
30
+ const groups = new Map<string, { members: string[]; text: string }>();
31
+ for (const part of parts) if (part) {
32
+ const key = deduplicate ? part.text : part.member;
33
+ const group = groups.get(key);
34
+ if (group) group.members.push(part.member);
35
+ else groups.set(key, { members: [part.member], text: part.text });
36
+ }
37
+ return [...groups.values()].map(part => `[${part.members.join(", ")}]\n${part.text}`).join("\n\n") || undefined;
38
+ };
39
+ return {
40
+ ...base,
41
+ account,
42
+ pause(peer) { for (const member of ids(peer)) base.pause(member); },
43
+ resume(peer) { for (const member of ids(peer)) base.resume(member); },
44
+ requestCheckpoint: peer => summaries(peer, member => base.requestCheckpoint(member)),
45
+ platformContext: peer => summaries(peer, member => base.platformContext(member), true),
46
+ attached: peer => ids(peer).some(member => base.attached(member)),
47
+ canHandOff: peer => ids(peer).some(member => base.canHandOff(member)),
48
+ async handoff(peer, context, urgentOnly) {
49
+ const peers = ids(peer);
50
+ if (peers.length === 1) return base.handoff(peers[0]!, context, urgentOnly);
51
+ const key = account(peer);
52
+ let moved = partialMoves.get(key) ?? [];
53
+ for (const member of peers) {
54
+ const current = await base.handoff(member, context, urgentOnly);
55
+ moved = mergeMoves(moved, current);
56
+ partialMoves.set(key, moved);
57
+ }
58
+ partialMoves.delete(key);
59
+ return moved;
60
+ },
61
+ resumed(record) {
62
+ const key = account(record.peer), peers = ids(record.peer), partial = partialMoves.get(key);
63
+ const resumed: PauseRecord = partial ? { ...record, moved: mergeMoves(record.moved, partial) } : record;
64
+ for (const member of peers) base.resumed(member === resumed.peer ? resumed : { ...resumed, peer: member });
65
+ partialMoves.delete(key);
66
+ },
67
+ };
68
+ }
69
+
70
+ /** Routing sees each declared member's account window; the underlying quota store still keeps it once. */
71
+ export function projectBudgetHeadroom(rows: Headroom, members: BudgetAccountMembers): Headroom {
72
+ return Object.fromEntries(Object.entries(rows).flatMap(([account, reading]) => memberIds(account, members).map(member => [member, reading])));
73
+ }
@@ -0,0 +1,47 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defaultPeerRoles } from "../adapters/registry.ts";
4
+ import { conductorPeer } from "./conductor.ts";
5
+ import { configRefusal } from "./config-trust.ts";
6
+ import { validateInstances, type InstancesConfig } from "./instances.ts";
7
+
8
+ export const INSTANCE_CONFIG_FILES = ["config.json", "config.local.json"] as const;
9
+ export interface InstanceConfigLayer { name: typeof INSTANCE_CONFIG_FILES[number]; raw: Record<string, unknown> }
10
+
11
+ /** One source capture can feed declaration validation and the ordinary config loader. */
12
+ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
13
+ return INSTANCE_CONFIG_FILES.flatMap(name => {
14
+ try { return [{ name, raw: JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")) as Record<string, unknown> }]; }
15
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
16
+ });
17
+ }
18
+
19
+ /** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
20
+ export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; ignored: string[] } {
21
+ const ignored: string[] = [];
22
+ let instances: InstancesConfig | undefined;
23
+ for (const { name, raw } of layers) {
24
+ const checked = validateInstances(raw.instances);
25
+ if (!Object.keys(checked).length) continue;
26
+ if (name !== "config.local.json") { ignored.push(`instances in .agenthub/${name} ignored: instances are machine-local config.local.json declarations`); continue; }
27
+ const refusal = configRefusal(cwd, name);
28
+ if (refusal) ignored.push(`instances in .agenthub/${name} ignored: ${refusal}`);
29
+ else instances = checked;
30
+ }
31
+ return { ...(instances ? { instances } : {}), ignored };
32
+ }
33
+
34
+ /** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
35
+ export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; roles: Record<string, string[]>; ignored: string[] } {
36
+ const declarationConfig = readInstanceConfig(cwd, layers);
37
+ const roles = defaultPeerRoles(false);
38
+ for (const name of INSTANCE_CONFIG_FILES) {
39
+ const raw = layers.find(layer => layer.name === name)?.raw;
40
+ if (raw?.roles !== undefined) {
41
+ conductorPeer(raw.roles); // Preserve the ordinary loader's ids, role-list and conductor validation exactly.
42
+ for (const [id, list] of Object.entries(raw.roles as Record<string, string[]>)) roles[id] = [...list];
43
+ }
44
+ }
45
+ conductorPeer(roles);
46
+ return { ...declarationConfig, roles };
47
+ }
@@ -0,0 +1,50 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { definitionOf } from "../adapters/registry.ts";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { realPath } from "./project.ts";
6
+ import { validateInstances, type InstancesConfig } from "./instances.ts";
7
+
8
+ const refuse = (reason: string): never => { throw new Error(`instance execution workspace refused: ${reason}`); };
9
+ const GIT_TIMEOUT_MS = 5000, GIT_OUTPUT_BYTES = 1024 * 1024;
10
+ function git(cwd: string, args: string[]): string {
11
+ const result = hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_BYTES });
12
+ if (result.error || result.status !== 0) return refuse("Git workspace evidence unavailable");
13
+ return result.stdout;
14
+ }
15
+ const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
16
+
17
+ /** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
18
+ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[]): string {
19
+ if (definitionOf(id)) return projectRoot;
20
+ const declaration = validateInstances(declarations)[id];
21
+ if (!declaration) return refuse("extra instance is not declared");
22
+ const implementer = roles.includes("implementer");
23
+ if (!declaration.worktree) {
24
+ if (implementer) return refuse("an extra implementer requires an explicit managed worktree");
25
+ return projectRoot;
26
+ }
27
+ let root: string, cwd: string;
28
+ try {
29
+ root = realPath(projectRoot);
30
+ cwd = realPath(resolve(root, declaration.worktree));
31
+ if (!lstatSync(root).isDirectory() || !lstatSync(cwd).isDirectory()) return refuse("workspace is not a directory");
32
+ } catch { return refuse("workspace paths are unavailable"); }
33
+ const expected = join(root, ".worktrees", id);
34
+ // Compare a real path with the exact canonical project child, so a symlink cannot move this boundary elsewhere.
35
+ if (cwd !== expected) return refuse("worktree must be the exact managed instance directory");
36
+ const inventory = git(root, ["worktree", "list", "--porcelain", "-z"]);
37
+ const records = inventory.split("\0\0").filter(Boolean).map(record => record.split("\0").filter(Boolean));
38
+ const matches = records.filter(record => {
39
+ const path = record.find(field => field.startsWith("worktree "))?.slice("worktree ".length);
40
+ if (!path) return false;
41
+ try { return realPath(path) === cwd; } catch { return false; }
42
+ });
43
+ if (matches.length !== 1 || matches[0]!.some(field => field === "prunable" || field.startsWith("prunable "))) return refuse("directory is not an available registered project worktree");
44
+ if (commonDirectory(root) !== commonDirectory(cwd)) return refuse("worktree belongs to a different Git repository");
45
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]).replace(/\n$/, "");
46
+ if (realPath(top) !== cwd) return refuse("directory is not the registered worktree root");
47
+ if (implementer && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse("an implementer worktree requires an attached branch");
48
+ // ponytail: this read cannot bind the branch to a task or make path swaps atomic; task/start authority must recheck before effects.
49
+ return cwd;
50
+ }
@@ -0,0 +1,100 @@
1
+ import { types } from "node:util";
2
+ import { ADAPTERS, defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm } from "../adapters/registry.ts";
3
+ import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
4
+
5
+ export interface InstanceConfig {
6
+ kind: string;
7
+ roles?: string[];
8
+ start_mode?: StartForm;
9
+ permission_mode?: PermissionMode;
10
+ worktree?: string;
11
+ }
12
+ export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
13
+ export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
14
+ const MAX_INSTANCES = 64;
15
+ const FLOW_ROLES = new Set(["planner", "implementer", "verifier", "reviewer", "conductor"]);
16
+ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
17
+ const RESERVED = new Set(["user", "hub", "digest"]);
18
+ const fail = (field: string, reason: string): never => { throw new Error(`${field}: ${reason}`); };
19
+
20
+ /** Inspect only ordinary own data descriptors; accessors, proxies, symbols and custom prototypes are not config. */
21
+ function fields(value: unknown, field: string): Record<string, PropertyDescriptor> {
22
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
23
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) return fail(field, "must be a plain own-data object");
24
+ const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
25
+ if (Object.values(descriptors).some(entry => !Object.hasOwn(entry, "value") || !entry.enumerable)) return fail(field, "must contain enumerable data fields only");
26
+ return descriptors;
27
+ }
28
+ function roles(value: unknown, field: string): string[] {
29
+ if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length) return fail(field, "must be a role array");
30
+ const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
31
+ const length = descriptors.length?.value;
32
+ if (!Number.isSafeInteger(length) || length > FLOW_ROLES.size || Object.keys(descriptors).length !== length + 1) return fail(field, "invalid role array");
33
+ const out: string[] = [];
34
+ for (let index = 0; index < length; index++) {
35
+ const descriptor = descriptors[String(index)];
36
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || !descriptor.enumerable || typeof descriptor.value !== "string" || !FLOW_ROLES.has(descriptor.value) || out.includes(descriptor.value)) return fail(field, "roles must be known and unique");
37
+ out.push(descriptor.value);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Pure I2 declaration validation. Physical worktree and startup authority checks belong to the bound host. */
43
+ export function validateInstances(raw: unknown): InstancesConfig {
44
+ const result: Record<string, InstanceConfig> = Object.create(null);
45
+ if (raw === undefined) return Object.freeze(result);
46
+ const declarations = fields(raw, "instances");
47
+ if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
48
+ for (const [id, descriptor] of Object.entries(declarations)) {
49
+ if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
50
+ const path = `instances.${id}`, entry = fields(descriptor.value, path);
51
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree"].includes(key))) return fail(path, "unsupported field");
52
+ const kind: unknown = entry.kind?.value;
53
+ if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
54
+ const definition = definitionOf(kind), prefix = `${kind}-`;
55
+ if (!definition) return fail(`${path}.kind`, "unknown native kind");
56
+ if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
57
+ if (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp)) return fail(`${path}.kind`, "unsupported in I2; a headless factory with env or ACP-session identity is required, not channel-only or project-file identity");
58
+ const value: InstanceConfig = { kind };
59
+ if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
60
+ if (entry.start_mode) {
61
+ if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
62
+ value.start_mode = "headless";
63
+ }
64
+ if (entry.permission_mode) {
65
+ if (!isPermissionMode(entry.permission_mode.value)) return fail(`${path}.permission_mode`, "must be ask, ask-when-needed or never-ask");
66
+ value.permission_mode = entry.permission_mode.value;
67
+ }
68
+ if (entry.worktree) {
69
+ const worktree: unknown = entry.worktree.value;
70
+ if (typeof worktree !== "string" || !worktree.trim() || Buffer.byteLength(worktree) > 4096 || /\p{Cc}/u.test(worktree)) return fail(`${path}.worktree`, "must be a nonempty control-free path of at most 4096 UTF-8 bytes");
71
+ value.worktree = worktree;
72
+ }
73
+ result[id] = Object.freeze(value);
74
+ }
75
+ return Object.freeze(result);
76
+ }
77
+
78
+ /** Each call derives a fresh project-local projection, without registration or suffix discovery. */
79
+ export function instanceCatalog(declarations?: InstancesConfig): readonly InstanceCatalogEntry[] {
80
+ const instances = validateInstances(declarations);
81
+ return Object.freeze([
82
+ ...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
83
+ ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(config.kind)!, config })),
84
+ ]);
85
+ }
86
+ export function instancePeerIds(declarations?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
87
+ export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
88
+ const result = defaultPeerRoles(includeOptional);
89
+ for (const [id, config] of Object.entries(validateInstances(declarations))) result[id] = [...(config.roles ?? definitionOf(config.kind)!.roles)];
90
+ return result;
91
+ }
92
+
93
+ /** One precedence rule for startup, native launch and recovery; ordinary roles may use the instance id or its kind. */
94
+ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>): string[] {
95
+ const definition = definitionOf(id, declarations);
96
+ if (!definition) throw new Error(`${id}: no declared native instance`);
97
+ const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
98
+ const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
99
+ return [...(declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles)];
100
+ }
@@ -1,17 +1,21 @@
1
1
  import { ControlClient } from "./control-client.ts";
2
2
  import { SDLC_STAGES } from "./sdlc.ts";
3
3
  /** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
4
- export async function activeLifecycleStages(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<string[] | undefined> {
4
+ /** The active stage ids and whether the lifecycle is enabled (`criteria`, #481 S8); an older daemon never says so. */
5
+ export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<{ stages: string[]; criteria: boolean } | undefined> {
5
6
  let client: ControlClient | undefined;
6
7
  try {
7
8
  client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
8
9
  const reply = await client.request({ t: "status" }, 1000);
9
10
  if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
10
11
  // A live older daemon has the fixed seven-stage contract and no optional metadata field.
11
- if (!Object.hasOwn(reply.status, "lifecycleStages")) return [...SDLC_STAGES];
12
+ if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false };
12
13
  const stages: unknown = reply.status.lifecycleStages;
13
14
  if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
14
- return [...stages] as string[];
15
+ return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true };
15
16
  } catch { return undefined; }
16
17
  finally { if (client !== active) client?.close(); }
17
18
  }
19
+ export async function activeLifecycleStages(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<string[] | undefined> {
20
+ return (await activeLifecycle(stateDir, peer, projectRoot, active))?.stages;
21
+ }
@@ -1,4 +1,5 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
2
+ export { SUPPORTED_PEERS } from "../adapters/registry.ts";
2
3
 
3
4
  // ponytail: older staged coordinators read these literal declarations to probe target support.
4
5
  // A2 replaces that source probe with a published capability before deleting the compatibility exports.
@@ -6,7 +7,6 @@ import { definitionOf } from "../adapters/registry.ts";
6
7
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
7
8
  /** Optional production peers are added explicitly; legacy defaults remain stable. */
8
9
  export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
9
- export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
10
10
  export type KnownPeer = typeof SUPPORTED_PEERS[number];
11
11
 
12
12
  export const isKnownPeer = (value: unknown): value is KnownPeer =>
@@ -15,14 +15,5 @@ export const isKnownPeer = (value: unknown): value is KnownPeer =>
15
15
  export const isSupportedPeer = (value: unknown): value is KnownPeer =>
16
16
  typeof value === "string" && !!definitionOf(value);
17
17
 
18
- // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
19
- const ROLE_ORDER: readonly KnownPeer[] = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"];
20
-
21
- /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
22
- export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
23
- return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
24
- .map(peer => [peer, [...definitionOf(peer)!.roles]]));
25
- }
26
-
27
- /** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
28
- export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
18
+ // Policy and historical projection order belong to the registry; public compatibility exports remain intact.
19
+ export { defaultPeerRoles, PLANNER_PEERS } from "../adapters/registry.ts";
@@ -1,5 +1,4 @@
1
- import { isSupportedPeer } from "./peer-catalog.ts";
2
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
3
2
  import { lstatSync } from "node:fs";
4
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
5
4
 
@@ -9,39 +8,37 @@ export type PermissionMode = typeof PERMISSION_MODES[number];
9
8
  export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
10
9
  export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
11
10
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
12
- export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
11
+ export function permissionDefaults(value: unknown, instances?: KindMap): Record<string, PermissionMode> {
13
12
  if (value === undefined) return {};
14
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
15
14
  const result: Record<string, PermissionMode> = {};
16
15
  for (const [peer, mode] of Object.entries(value)) {
17
- if (!isSupportedPeer(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
18
- if (peer === "jcode") throw new Error("Jcode native policy is unmanaged; no hub permission_modes.jcode mapping exists");
16
+ const definition = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
17
+ if (!definition) throw new Error(`permission_modes.${peer}: unsupported peer`);
18
+ if (definition.permission.unmanaged) throw new Error(`${definition.displayName} native policy is unmanaged; no hub permission_modes.${peer} mapping exists`);
19
19
  if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
20
20
  result[peer] = mode;
21
21
  }
22
22
  return result;
23
23
  }
24
24
  /** Approval frequency does not create a common sandbox across native peers. */
25
- export function permissionBoundary(peer: string): string {
26
- if (peer === "jcode") return "jcode: native policy unmanaged; NO hub sandbox; approvals belong to its native tools";
27
- if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
28
- if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
25
+ export function permissionBoundary(peer: string, instances?: KindMap): string {
26
+ const definition = definitionOf(peer, instances);
27
+ if (definition?.permission.unmanaged) return `${peer}: native policy unmanaged; NO hub sandbox; approvals belong to its native tools`;
28
+ if (definition?.trust.hubSandboxed) return `${peer}: inside hub sandbox, path guard and denylist`;
29
+ if (definition?.transport === "claude-channel" || definition?.transport === "codex-appserver") return `${peer}: native vendor bounds; no hub sandbox`;
29
30
  return `${peer}: runs its own tools; NO hub sandbox`;
30
31
  }
31
32
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
32
- export function permissionGrant(peer: string, mode: PermissionMode): string {
33
- const grant = definitionOf(peer)?.permission.grants?.[mode];
33
+ export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap): string {
34
+ const grant = definitionOf(peer, instances)?.permission.grants?.[mode];
34
35
  if (grant !== undefined) return grant;
35
- if (peer === "jcode") return "no verified hub mode mapping; native policy only";
36
- if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
37
- if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
38
- if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
39
- if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
36
+ if (mode === "ask") return "every write, edit and shell command asks";
40
37
  return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
41
38
  }
42
39
  export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
43
40
  /** Native agent policy files are never included in scoped automatic file grants. */
44
- export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".claude.json"]);
41
+ export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".agent", "_agents", "_agent", ".gemini", ".grok", ".claude.json"]);
45
42
  export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
46
43
  export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
47
44
  export function instructionProtection(value: unknown): boolean {
@@ -1,3 +1,4 @@
1
+ import { ADAPTERS } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
@@ -37,12 +38,12 @@ function canonicalRoot(value: unknown): string {
37
38
  return root;
38
39
  }
39
40
  function checked(value: unknown): RecoveryContinuityBookmark {
40
- if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", "pi"]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
41
- const pi = value.pi;
42
- if (!record(pi) || !keys(pi, ["sessionId", "sessionFile", "backend", "model"]) || !id(pi.sessionId) || pi.sessionFile !== undefined && !absolute(pi.sessionFile) || pi.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(pi.backend)) || pi.model !== undefined && !text(pi.model, 4096)) return unavailable();
41
+ const codecs = ADAPTERS.flatMap(adapter => adapter.session.continuity ? [adapter.session.continuity] : []);
42
+ if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", ...codecs.map(codec => codec.key)]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
43
+ const native: Record<string, unknown> = {};
44
+ for (const codec of codecs) { const parsed = codec.parse(value[codec.key]); if (parsed === undefined) return unavailable(); native[codec.key] = parsed; }
43
45
  const projectRoot = canonicalRoot(value.projectRoot);
44
- return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
45
- pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
46
+ return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number, ...native } as RecoveryContinuityBookmark;
46
47
  }
47
48
  function stateDirectory(stateDir: string): void {
48
49
  if (!absolute(stateDir)) return unavailable();
package/src/hub/report.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import type { StampedEvent } from "./events.ts";
2
3
 
3
4
  export interface Report {
@@ -103,7 +104,7 @@ export function summarize(events: StampedEvent[]): Report {
103
104
  const r: Report = { peers: {}, conduct: {}, supervision: {}, routes: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
104
105
  const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
105
106
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
106
- const claudeNativeStops = events.some(event => event.type === "native_turn_end" && event.peer === "claude" && !!event.id);
107
+ const nativeStops = events.some(event => event.type === "native_turn_end" && definitionOf(event.peer)?.turnEnd === "native-hook" && !!event.id);
107
108
  const seenNativeStops = new Set<string>();
108
109
  const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
109
110
  const pairs = new Set<string>();
@@ -147,11 +148,11 @@ export function summarize(events: StampedEvent[]): Report {
147
148
  r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
148
149
  break;
149
150
  case "turn_end":
150
- if (e.peer !== "claude" || !claudeNativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
151
+ if (definitionOf(e.peer)?.turnEnd !== "native-hook" || !nativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
151
152
  peer(e.peer).busyMinutes += e.ms / 60_000;
152
153
  break;
153
154
  case "native_turn_end":
154
- if (e.peer === "claude" && e.id) {
155
+ if (definitionOf(e.peer)?.turnEnd === "native-hook" && e.id) {
155
156
  if (seenNativeStops.has(e.id)) break;
156
157
  seenNativeStops.add(e.id);
157
158
  peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
@@ -211,10 +212,10 @@ export function summarize(events: StampedEvent[]): Report {
211
212
  // Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
212
213
  r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
213
214
  for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
214
- if (r.peers.claude) {
215
- const logical = events.some(event => event.type === "turn_end" && event.peer === "claude");
216
- r.peers.claude.turnSource = claudeNativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
217
- if (!claudeNativeStops && !logical) r.peers.claude.turns = null;
215
+ for (const [id, summary] of Object.entries(r.peers)) if (definitionOf(id)?.turnEnd === "native-hook") {
216
+ const logical = events.some(event => event.type === "turn_end" && event.peer === id);
217
+ summary.turnSource = nativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
218
+ if (!nativeStops && !logical) summary.turns = null;
218
219
  }
219
220
  return r;
220
221
  }
@@ -223,7 +224,7 @@ export function formatReport(r: Report): string[] {
223
224
  const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
224
225
  for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) {
225
226
  const turns = p.turns === null ? "turns unknown" : `${p.turns} turn${p.turns === 1 ? "" : "s"}${p.turnSource === "native-stop" ? " (native Stop)" : p.turnSource === "logical-state" ? " (logical state, native completion unobserved)" : ""}`;
226
- lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
227
+ lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : definitionOf(id)?.reporting?.tokensNotReported ? "tokens not reported" : "- tokens"}`);
227
228
  }
228
229
  if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
229
230
  for (const [id, s] of Object.entries(r.supervision).sort(([a], [b]) => a.localeCompare(b))) {
package/src/hub/roster.ts CHANGED
@@ -1,15 +1,14 @@
1
- import { isSupportedPeer, type KnownPeer } from "./peer-catalog.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
+ import { defaultRosterEnabled } from "../adapters/registry.ts";
2
3
 
3
- export type PeerRoster = Partial<Record<KnownPeer, boolean>>;
4
- // Default participation policy, not another catalog. New catalog members default off.
5
- const DEFAULT_ON: Partial<Record<KnownPeer, boolean>> = { claude: true, codex: true, kimi: true, local: true };
4
+ export type PeerRoster = Partial<Record<string, boolean>>;
6
5
 
7
- export function peerRosterConfig(value: unknown): PeerRoster {
6
+ export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoster {
8
7
  if (value === undefined) return {};
9
8
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
10
9
  const roster: PeerRoster = {};
11
10
  for (const [peer, on] of Object.entries(value)) {
12
- if (!isSupportedPeer(peer)) throw new Error("roster contains an unsupported peer");
11
+ if (!definitionOf(peer, instances)) throw new Error("roster contains an unsupported peer");
13
12
  if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
14
13
  roster[peer] = on;
15
14
  }
@@ -17,7 +16,8 @@ export function peerRosterConfig(value: unknown): PeerRoster {
17
16
  }
18
17
 
19
18
  /** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
20
- export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean): boolean {
21
- if (!isSupportedPeer(peer)) return true;
22
- return roster[peer] ?? (peer === "pi" ? piEnabled : DEFAULT_ON[peer] ?? false);
19
+ export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances?: KindMap): boolean {
20
+ const definition = definitionOf(peer, instances);
21
+ if (!definition) return true; // External clients do not acquire a native kind from a project declaration.
22
+ return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
23
23
  }