@staix/agent-hub 0.12.44 → 0.13.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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
@@ -20,7 +20,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
20
20
  const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
21
21
  const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
22
22
  for (const p of op.projects) {
23
- const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
23
+ const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value, { authority: "historical" })}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
24
24
  lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
25
25
  }
26
26
  // The error ends with the commands status would print; the menu below offers them instead.
@@ -76,7 +76,7 @@ export function renderOperation(op: RecoveryOperation, runner: number | "unknown
76
76
  if (key.startsWith("restored:")) {
77
77
  let outcome = "recorded outcome unavailable";
78
78
  try {
79
- const state = restoreReceiptStatus(value);
79
+ const state = restoreReceiptStatus(value, { authority: "historical" }); // display only: a revoked declaration must not hide the receipt (#225)
80
80
  outcome = state === "done" ? "session restored" : state === "failed" ? "session restoration needs attention" : state.startsWith("waiting:") ? "waiting for the recorded session to attach" : "session restoration is pending";
81
81
  } catch { /* Malformed historical metadata is unknown, never successful. */ }
82
82
  output.push(...lines(` ${key.slice("restored:".length)}: ${outcome}`, "muted", options));
@@ -66,8 +66,9 @@ export function decodeRestoreReceipt(value: unknown, options: { authority?: "cur
66
66
  return { state: "done", binding: terminalBinding(value, historical) };
67
67
  }
68
68
 
69
- export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
70
- const receipt = decodeRestoreReceipt(value);
69
+ /** The authority is the caller's: a decision reads the current one, a screen that only shows what was recorded reads the historical one (#225). */
70
+ export function restoreReceiptStatus(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceiptStatus {
71
+ const receipt = decodeRestoreReceipt(value, options);
71
72
  return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
72
73
  }
73
74
  export function canChooseFresh(value: unknown): boolean {
@@ -145,6 +145,7 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
145
145
  /** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
146
146
  export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
147
147
  const bound = readInstanceConfig(projectRoot);
148
+ if (bound.adapter_modules?.length && !(Array.isArray(features) && features.includes("external_adapter_modules"))) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
148
149
  const configured = Object.keys(bound.adapters ?? {});
149
150
  const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
150
151
  if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
@@ -159,7 +160,7 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
159
160
  if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
160
161
  !peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
161
162
  }
162
- return "source lacks peer_instances support for this project's declared instances; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end any extra native sessions and temporarily remove the instance declarations, upgrade the source, then declare them again; legacy peer identities were not reinterpreted";
163
+ return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
163
164
  }
164
165
 
165
166
  export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
@@ -626,6 +627,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
626
627
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
627
628
  }
628
629
  if (cached && op.targetFeatures !== undefined) {
630
+ if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
629
631
  if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
630
632
  if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
631
633
  return { ...target, targetFeatures: [...op.targetFeatures] };
@@ -638,6 +640,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
638
640
  if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
639
641
  if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
640
642
  const targetFeatures: readonly string[] = [...metadata.features];
643
+ if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
641
644
  if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
642
645
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
643
646
  if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
@@ -0,0 +1,362 @@
1
+ import { basename, isAbsolute, relative, resolve } from "node:path";
2
+
3
+ export const ACTION_CLASSES = ["publish", "external-write", "destroy", "install"] as const;
4
+ export type ActionClass = typeof ACTION_CLASSES[number];
5
+
6
+ const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
7
+ const LIMIT = 16_384;
8
+ type Command = { words: string[]; outputs: string[] };
9
+
10
+ /** Bound the whole input before accepting any prefix, including unsupported suffixes. */
11
+ function segmentLimit(text: string): boolean {
12
+ let quote = "", count = 0;
13
+ for (let i = 0; i < text.length; i++) {
14
+ const c = text[i]!;
15
+ if (c === "\\" && quote !== "'") { i++; continue; }
16
+ if (quote) { if (quote === c) quote = ""; continue; }
17
+ if (c === "'" || c === '"') { quote = c; continue; }
18
+ if (c === "#" && (i === 0 || /[ \t\r\n;]/.test(text[i - 1]!))) {
19
+ while (i < text.length && text[i] !== "\n") i++;
20
+ }
21
+ if (i < text.length && ";|&\n".includes(text[i]!)) {
22
+ // FD duplication is an operator, not another command.
23
+ if (text[i] === "&" && [">", "<"].includes(text[i - 1] ?? "")) continue;
24
+ if (++count > 64) return false;
25
+ if ((c === "|" || c === "&") && text[i + 1] === c) i++;
26
+ }
27
+ }
28
+ return true;
29
+ }
30
+
31
+ /** Literal shell words only; unsupported syntax discards only its unfinished segment. */
32
+ function commands(text: string): Command[] | undefined {
33
+ if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
34
+ const result: Command[] = [];
35
+ let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
36
+ let redirect: false | "overwrite" | "skip" = false;
37
+ const flush = () => {
38
+ if (!active) return;
39
+ if (redirect === "overwrite") outputs.push(word);
40
+ else if (!redirect) words.push(word);
41
+ word = ""; active = false; redirect = false;
42
+ };
43
+ const finish = () => { flush(); if (words.length || outputs.length) result.push({ words, outputs }); words = []; outputs = []; };
44
+ for (let i = 0; i < text.length; i++) {
45
+ const c = text[i]!;
46
+ if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
47
+ if (c === "\\") {
48
+ const next = text[++i];
49
+ if (next === undefined) return result;
50
+ if (quote === '"' && !['$', '`', '"', "\\", "\n"].includes(next)) word += "\\";
51
+ if (next !== "\n") word += next;
52
+ active = true; continue;
53
+ }
54
+ if (c === "$" || c === "`") return result;
55
+ if (quote) { if (c === quote) quote = ""; else word += c; continue; }
56
+ if (c === "'" || c === '"') { quote = c; active = true; continue; }
57
+ if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
58
+ if ("(){}".includes(c)) {
59
+ if (redirect && !active) return result;
60
+ finish(); continue;
61
+ }
62
+ if (c === ">" || c === "<") {
63
+ if (redirect && !active) return result;
64
+ if (redirect) flush();
65
+ if (c === "<" && text[i + 1] === "<") return result;
66
+ if (active && /^\d+$/.test(word)) { word = ""; active = false; } else flush();
67
+ if (text[i + 1] === "&") {
68
+ const duplicate = text.slice(i + 2).match(/^(?:\d+|-)(?=$|[ \t\r\n;|&(){}<>])/);
69
+ if (!duplicate) return result;
70
+ i += 1 + duplicate[0].length; continue;
71
+ }
72
+ redirect = c === "<" || text[i + 1] === ">" ? "skip" : "overwrite";
73
+ if (text[i + 1] === ">" || text[i + 1] === "|") i++;
74
+ continue;
75
+ }
76
+ if (/[ \t\r]/.test(c)) { flush(); continue; }
77
+ if (";|&\n".includes(c)) {
78
+ if (redirect && !active) return result;
79
+ finish();
80
+ if (text[i + 1] === c && (c === "|" || c === "&")) i++;
81
+ continue;
82
+ }
83
+ word += c; active = true;
84
+ if (words.length + outputs.length > 256) return result;
85
+ }
86
+ if (quote || (redirect && !active)) return result;
87
+ finish();
88
+ return result;
89
+ }
90
+
91
+ function object(value: unknown): Record<string, unknown> | undefined {
92
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
93
+ }
94
+ function outside(target: string, root: string, cwd: string): boolean {
95
+ if (!isAbsolute(root) || !target || /[$`]/.test(target)) return false;
96
+ if (target.startsWith("~") || !cwd && !isAbsolute(target)) return true;
97
+ const rel = relative(resolve(root), resolve(cwd, target));
98
+ return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
99
+ }
100
+ function flags(words: string[], letter: string): boolean {
101
+ const end = words.indexOf("--");
102
+ return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
103
+ }
104
+ function operands(words: string[]): string[] {
105
+ const end = words.indexOf("--");
106
+ return end < 0 ? words.filter(w => !w.startsWith("-")) : [...words.slice(0, end).filter(w => !w.startsWith("-")), ...words.slice(end + 1)];
107
+ }
108
+ function option(words: string[], short: string, long: string): string | undefined {
109
+ for (let i = 0; i < words.length; i++) {
110
+ const w = words[i]!;
111
+ if (w === "--") break;
112
+ if (w === short || w === long) return words[i + 1];
113
+ if (w.startsWith(`${long}=`)) return w.slice(long.length + 1);
114
+ if (w.startsWith(short) && w.length > short.length) return w.slice(short.length);
115
+ }
116
+ return;
117
+ }
118
+ /** Known option values are opaque. Unknown prefix options retain every plausible action head. */
119
+ function commandHeads(words: string[], values: readonly string[], booleans: readonly string[], actions: readonly string[]): Array<{ name: string; args: string[] }> {
120
+ const heads: Array<{ name: string; args: string[] }> = [];
121
+ let uncertain = false, literal = false;
122
+ for (let i = 0; i < words.length; i++) {
123
+ const word = words[i]!;
124
+ if (!literal && word === "--") { literal = true; continue; }
125
+ if (!literal && word.startsWith("-")) {
126
+ if (!uncertain && ["--help", "--version", "-h", "-V"].includes(word)) return [];
127
+ if (values.includes(word)) { if (!uncertain) i++; continue; }
128
+ if (values.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : word.startsWith(key) && word.length > key.length)) continue;
129
+ if (!booleans.includes(word)) uncertain = true;
130
+ continue;
131
+ }
132
+ if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
133
+ if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
134
+ }
135
+ return heads;
136
+ }
137
+
138
+ /** Exclude patterns and path operands cannot impersonate clean's force/dry-run options. */
139
+ function forcedClean(words: string[]): boolean {
140
+ let force = false, dry = false;
141
+ for (let i = 0; i < words.length; i++) {
142
+ const word = words[i]!;
143
+ if (word === "--") break;
144
+ if (word === "--exclude") { i++; continue; }
145
+ if (word.startsWith("--exclude=")) continue;
146
+ if (word === "--force") force = true;
147
+ else if (word === "--dry-run") dry = true;
148
+ else if (/^-[^-]/.test(word)) {
149
+ for (let at = 1; at < word.length; at++) {
150
+ const flag = word[at]!;
151
+ if (flag === "e") { if (at === word.length - 1) i++; break; }
152
+ if (flag === "f") force = true;
153
+ if (flag === "n") dry = true;
154
+ }
155
+ }
156
+ }
157
+ return force && !dry;
158
+ }
159
+
160
+ /** Peel only known transparent argv wrappers; no aliases or executable discovery. */
161
+ function unwrapped(input: string[]): string[] {
162
+ const words = [...input];
163
+ for (let step = 0; step < 32; step++) {
164
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? "")) words.shift();
165
+ const name = basename(words[0] ?? "");
166
+ if (["!", "if", "then", "elif", "else"].includes(name)) { words.shift(); continue; }
167
+ if (["fi", "do", "done"].includes(name)) return [];
168
+ if (!["sudo", "time", "timeout", "nohup", "exec", "xargs", "nice", "env", "command"].includes(name)) return words;
169
+ words.shift();
170
+ const values: Record<string, string[]> = {
171
+ sudo: ["-u", "-g", "-h", "-p", "-C", "-T", "--user", "--group", "--host", "--prompt"],
172
+ time: ["-f", "-o", "--format", "--output"],
173
+ timeout: ["-s", "-k", "--signal", "--kill-after"],
174
+ exec: ["-a"], xargs: ["-n", "-P", "-I", "-L", "-s", "-E", "-d", "--max-args", "--max-procs", "--replace", "--delimiter"],
175
+ nice: ["-n", "--adjustment"], env: ["-u", "--unset"],
176
+ };
177
+ while (words[0]?.startsWith("-")) {
178
+ const flag = words.shift()!;
179
+ if (flag === "--") break;
180
+ if (name === "command" && (flag === "-v" || flag === "-V")) return [];
181
+ if ((values[name] ?? []).includes(flag)) { if (!words.length) return []; words.shift(); }
182
+ else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
183
+ }
184
+ if (name === "timeout") {
185
+ if (!/^\d+(?:\.\d+)?[smhd]?$/.test(words.shift() ?? "")) return [];
186
+ }
187
+ }
188
+ return [];
189
+ }
190
+
191
+ /** Scan curl options once: data, headers and other option values never become options. */
192
+ function curlWrites(input: string[]): boolean {
193
+ const longValues = new Set([
194
+ "request", "data", "data-ascii", "data-binary", "data-raw", "data-urlencode", "json", "form", "form-string", "upload-file",
195
+ "url-query", "header", "proxy-header", "output", "output-dir", "user-agent", "user", "proxy-user", "cookie", "cookie-jar", "referer", "url",
196
+ "resolve", "connect-to", "config", "cacert", "capath", "cert", "cert-type", "key", "key-type", "pass", "proxy", "preproxy",
197
+ "interface", "max-time", "connect-timeout", "limit-rate", "range", "request-target", "unix-socket", "abstract-unix-socket",
198
+ "write-out", "trace", "trace-ascii", "dump-header", "stderr", "netrc-file", "ftp-method", "ftp-port", "oauth2-bearer",
199
+ "proxy-cacert", "proxy-cert", "proxy-key", "socks5", "socks5-hostname", "retry", "retry-delay", "retry-max-time", "local-port",
200
+ ]);
201
+ let method: string | undefined, data = false, upload = false, get = false, head = false, uncertain = false, writeMethod = false;
202
+ const booleanOptions = new Set(["get", "head", "compressed", "location", "insecure", "silent", "show-error", "fail", "fail-with-body", "globoff", "verbose", "include", "remote-name", "path-as-is", "create-dirs", "progress-meter", "progress-bar", "http1.1", "http2", "http3"]);
203
+ // An unknown option may consume a following read-looking token. It cannot erase known write evidence.
204
+ const writes = () => uncertain && (data || upload || writeMethod) || (method !== undefined ? !["GET", "HEAD"].includes(method.toUpperCase()) : !head && (upload || data && !get));
205
+ for (let i = 0; i < input.length; i++) {
206
+ const arg = input[i]!;
207
+ if (arg === "--") break;
208
+ if (arg === "--next" || arg === "-:") {
209
+ if (writes()) return true;
210
+ method = undefined; data = upload = get = head = uncertain = writeMethod = false;
211
+ continue;
212
+ }
213
+ if (arg.startsWith("--")) {
214
+ const equal = arg.indexOf("=");
215
+ const name = arg.slice(2, equal < 0 ? undefined : equal);
216
+ if (name === "get" || name === "no-get") get = name === "get";
217
+ else if (name === "head" || name === "no-head") head = name === "head";
218
+ if (!longValues.has(name)) { if (!booleanOptions.has(name.replace(/^no-/, ""))) uncertain = true; continue; }
219
+ const value = equal < 0 ? input[++i] : arg.slice(equal + 1);
220
+ if (value === undefined) continue;
221
+ if (name === "request") { method = value; writeMethod ||= !["GET", "HEAD"].includes(value.toUpperCase()); }
222
+ else if (name === "upload-file") upload = true;
223
+ else if (name === "json" || name.startsWith("data") || name === "form" || name === "form-string") data = true;
224
+ continue;
225
+ }
226
+ if (!/^-[^-]/.test(arg)) continue;
227
+ for (let at = 1; at < arg.length; at++) {
228
+ const flag = arg[at]!;
229
+ if (flag === "G") get = true;
230
+ if (flag === "I") head = true;
231
+ // These short options consume the rest of this word, or the next whole word.
232
+ if (!"XdFTHoAuUbecrxEKPmCwDQztYy".includes(flag)) continue;
233
+ const value = arg.slice(at + 1) || input[++i];
234
+ if (value !== undefined) {
235
+ if (flag === "X") { method = value; writeMethod ||= !["GET", "HEAD"].includes(value.toUpperCase()); }
236
+ else if (flag === "d" || flag === "F") data = true;
237
+ else if (flag === "T") upload = true;
238
+ }
239
+ break;
240
+ }
241
+ }
242
+ return writes();
243
+ }
244
+
245
+ function sequence(parsed: Command[], root: string, initial: string[], found: Set<ActionClass>, depth = 0): void {
246
+ const cwd = new Set(initial);
247
+ for (const command of parsed) {
248
+ classify(command, root, [...cwd], found, depth);
249
+ const [program, ...args] = unwrapped(command.words);
250
+ const target = args[0] === "--" ? args[1] : args[0];
251
+ // Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
252
+ if (program === "cd" && (!target || target.startsWith("~"))) {
253
+ if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
254
+ cwd.add("");
255
+ }
256
+ else if (program === "cd" && target && !target.startsWith("-") && !"$`*?[]".split("").some(c => target.includes(c))) {
257
+ for (const current of [...cwd]) {
258
+ if (cwd.size >= 128) break;
259
+ cwd.add(current || isAbsolute(target) ? resolve(current, target) : "");
260
+ }
261
+ }
262
+ }
263
+ }
264
+ function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
265
+ const words = unwrapped(command.words);
266
+ const program = basename(words.shift() ?? "");
267
+ if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
268
+ const shell = [...words];
269
+ while (["--noprofile", "--norc"].includes(shell[0] ?? "")) shell.shift();
270
+ const mode = shell.shift();
271
+ if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
272
+ if (shell[0] === "--") shell.shift();
273
+ const nested = shell[0] === undefined ? undefined : commands(shell[0]);
274
+ if (nested) sequence(nested, root, cwd, found, depth + 1);
275
+ }
276
+ }
277
+ if (command.outputs.some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
278
+ if (program === "git") {
279
+ const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
280
+ ["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
281
+ for (const { name: sub, args } of heads) {
282
+ if (sub === "push") found.add("publish");
283
+ if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
284
+ if (sub === "clean" && forcedClean(args)) found.add("destroy");
285
+ if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
286
+ }
287
+ }
288
+ if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
289
+ const valuesByProgram: Record<string, string[]> = {
290
+ npm: ["--registry", "--prefix", "-C", "--cache", "--userconfig", "--globalconfig", "--loglevel", "--workspace", "-w"],
291
+ pnpm: ["--registry", "--dir", "-C", "--filter", "-F", "--store-dir", "--reporter", "--loglevel"],
292
+ yarn: ["--registry", "--cwd", "--cache-folder", "--mutex", "--use-yarnrc", "--modules-folder", "--network-concurrency", "--network-timeout"],
293
+ cargo: ["--color", "--config", "--manifest-path", "--target-dir"],
294
+ bun: ["--registry", "--cwd", "-C", "--filter", "-F", "--env-file", "--config", "-c", "--cache-dir"],
295
+ };
296
+ const values = valuesByProgram[program]!;
297
+ const booleans = ["--global", "-g", "--offline", "--locked", "--frozen", "--silent", "--quiet", "-q", "--verbose", "-v"];
298
+ const actions = ["publish", "install", "i", "add", "global", "npm"];
299
+ const heads = commandHeads(words, values, booleans, actions);
300
+ for (const head of heads) {
301
+ const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
302
+ for (const { name: sub, args } of candidates) {
303
+ if (sub === "publish") found.add("publish");
304
+ const global = words.includes("--global") || flags(words, "g");
305
+ if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
306
+ }
307
+ }
308
+ }
309
+ if (program === "pipx" && words[0] === "install" || program === "brew" && ["install", "reinstall"].includes(words[0] ?? "") || ["pip", "pip3"].includes(program) && words[0] === "install" && words.includes("--user")) found.add("install");
310
+ if (program === "gh") {
311
+ if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
312
+ if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) found.add("external-write");
313
+ if (words[0] === "api") {
314
+ const method = option(words, "-X", "--method");
315
+ const field = words.some(w => w === "-f" || w === "-F" || w.startsWith("-f") || w.startsWith("-F") || w === "--input" || w.startsWith("--input=") || w.startsWith("--field") || w.startsWith("--raw-field"));
316
+ if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field) found.add("external-write");
317
+ }
318
+ }
319
+ if (program === "curl" && curlWrites(words)) found.add("external-write");
320
+ if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
321
+ if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
322
+ if (["slack", "telegram", "discord"].includes(program) && words.some(w => ["send", "post", "chat.postMessage"].includes(w))) found.add("external-write");
323
+ if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive")) && operands(words).some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
324
+ if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force"))) {
325
+ const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
326
+ if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
327
+ }
328
+ }
329
+
330
+ /** Adds information only; an empty result always retains the native permission policy.
331
+ * ponytail: literal bounded shell/argv forms only; aliases, functions, expansion,
332
+ * interpreter programs and symlink targets need a richer observed contract.
333
+ * Home targets are conservatively outside without resolving a personal home.
334
+ * Unsupported segments retain only preceding complete commands.
335
+ * Literal cd retains both success/failure cwd candidates (at most 128); it may
336
+ * overclassify a later relative write and is not a shell control-flow proof.
337
+ */
338
+ export function classifyAction(tool: string, args: unknown, executionRoot: string): ActionClass[] {
339
+ if (typeof tool !== "string" || tool.length > 128 || typeof executionRoot !== "string" || executionRoot.length > LIMIT) return [];
340
+ if (typeof args === "string") {
341
+ if (args.length > LIMIT) return [];
342
+ try { args = JSON.parse(args) as unknown; } catch { args = { command: args }; }
343
+ }
344
+ const input = object(args);
345
+ if (!input) return [];
346
+ const found = new Set<ActionClass>();
347
+ if (["http", "http_request", "fetch"].includes(tool.toLowerCase()) && typeof input.method === "string" && /^[A-Za-z]+$/.test(input.method) && !["GET", "HEAD"].includes(input.method.toUpperCase())) found.add("external-write");
348
+ const boundedArgv = (value: unknown): value is string[] => Array.isArray(value) && value.length <= 256 && value.every(w => typeof w === "string" && w.length <= LIMIT) && value.join("").length <= LIMIT;
349
+ const location = input.cwd ?? input.workdir;
350
+ const cwd = typeof location === "string" && location.startsWith("~") ? "" : typeof location === "string" && location.length <= LIMIT && !/[$`]/.test(location) ? resolve(executionRoot, location) : executionRoot;
351
+ if (tool.toLowerCase() === "git" && boundedArgv(input.args)) classify({ words: ["git", ...input.args], outputs: [] }, executionRoot, [cwd], found);
352
+ if (SHELL_TOOLS.has(tool.toLowerCase())) {
353
+ const raw = input.command ?? input.cmd;
354
+ if (typeof raw === "string") {
355
+ const parsed = commands(raw);
356
+ if (parsed) sequence(parsed, executionRoot, [cwd], found);
357
+ } else if (boundedArgv(raw)) {
358
+ classify({ words: raw, outputs: [] }, executionRoot, [cwd], found);
359
+ }
360
+ }
361
+ return ACTION_CLASSES.filter(value => found.has(value));
362
+ }
@@ -0,0 +1,106 @@
1
+ import { ACTION_CLASSES, type ActionClass } from "./action-class.ts";
2
+ import { sanitize } from "./envelope.ts";
3
+
4
+ export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[]; budgets: Record<ActionClass, number> }
5
+ const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
6
+ /** Defaults are host policy; overrides belong only in untracked config.local.json.
7
+ * ponytail: startup adopts that local policy without a retained person confirmation.
8
+ * Before adapter enforcement (#312/#313), bind weakened startup policy to person
9
+ * confirmation or surface it as attention; slice 1 never enforces these grants.
10
+ */
11
+ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
12
+ const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
13
+ if (value === undefined) return { enabled: true, always_ask: [...ACTION_CLASSES], budgets: { ...DEFAULT_BUDGETS } };
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
15
+ const raw = value as Record<string, unknown>;
16
+ if (Object.keys(raw).some(key => !["enabled", "always_ask", "budgets"].includes(key))) return fail();
17
+ if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") return fail();
18
+ const classes = raw.always_ask === undefined ? ACTION_CLASSES : raw.always_ask;
19
+ if (!Array.isArray(classes) || classes.length > ACTION_CLASSES.length || new Set(classes).size !== classes.length || classes.some(key => !ACTION_CLASSES.includes(key))) return fail();
20
+ const budgets = { ...DEFAULT_BUDGETS };
21
+ if (raw.budgets !== undefined) {
22
+ if (!raw.budgets || typeof raw.budgets !== "object" || Array.isArray(raw.budgets)) return fail();
23
+ for (const [key, amount] of Object.entries(raw.budgets)) {
24
+ if (!ACTION_CLASSES.includes(key as ActionClass) || typeof amount !== "number" || !Number.isSafeInteger(amount) || amount < 0 || amount > 1000) return fail();
25
+ budgets[key as ActionClass] = amount;
26
+ }
27
+ }
28
+ return { enabled: raw.enabled ?? true, always_ask: [...classes], budgets } as ActionPolicyConfig;
29
+ }
30
+
31
+ export interface ActionSessionBudget { readonly peer: string; readonly session: string }
32
+ export interface ActionChallenge { readonly classes: readonly ActionClass[] }
33
+ export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
34
+ interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number> }
35
+ const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
36
+ /** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
37
+ export class ActionBudgets {
38
+ private sessions = new Map<string, Session>();
39
+ private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
40
+ constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
41
+ bindSession(peer: string, session: string): ActionSessionBudget | undefined {
42
+ if (!session || this.currentSession(peer) !== session) return undefined;
43
+ const previous = this.sessions.get(peer);
44
+ if (previous?.handle.session === session) return previous.handle;
45
+ const handle = Object.freeze({ peer, session });
46
+ this.sessions.set(peer, { handle, policy: "", remaining: empty() });
47
+ return handle;
48
+ }
49
+ revoke(peer: string): void { this.sessions.delete(peer); }
50
+ policyChanged(): void {
51
+ for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
52
+ this.challenges = new WeakMap();
53
+ }
54
+ private current(handle: ActionSessionBudget | undefined): Session | undefined {
55
+ if (!handle) return undefined;
56
+ const state = this.sessions.get(handle.peer);
57
+ if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
58
+ const policy = JSON.stringify(actionPolicyConfig(this.config()));
59
+ if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
60
+ return state;
61
+ }
62
+ private selected(actions: readonly ActionClass[]): ActionClass[] {
63
+ const config = actionPolicyConfig(this.config());
64
+ return config.enabled ? ACTION_CLASSES.filter(key => actions.includes(key) && config.always_ask.includes(key)) : [];
65
+ }
66
+ /** A budget decision consumes every selected class atomically; native never grants permission. */
67
+ request(handle: ActionSessionBudget | undefined, actions: readonly ActionClass[]): ActionDecision {
68
+ const state = this.current(handle), classes = this.selected(actions);
69
+ if (!classes.length) return { kind: "native" };
70
+ if (!state) return { kind: "ask", classes };
71
+ if (classes.some(key => state.remaining[key] <= 0)) {
72
+ const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
73
+ this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
74
+ return { kind: "ask", classes, challenge };
75
+ }
76
+ for (const key of classes) state.remaining[key]--;
77
+ return { kind: "budget", classes };
78
+ }
79
+ /** Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. */
80
+ approve(challenge: ActionChallenge | undefined): boolean {
81
+ if (!challenge) return false;
82
+ const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
83
+ if (!grant) return false;
84
+ const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
85
+ if (!state || state.policy !== grant.policy) return false;
86
+ for (const key of grant.classes) state.remaining[key] = Math.max(0, config.budgets[key] - 1);
87
+ return true;
88
+ }
89
+ /** Read-only projection: no session is created, rebound or reset by a status poll. */
90
+ status(peer: string, enforced: boolean) {
91
+ const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
92
+ const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
93
+ return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
94
+ enforceable: enforced, note: !enforced ? `always-ask not enforceable: adapter integration pending${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
95
+ }
96
+ }
97
+
98
+ /** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
99
+ export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
100
+ ports: { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined }): string {
101
+ const view = task === undefined ? undefined : ports.publicView(task);
102
+ const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
103
+ const shown = ports.private ? undefined : ports.commandView(command);
104
+ const line = shown === undefined ? "[private command withheld]" : sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 512);
105
+ return `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`;
106
+ }