@staix/agent-hub 0.12.45 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
package/src/cli/output.ts CHANGED
@@ -5,6 +5,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
5
5
  import { taskProgress } from "../ui/task-progress.ts";
6
6
  import { backendLabel } from "./status-lines.ts";
7
7
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
8
+ import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
8
9
 
9
10
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
10
11
  export interface StatusOutput {
@@ -17,7 +18,7 @@ export interface StatusOutput {
17
18
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
18
19
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
19
20
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
20
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
21
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
21
22
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
22
23
  }
23
24
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
@@ -245,9 +246,13 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
245
246
  }
246
247
  if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
247
248
  if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
249
+ const actionPolicy = data.actionPolicyInfo?.[id];
250
+ if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
251
+ if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
248
252
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
249
253
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
250
254
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
255
+ if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
251
256
  if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
252
257
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
253
258
  return out;
@@ -264,10 +269,55 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
264
269
  if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
265
270
  return out;
266
271
  }
267
- export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
272
+ // #724: short next-step codes for the compact board, keyed on the blocker texts of src/hub/sdlc.ts; the full advice stays in --full and ahub task show.
273
+ const STAGE_CODES: [string, string][] = [["issue identity missing", "issue?"], ["PR identity missing", "PR?"], ["PR is not open", "PR closed"], ["current PR evidence unknown", "PR state?"],
274
+ ["required check", "CI?"], ["local gate", "gate?"], ["current head has not been reviewed", "review?"], ["GitHub review approval", "approval?"], ["review requests", "requests?"],
275
+ ["review threads", "threads?"], ["open important review findings", "findings"], ["person may inspect merge readiness", "ready"]];
276
+ export function stageCode(next: string): string {
277
+ const known = STAGE_CODES.find(([prefix]) => next.startsWith(prefix));
278
+ return known ? known[1] : width(next) <= 12 ? next : characters(next).slice(0, 9).join("") + "...";
279
+ }
280
+ const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
281
+ const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
282
+ /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
283
+ function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
284
+ const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
285
+ let table = tasks.map((task, i) => {
286
+ const row = data[i]!, deps: number[] = task.deps ?? [];
287
+ const after = deps.length ? `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}` : "-";
288
+ const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
289
+ const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
290
+ const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
291
+ return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
292
+ });
293
+ let names = [...head];
294
+ const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
295
+ for (const name of [...names]) if (name !== "TITLE" && table.every(row => flat(row[names.indexOf(name)]?.text) === "-")) drop(name);
296
+ const room = () => { const t = names.indexOf("TITLE"); return columns - names.reduce((sum, name, i) => i === t ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1); };
297
+ const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
298
+ for (const name of ["REVIEWER", "CLASS", "AGE", "STAGE"]) if (room() < want) {
299
+ // STAGE is never dropped: it falls back to the meter and the code, the stage name being the meter's position.
300
+ if (name !== "STAGE") drop(name);
301
+ else { const at = names.indexOf("STAGE"); if (at >= 0) table = table.map(row => row.map((cell, i) => i === at ? { ...cell, text: (cell as OutputCell & { short?: string }).short ?? cell.text } : cell)); }
302
+ }
303
+ // Still too narrow (a long instance id as OWNER): AFTER gives way, then OWNER is cut, so the row never becomes linked bands.
304
+ if (room() < 8) drop("AFTER");
305
+ const owner = names.indexOf("OWNER");
306
+ if (room() < 8 && owner >= 0) { const cut = Math.max(5, Math.max(...table.map(row => width(flat(row[owner]!.text)))) - (8 - room())); table = table.map(row => row.map((cell, i) => i === owner ? { ...cell, text: ellipsis(flat(cell.text), cut) } : cell)); }
307
+ const titleRoom = Math.max(5, room());
308
+ // A PII row keeps its whole "ahub task show" hint when 8 columns of title remain; the cut title is never wrapped.
309
+ table = table.map((row, i) => { const pii = ` (ahub task show ${tasks[i].id})`, hint = tasks[i].signals?.includes("pii") && titleRoom - width(pii) >= 8 ? pii : ""; return [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, Math.max(4, titleRoom - width(hint))) + hint }]; });
310
+ return rows(names, table, columns);
311
+ }
312
+ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
268
313
  if (!tasks.length) return [[span("no tasks")]];
269
314
  const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
270
315
  const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
316
+ const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
317
+ if (!full && columns !== undefined) {
318
+ const data = tasks.map(task => { const row = cells(s, task, false, now, stages); row[2] = span(row[2]!.text, peerTone(task.owner ?? "")); row[3] = span(row[3]!.text, peerTone(task.reviewer ?? "")); return row; });
319
+ return [...compactBoard(tasks, data, columns, allTasks !== null), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns), ...(allTasks !== null ? lines(BOARD_LEGEND, columns, "muted") : [])];
320
+ }
271
321
  const data = tasks.map(task => {
272
322
  const row = cells(s, task, false, now, stages);
273
323
  row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
@@ -276,7 +326,6 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _f
276
326
  if (allTasks === null) row.pop();
277
327
  return row;
278
328
  });
279
- const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
280
329
  return [...rows(allTasks === null ? TABLES[3]!.slice(0, -1) : [...TABLES[3]!], data, columns), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns)];
281
330
  }
282
331
  export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
@@ -431,3 +480,42 @@ export function renderReport(report: Report | TaskReport, columns?: number, now
431
480
  }
432
481
  return out;
433
482
  }
483
+
484
+ // ---- ahub usage (#683 U2): every number names its source; an unknown number prints `unknown`, never 0 ----------------------------------------------
485
+ const SOURCE_NAME: Record<Counter["source"], string> = { native: "native", count: "hub count", manual: "manual", partial: "partial, at least", unknown: "unknown" };
486
+ /** `1234 (native)`, `1234 (hub count)`, or `unknown`: a missing counter is never shown as zero. */
487
+ export const usageNumber = (c: Counter): string => c.value === null ? "unknown" : `${c.value} (${SOURCE_NAME[c.source]})`;
488
+ const usageCell = (c: Counter): OutputCell => c.value === null ? span("unknown", "muted") : span(usageNumber(c), "number");
489
+
490
+ export function renderUsage(result: UsageResult, columns?: number): Span[][] {
491
+ const out = lines(`usage by ${result.by}: ${result.coverage.records} records, ${result.coverage.withUsage} with provider counters, ${result.coverage.withoutUsage} without`, columns, "muted");
492
+ const header = ["KEY", "TOTAL", "WITHOUT CACHE", "CACHE READ", "INPUT", "OUTPUT", "REASONING", "HUB COUNT"];
493
+ const row = (r: UsageResult["rows"][number]) => [span(r.pii ? `${r.key} [pii]` : r.key), usageCell(r.total), usageCell(r.totalWithoutCache), usageCell(r.cacheRead), usageCell(r.input), usageCell(r.output), usageCell(r.reasoning), usageCell(r.counted)];
494
+ const all = [...result.rows, ...(result.unattributed ? [result.unattributed] : [])];
495
+ if (!all.length) return [...out, ...lines("no usage recorded in this period", columns)];
496
+ return [...out, [], ...rows(header, all.map(row), columns, undefined, true),
497
+ ...lines("TOTAL is the provider's own total, cache reads included; WITHOUT CACHE removes them. HUB COUNT is what the hub counted itself and covers peers that report no usage record. unknown is never zero.", columns, "muted")];
498
+ }
499
+
500
+ export function renderLimits(result: LimitsResult, current: unknown, columns?: number): Span[][] {
501
+ const out = lines(`limit history: ${result.truncated ? `the newest ${result.shown} of ${result.total}` : `${result.total}`} events, ${result.open.length} open pause${result.open.length === 1 ? "" : "s"}`, columns, "muted");
502
+ if (result.truncated) out.push(...lines(`${result.omitted} older events are not shown: use --limit <n> (up to ${result.maxLimit}) for more`, columns, "attention"));
503
+ for (const o of result.open) out.push(...lines(`open pause: ${o.peer} ${o.window} since ${o.since}, resets ${new Date(o.resetsAt).toISOString()}`, columns, "attention"));
504
+ for (const o of result.endedUnknown) out.push(...lines(`pause ended, end unknown: ${o.peer} ${o.window} since ${o.since} (the hub no longer holds it, for example after ahub reset; no resume was recorded)`, columns, "muted"));
505
+ const windows = current && typeof current === "object" ? Object.entries(current as Record<string, { windows?: { id: string; used: number; resetsAt?: number }[] }>) : [];
506
+ const live = windows.flatMap(([peer, b]) => (b?.windows ?? []).map(w => [span(peer), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(w.resetsAt ? new Date(w.resetsAt).toISOString() : "unknown", w.resetsAt ? undefined : "muted")]));
507
+ if (live.length) out.push([], ...lines("current windows", columns, "strong"), ...rows(["PEER", "WINDOW", "USED", "RESETS"], live, columns, undefined, true));
508
+ const hist = result.history.map(h => [span(h.at), span(h.type.replace("budget_", "").replace("quota_reset", "reset")), span(h.peer), span(h.window), span(Object.entries(h.detail).map(([k, v]) => `${k} ${v}`).join(", "), "muted")]);
509
+ if (hist.length) out.push([], ...lines("history", columns, "strong"), ...rows(["AT", "EVENT", "PEER", "WINDOW", "DETAIL"], hist, columns, undefined, true));
510
+ return out;
511
+ }
512
+
513
+ export function renderTotal(result: TotalResult, columns?: number): Span[][] {
514
+ const out = lines(`lifetime development tokens since ${result.since ?? "the first event"}`, columns, "muted");
515
+ const table = result.peers.map(p => [span(p.peer), usageCell(p.tokens), usageCell(p.withoutCache), usageCell(p.cacheRead)]);
516
+ table.push([span("ALL PEERS", "strong"), usageCell(result.overall.tokens), usageCell(result.overall.withoutCache), usageCell(result.overall.cacheRead)]);
517
+ const left = (label: string, peers: string[]) => peers.length ? lines(`${label} leaves out ${peers.join(", ")} (unknown, not counted as 0): a lower bound`, columns, "attention") : [];
518
+ return [...out, [], ...rows(["PEER", "TOTAL", "WITHOUT CACHE", "CACHE READ"], table, columns, undefined, true),
519
+ ...left("ALL PEERS TOTAL", result.overall.tokens.source === "partial" ? result.unknownPeers.tokens : []), ...left("ALL PEERS CACHE READ", result.overall.cacheRead.source === "partial" ? result.unknownPeers.cacheRead : []),
520
+ ...lines("A peer with no provider counters (for example jcode, kimi) shows the hub's own count where one exists, otherwise unknown. The overall WITHOUT CACHE is unknown unless every peer's cache split is known.", columns, "muted")];
521
+ }
@@ -21,7 +21,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
21
21
  if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
22
22
  if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
23
23
  }
24
- const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
24
+ const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition, ...(selected.instance ? { instance: selected.instance } : {}),
25
25
  baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
26
26
  helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
27
27
  startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
@@ -0,0 +1,82 @@
1
+ /** Literal release targets only. Parsing never executes a command or reads the repository. */
2
+ export type ReleaseGuardRequest =
3
+ | { kind: "tag-push"; version: string; tag: string; sourceRef: string; remote: string }
4
+ | { kind: "gh-release"; version: string; tag: string; target?: string }
5
+ | { kind: "npm-publish" };
6
+ export interface ReleasePushUpdate { localRef: string; localOid: string; remoteRef: string; remoteOid: string; version: string; }
7
+ type Refusal = { ok: false; reason: string };
8
+ export type ReleaseGuardParse = { ok: true; request: ReleaseGuardRequest } | Refusal;
9
+ export type ReleasePushParse = { ok: true; remoteName: string; remoteUrl: string; updates: ReleasePushUpdate[]; stdin: string } | Refusal;
10
+ const refuse = (): Refusal => ({ ok: false, reason: "release target is ambiguous, unsupported or invalid" });
11
+ const SEMVER = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/;
12
+ const oid = (value: string) => /^[0-9a-f]{40}$/.test(value);
13
+ const zero = (value: string) => /^0{40}$/.test(value);
14
+ const literal = (value: string) => value.length > 0 && value.length <= 4096 && !/[\s\0-\x1f\x7f$`*?\\]/.test(value);
15
+ const ref = (value: string) => literal(value) && !value.startsWith("-") && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.endsWith(".lock") && !"~^:[]()".split("").some(char => value.includes(char));
16
+ const versionOf = (tag: string): string | undefined => tag.startsWith("v") && SEMVER.test(tag.slice(1)) ? tag.slice(1) : undefined;
17
+
18
+ // Hook values are opaque Git data, not shell source. Unicode and shell-looking
19
+ // characters are valid in refs; only Git's forbidden ref syntax is rejected.
20
+ const hookText = (value: string) => value.length > 0 && value.length <= 4096 && !/[\p{Cc}]/u.test(value);
21
+ const hookRef = (value: string) => hookText(value) && !/[\0-\x20\x7f]/.test(value) && !"~^:?*[\\".split("").some(char => value.includes(char)) && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.split("/").some(part => !part || part.startsWith(".") || part.endsWith(".lock"));
22
+
23
+ export function parseReleaseGuardArgv(argv: readonly string[]): ReleaseGuardParse {
24
+ if (!Array.isArray(argv) || !argv.length || argv.length > 128 || !argv.every(word => typeof word === "string" && word.length <= 4096 && !/[\0-\x1f\x7f]/.test(word)) || Buffer.byteLength(argv.join("\0")) > 16 * 1024) return refuse();
25
+ const [program, ...args] = argv;
26
+ if (program === "git" && args[0] === "push") {
27
+ if (args.length !== 3 || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[1] ?? "")) return refuse();
28
+ const spec = args[2]!, parts = spec.split(":");
29
+ if (parts.length > 2 || spec.startsWith("+")) return refuse();
30
+ const destination = parts.length === 2 ? parts[1]! : parts[0]!;
31
+ const tag = destination.startsWith("refs/tags/") ? destination.slice(10) : destination;
32
+ const version = versionOf(tag), sourceRef = parts[0]!;
33
+ if (!version || !ref(sourceRef) || parts.length === 2 && !destination.startsWith("refs/tags/")) return refuse();
34
+ return { ok: true, request: { kind: "tag-push", version, tag, sourceRef, remote: args[1]! } };
35
+ }
36
+ if (program === "gh" && args[0] === "release" && args[1] === "create") {
37
+ const tag = args[2] ?? "", version = versionOf(tag);
38
+ if (!version) return refuse();
39
+ let target: string | undefined;
40
+ const valued = new Set(["--title", "-t", "--notes", "-n", "--notes-file", "-F", "--notes-start-tag", "--target"]);
41
+ const flags = new Set(["--verify-tag", "--generate-notes", "--draft", "-d", "--prerelease", "-p", "--fail-on-no-commits"]);
42
+ for (let i = 3; i < args.length; i++) {
43
+ const flag = args[i]!;
44
+ if (flags.has(flag)) continue;
45
+ if (!valued.has(flag) || args[i + 1] === undefined) return refuse();
46
+ const value = args[++i]!;
47
+ if (flag === "--target") { if (target !== undefined || !ref(value)) return refuse(); target = value; }
48
+ }
49
+ return { ok: true, request: { kind: "gh-release", version, tag, ...(target === undefined ? {} : { target }) } };
50
+ }
51
+ if (program === "npm" && args[0] === "publish") {
52
+ const flags = new Set(["--provenance", "--ignore-scripts", "--dry-run"]);
53
+ for (let i = 1; i < args.length; i++) {
54
+ if (flags.has(args[i]!)) continue;
55
+ if (args[i] === "--access" && ["public", "restricted"].includes(args[i + 1] ?? "")) { i++; continue; }
56
+ if (args[i] === "--tag" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[i + 1] ?? "")) { i++; continue; }
57
+ return refuse();
58
+ }
59
+ return { ok: true, request: { kind: "npm-publish" } };
60
+ }
61
+ return refuse();
62
+ }
63
+
64
+ /** Validate every hook row before selecting version-tag updates. Preserve the exact original input. */
65
+ export function parseReleasePushUpdates(remoteName: string, remoteUrl: string, stdin: string): ReleasePushParse {
66
+ if (typeof remoteName !== "string" || typeof remoteUrl !== "string" || typeof stdin !== "string" || !hookText(remoteName) || !hookText(remoteUrl) || Buffer.byteLength(stdin) > 128 * 1024) return refuse();
67
+ const lines = stdin.endsWith("\n") ? stdin.slice(0, -1).split("\n") : stdin.split("\n");
68
+ if (stdin === "") lines.length = 0;
69
+ if (lines.length > 128) return refuse();
70
+ const updates: ReleasePushUpdate[] = [];
71
+ for (const line of lines) {
72
+ const fields = line.split(" ");
73
+ if (fields.length !== 4) return refuse();
74
+ const [localRef, localOid, remoteRef, remoteOid] = fields as [string, string, string, string];
75
+ if ((!hookRef(localRef) && localRef !== "(delete)") || !hookRef(remoteRef) || !remoteRef.startsWith("refs/") || !oid(localOid) || !oid(remoteOid)) return refuse();
76
+ if (!remoteRef.startsWith("refs/tags/v")) continue;
77
+ const version = versionOf(remoteRef.slice(10));
78
+ if (!version || zero(localOid) || localRef === "(delete)") return refuse();
79
+ updates.push({ localRef, localOid, remoteRef, remoteOid, version });
80
+ }
81
+ return { ok: true, remoteName, remoteUrl, updates, stdin };
82
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
5
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -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
+ }