@staix/agent-hub 0.15.0 → 0.16.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
package/src/cli/memory.ts CHANGED
@@ -1,15 +1,32 @@
1
+ import { TaskRepositories } from "../hub/task-repository.ts";
2
+ import type { TaskPlan, TaskRefs } from "../hub/board.ts";
3
+ import { Database } from "bun:sqlite";
4
+ import { lstatSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { CodeFacts } from "../hub/code-facts.ts";
7
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
8
+ import { hubGitEnv, hubGitSync } from "../hub/git.ts";
9
+ import { stateDirFor } from "../hub/control-client.ts";
10
+ import { isDenied } from "../local/deny.ts";
11
+ import { foldSegment } from "../hub/permission-mode.ts";
12
+ import { matchesPolicyLexically } from "../hub/path-policy.ts";
13
+ import { memoryAnchorTaskIdentity, memoryStaleAnchors, unavailableMemoryStaleAnchors, type MemoryStaleAnchorReport, type MemoryTaskPrivacy } from "../hub/memory-stale-anchors.ts";
14
+ import { MEMORY_SYMBOL_START_BUDGET_MS } from "../hub/memory-symbols.ts";
1
15
  import { detectCliIdentity } from "./identity.ts";
2
16
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
- import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
17
+ import { looksSecret, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
4
18
  import type { SyncOptions } from "@staix/agent-mem";
5
- import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
19
+ import { memoryUsage, goalUsage, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
20
+
21
+ /** The hub's command names in agent-mem usage and refusal text (agent-mem 0.7.0 defaults to `amem`). */
22
+ const AHUB_NAMES = { memory: "ahub memory", goal: "ahub goal" };
6
23
  import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
7
24
  import { currentRouting, detectSignals } from "../hub/routing.ts";
8
25
  import { loadConfig } from "../hub/daemon.ts";
9
- import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
26
+ import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MemoryProfileBinding, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
10
27
 
11
- export { GOAL_USAGE } from "@staix/agent-mem";
12
- export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
28
+ export const GOAL_USAGE = goalUsage(AHUB_NAMES.goal);
29
+ export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | profile-check [--json]`;
13
30
 
14
31
  const fail = (message: string): never => { throw new Error(message); };
15
32
  /** The project's config, or none when there is no valid one (reading memory needs none). */
@@ -75,7 +92,7 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
75
92
  screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
76
93
  try {
77
94
  context.binding.assertCurrent();
78
- const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
95
+ const text = await rawMemoryCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
79
96
  readable: notes => {
80
97
  const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
81
98
  if (args[0] !== "show" || args.includes("--json")) return visible;
@@ -99,9 +116,104 @@ export async function goalCommand(args: string[], cwd: string, opts: { store?: M
99
116
  const context = commandStores(opts.store, { project }, cwd);
100
117
  try {
101
118
  context.binding.assertCurrent();
102
- const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
119
+ const text = await rawGoalCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
103
120
  clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
104
121
  });
105
122
  return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
106
123
  } finally { if (context.owned) context.store.close(); }
107
124
  }
125
+
126
+
127
+ interface StaleAnchorCommandOptions {
128
+ store?: MemoryStore;
129
+ facts?: Pick<CodeFacts, "exists">;
130
+ /** Trusted host test seam; never read from CLI arguments or project configuration. */
131
+ createFacts?: () => Pick<CodeFacts, "exists" | "close">;
132
+ stateDir?: string;
133
+ head?: () => string | undefined;
134
+ taskPrivacy?: (key: string) => MemoryTaskPrivacy;
135
+ now?: () => number;
136
+ }
137
+ /** Unlike an ordinary core open, inspection must not initialize a missing profile, machine or shard. */
138
+ function existingInspectionProfile(project: string, cwd: string): boolean {
139
+ try {
140
+ const binding = new MemoryProfileBinding({ project, projectKey: memoryProjectKey(cwd) });
141
+ const profiles = [binding.profile, ...(binding.profile.sharded ? [resolveShardProfile(binding.home, { user: true })] : [])];
142
+ for (const profile of profiles) {
143
+ const machineFile = join(profile.sharded ? profile.home : profile.dir, "machine.json"), machine = lstatSync(machineFile);
144
+ if (!machine.isFile() || machine.isSymbolicLink()) return false;
145
+ const metadata = readProfileJSON(machineFile, 4096) as { id?: unknown } | undefined;
146
+ if (!metadata || typeof metadata.id !== "string" || !/^[a-f0-9]{16}$/.test(metadata.id)) return false;
147
+ const logDirectory = lstatSync(join(profile.dir, "log"));
148
+ const localDirectory = lstatSync(join(profile.dir, "local"));
149
+ if (!logDirectory.isDirectory() || logDirectory.isSymbolicLink() || !localDirectory.isDirectory() || localDirectory.isSymbolicLink()) return false;
150
+ }
151
+ binding.assertCurrent(); return true;
152
+ } catch { return false; }
153
+ }
154
+
155
+ /** Host-only doctor report: selected amem reads may maintain derived indexes/leases, never notes or candidates. */
156
+ export async function memoryStaleAnchorsCommand(cwd: string, opts: StaleAnchorCommandOptions = {}): Promise<MemoryStaleAnchorReport> {
157
+ let ownedFacts: Pick<CodeFacts, "exists" | "close"> | undefined;
158
+ let context: ReturnType<typeof commandStores> | undefined;
159
+ try {
160
+ const project = projectOf(cwd, opts.store);
161
+ if (!opts.store && !existingInspectionProfile(project, cwd)) return unavailableMemoryStaleAnchors();
162
+ context = commandStores(opts.store, { project, projectKey: memoryProjectKey(cwd), deferPromotions: true }, cwd);
163
+ const config = () => loadConfig(cwd);
164
+ const publicMetadataText = (text: string): boolean => {
165
+ try {
166
+ const c = config(), paths = [...(c.pii?.paths ?? []), ...c.local.deny];
167
+ return !looksSecret(text) && !detectSignals({ title: "", detail: text, refs: {} }, currentRouting(cwd), cwd).includes("pii")
168
+ && !mentionsPrivatePath(text, [], paths, cwd)
169
+ && !pathCandidates(text).some(path => isDenied(path, c.local.deny) || isDenied(foldSegment(path), c.local.deny.map(foldSegment)));
170
+ } catch { return false; }
171
+ };
172
+ const clock = opts.now ?? performance.now.bind(performance), metadataDeadline = clock() + MEMORY_SYMBOL_START_BUDGET_MS;
173
+ const blockedTasks = new Map<string, "private" | "unknown">(), repositories = new TaskRepositories(cwd); let taskSourceUnavailable = false;
174
+ const taskPrivacy = opts.taskPrivacy ?? ((key: string): MemoryTaskPrivacy => {
175
+ const identity = memoryAnchorTaskIdentity(key, project); if (!identity) return "unknown";
176
+ const blocked = blockedTasks.get(key); if (blocked) return blocked;
177
+ if (taskSourceUnavailable || clock() >= metadataDeadline) return "unknown";
178
+ let db: Database | undefined;
179
+ try {
180
+ const file = join(opts.stateDir ?? stateDirFor(cwd), "hub.db"), stat = lstatSync(file);
181
+ if (!stat.isFile() || stat.isSymbolicLink()) { taskSourceUnavailable = true; return "unknown"; }
182
+ db = new Database(file, { readonly: true, create: false });
183
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
184
+ const row = db.query(`SELECT id,created,signals,title,detail,refs,plan FROM tasks WHERE id=? AND created=?
185
+ AND length(signals)<=65536 AND length(title)<=300 AND length(detail)<=8000 AND length(refs)<=65536 AND length(plan)<=65536`).get(identity.id, identity.created) as { id: number; created: number; signals: string; title: string; detail: string; refs: string; plan: string } | null;
186
+ if (!row || row.id !== identity.id || row.created !== identity.created) { blockedTasks.set(key, "unknown"); return "unknown"; }
187
+ const signals: unknown = JSON.parse(row.signals), refs: unknown = JSON.parse(row.refs), plan: unknown = JSON.parse(row.plan);
188
+ if (!Array.isArray(signals) || !signals.every(signal => typeof signal === "string") || !refs || typeof refs !== "object" || Array.isArray(refs) || !plan || typeof plan !== "object" || Array.isArray(plan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
189
+ const raw = [row.title, row.detail];
190
+ JSON.stringify({ refs, plan }, (key: string, value: unknown) => { if (key) raw.push(key); if (typeof value === "string") raw.push(value); return value; });
191
+ if (signals.includes("pii") || signals.includes("pii_path") || !publicMetadataText(raw.join("\n"))) { blockedTasks.set(key, "private"); return "private"; }
192
+ const paths = [...Object.values(refs), ...Object.values(plan)].flatMap(value => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
193
+ const c = config();
194
+ if (paths.some(path => isDenied(path, c.local.deny) || matchesPolicyLexically(cwd, path, [...(c.pii?.paths ?? []), ...c.local.deny]))) { blockedTasks.set(key, "private"); return "private"; }
195
+ const scope = repositories.scope(refs as TaskRefs);
196
+ if (scope.key !== repositories.project.key || repositories.mismatch(refs as TaskRefs, plan as TaskPlan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
197
+ return "public";
198
+ } catch { taskSourceUnavailable = true; return "unknown"; }
199
+ finally { db?.close(); }
200
+ });
201
+ const head = opts.head ?? (() => {
202
+ try {
203
+ const result = hubGitSync(["-C", cwd, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", env: hubGitEnv(process.env, { repositoryIsolation: true }), timeout: 1000, maxBuffer: 4096 });
204
+ const value = result.stdout.trim(); return result.status === 0 && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
205
+ } catch { return undefined; }
206
+ });
207
+ const facts = opts.facts ?? (ownedFacts = opts.createFacts?.() ?? new CodeFacts({ timeoutMs: MEMORY_SYMBOL_START_BUDGET_MS }));
208
+ const binding = context.binding;
209
+ return await memoryStaleAnchors({ cwd, project, store: context.store, head, now: opts.now, taskPrivacy, publicMetadataText,
210
+ assertCurrent: () => binding.assertCurrent(),
211
+ context: () => ({ cwd, project, repo: project, deny: config().local.deny, allowed: publicMetadataText }),
212
+ exists: (repo, at, name) => facts.exists(repo, at, name),
213
+ finishQueries: async () => { if (ownedFacts) { await ownedFacts.close(); ownedFacts = undefined; } } });
214
+ } catch { return unavailableMemoryStaleAnchors(); }
215
+ finally {
216
+ try { if (ownedFacts) await ownedFacts.close(); } catch { /* Optional cleanup failure remains neutral, including a second failed close. */ }
217
+ try { if (context?.owned) context.store.close(); } catch { /* Inspection must still return its neutral report. */ }
218
+ }
219
+ }
package/src/cli/output.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definitionOf } from "../adapters/registry.ts";
2
+ import { windowKind } from "../hub/window-kind.ts";
2
3
  import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
3
4
  import { peerStyle } from "../ui/peer-palette.ts";
4
5
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
@@ -16,12 +17,14 @@ export interface StatusOutput {
16
17
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
17
18
  pendingTaskOffers?: readonly { task: number; peer: string }[];
18
19
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
20
+ memoryRead?: { truncated: boolean; scanned: number; limit: number };
19
21
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
20
22
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
21
23
  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 };
22
24
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
23
25
  }
24
- export interface BudgetOutput { budget: Record<string, any>; gate?: number }
26
+ /** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
27
+ export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
25
28
  export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
26
29
  export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
27
30
  return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
@@ -207,6 +210,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
207
210
  const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
208
211
  out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
209
212
  }
213
+ if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
210
214
  if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
211
215
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
212
216
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
@@ -358,9 +362,13 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
358
362
  }
359
363
  export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
360
364
  const out: Span[][] = [];
361
- const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : "fresh", w.stale ? "attention" : undefined)]));
362
- if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "SOURCE", "MEASURED", "STATE"], windows, columns));
363
- else out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
365
+ // KIND names what the number is (#285): the provider's own (native), tokens the hub counted against a ceiling (count), or a person's (manual).
366
+ // A window without a kind, from an older hub, is classified by `windowKind`. A fresh window that does not gate says so in STATE.
367
+ const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(windowKind({ id: w.id, source: w.source ?? "", kind: w.kind }), "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : w.superseded ? "info only" : "fresh", w.stale ? "attention" : undefined)]));
368
+ if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "KIND", "SOURCE", "MEASURED", "STATE"], windows, columns));
369
+ else if (!data.peers?.length) out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
370
+ // A peer with no number says why, in its own line, so a peer without quota is not mistaken for one that has not reported.
371
+ for (const note of data.peers ?? []) out.push(...detail(note.peer, note.note, columns, peerTone(note.peer)));
364
372
  for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
365
373
  return out;
366
374
  }
@@ -5,7 +5,8 @@ export type ActionClass = typeof ACTION_CLASSES[number];
5
5
 
6
6
  const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
7
7
  const LIMIT = 16_384;
8
- type Command = { words: string[]; outputs: string[] };
8
+ /** `nested`: the commands of each substitution in the segment (`$(...)`, a backtick). They run in a subshell, so their `cd` never moves the outer cwd. */
9
+ type Command = { words: string[]; outputs: string[]; nested?: Command[][] };
9
10
 
10
11
  /** Bound the whole input before accepting any prefix, including unsupported suffixes. */
11
12
  function segmentLimit(text: string): boolean {
@@ -28,8 +29,79 @@ function segmentLimit(text: string): boolean {
28
29
  return true;
29
30
  }
30
31
 
31
- /** Literal shell words only; unsupported syntax discards only its unfinished segment. */
32
- function commands(text: string): Command[] | undefined {
32
+ /**
33
+ * An expansion (`$VAR`, `${...}`, `$(...)`, a backtick) stands in a word as this one character. The input itself may not contain it
34
+ * (`commands` refuses a NUL), so a word has it only where the hub put it: the word's value is unknown (#762).
35
+ */
36
+ const OPAQUE = "\0";
37
+ const opaque = (word: string | undefined): boolean => word !== undefined && word.includes(OPAQUE);
38
+
39
+ /** The index after the bracket that closes `text[at]` (`(` or `{`), quotes and nested expansions respected, or -1 when it never closes. */
40
+ function matching(text: string, at: number, nest = 0): number {
41
+ if (nest > 16) return -1;
42
+ const open = text[at]!, close = open === "(" ? ")" : "}";
43
+ let depth = 0;
44
+ for (let i = at; i < text.length; i++) {
45
+ const c = text[i]!;
46
+ if (c === "\\") { i++; continue; }
47
+ if (c === "'") { const end = text.indexOf("'", i + 1); if (end < 0) return -1; i = end; continue; }
48
+ if (c === "`") { const end = text.indexOf("`", i + 1); if (end < 0) return -1; i = end; continue; }
49
+ if (c === '"') {
50
+ for (i++; i < text.length && text[i] !== '"'; i++) {
51
+ if (text[i] === "\\") i++;
52
+ else if (text[i] === "$" && (text[i + 1] === "(" || text[i + 1] === "{")) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; }
53
+ }
54
+ if (i >= text.length) return -1;
55
+ continue;
56
+ }
57
+ if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{") && i + 1 !== at) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; continue; }
58
+ if (c === open) depth++;
59
+ else if (c === close && --depth === 0) return i + 1;
60
+ }
61
+ return -1;
62
+ }
63
+
64
+ /**
65
+ * The expansion that starts at `text[at]` (a `$` or a backtick): where it ends, the command texts it runs (`inners`: `$(...)` and a backtick
66
+ * run their text; `${...}` runs only the substitutions nested in its parameter text, never the text itself), and `drop` for a `$"` that only
67
+ * opens a quote. Undefined for a `$` that is a literal dollar. An expansion that never closes ends at the end of the text.
68
+ */
69
+ function expansion(text: string, at: number, quoted: boolean, nest = 0): { end: number; inners: string[]; drop?: true } | undefined {
70
+ if (text[at] === "`") {
71
+ for (let i = at + 1; i < text.length; i++) {
72
+ if (text[i] === "\\") { i++; continue; }
73
+ if (text[i] === "`") return { end: i + 1, inners: [text.slice(at + 1, i)] };
74
+ }
75
+ return { end: text.length, inners: [] };
76
+ }
77
+ const next = text[at + 1];
78
+ if (next === "(") {
79
+ const end = matching(text, at + 1);
80
+ return end < 0 ? { end: text.length, inners: [] } : { end, inners: [text.slice(at + 2, end - 1)] };
81
+ }
82
+ if (next === "{") { // parameter text is data: only the substitutions inside it run
83
+ const end = matching(text, at + 1);
84
+ const body = end < 0 ? text.slice(at + 2) : text.slice(at + 2, end - 1), inners: string[] = [];
85
+ for (let i = 0; i < body.length && nest < 4; i++) {
86
+ if (body[i] === "\\") { i++; continue; }
87
+ if (body[i] !== "$" && body[i] !== "`") continue;
88
+ const inner = expansion(body, i, false, nest + 1);
89
+ if (inner) { inners.push(...inner.inners); i = inner.end - 1; }
90
+ }
91
+ return { end: end < 0 ? text.length : end, inners };
92
+ }
93
+ if (next === "'") { // $'...': ANSI-C quoting, whose escapes build the word
94
+ for (let i = at + 2; i < text.length; i++) { if (text[i] === "\\") i++; else if (text[i] === "'") return { end: i + 1, inners: [] }; }
95
+ return { end: text.length, inners: [] };
96
+ }
97
+ if (next === '"') return quoted ? undefined : { end: at + 1, inners: [], drop: true };
98
+ if (next !== undefined && /[A-Za-z_]/.test(next)) { let end = at + 2; while (end < text.length && /[A-Za-z0-9_]/.test(text[end]!)) end++; return { end, inners: [] }; }
99
+ if (next !== undefined && /[0-9@*#?$!-]/.test(next)) return { end: at + 2, inners: [] };
100
+ return undefined;
101
+ }
102
+
103
+ /** Shell words, literal where they can be read. An expansion becomes an opaque word and the commands it runs are read too; unsupported syntax discards only its unfinished segment. */
104
+ function commands(text: string, nest = 0): Command[] | undefined {
33
105
  if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
34
106
  const result: Command[] = [];
35
107
  let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
@@ -40,7 +112,8 @@ function commands(text: string): Command[] | undefined {
40
112
  else if (!redirect) words.push(word);
41
113
  word = ""; active = false; redirect = false;
42
114
  };
43
- const finish = () => { flush(); if (words.length || outputs.length) result.push({ words, outputs }); words = []; outputs = []; };
115
+ let nested: Command[][] = [];
116
+ const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}) }); words = []; outputs = []; nested = []; };
44
117
  for (let i = 0; i < text.length; i++) {
45
118
  const c = text[i]!;
46
119
  if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
@@ -51,7 +124,13 @@ function commands(text: string): Command[] | undefined {
51
124
  if (next !== "\n") word += next;
52
125
  active = true; continue;
53
126
  }
54
- if (c === "$" || c === "`") return result;
127
+ if (c === "$" || c === "`") {
128
+ const found = expansion(text, i, quote === '"');
129
+ if (!found) { word += c; active = true; continue; } // a lone `$` is a literal dollar
130
+ if (nest < 4) for (const text of found.inners) { const inner = commands(text, nest + 1); if (inner?.length) nested.push(inner); }
131
+ if (!found.drop) { word += OPAQUE; active = true; }
132
+ i = found.end - 1; continue;
133
+ }
55
134
  if (quote) { if (c === quote) quote = ""; else word += c; continue; }
56
135
  if (c === "'" || c === '"') { quote = c; active = true; continue; }
57
136
  if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
@@ -97,6 +176,14 @@ function outside(target: string, root: string, cwd: string): boolean {
97
176
  const rel = relative(resolve(root), resolve(cwd, target));
98
177
  return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
99
178
  }
179
+ /**
180
+ * A word that starts with an expansion may expand to a dash word (`$OPTS` as `-rf`), so a class that depends on a flag counts it as that flag
181
+ * (#762). Only the words before `--` can be options. A word that merely has an expansion later (`build/$X`, `--name=$X`) cannot start one.
182
+ */
183
+ function mayBeFlag(words: readonly string[]): boolean {
184
+ const end = words.indexOf("--");
185
+ return words.slice(0, end < 0 ? undefined : end).some(w => w.startsWith(OPAQUE));
186
+ }
100
187
  function flags(words: string[], letter: string): boolean {
101
188
  const end = words.indexOf("--");
102
189
  return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
@@ -130,7 +217,7 @@ function commandHeads(words: string[], values: readonly string[], booleans: read
130
217
  continue;
131
218
  }
132
219
  if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
133
- if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
220
+ if (actions.includes(word) || opaque(word)) heads.push({ name: word, args: words.slice(i + 1) });
134
221
  }
135
222
  return heads;
136
223
  }
@@ -153,6 +240,7 @@ function forcedClean(words: string[]): boolean {
153
240
  if (flag === "n") dry = true;
154
241
  }
155
242
  }
243
+ else if (word.startsWith(OPAQUE)) force = true; // an expansion may be `-f`; a literal dry run still wins (#762)
156
244
  }
157
245
  return force && !dry;
158
246
  }
@@ -182,12 +270,110 @@ function unwrapped(input: string[]): string[] {
182
270
  else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
183
271
  }
184
272
  if (name === "timeout") {
185
- if (!/^\d+(?:\.\d+)?[smhd]?$/.test(words.shift() ?? "")) return [];
273
+ const duration = words.shift() ?? "";
274
+ if (!/^\d+(?:\.\d+)?[smhd]?$/.test(duration) && !opaque(duration)) return [];
186
275
  }
187
276
  }
188
277
  return [];
189
278
  }
190
279
 
280
+ /**
281
+ * Whether every definition of a GraphQL document is a read: a `query` (or shorthand `{...}`) or a `fragment`. Strings and comments are skipped, so
282
+ * a keyword inside one counts for nothing. A `mutation`, a `subscription`, any other top-level word, unbalanced text or no operation is false:
283
+ * unknown is never a read.
284
+ */
285
+ function graphqlQueryOnly(text: string): boolean {
286
+ const skipString = (at: number): number => { // `at` is the opening quote; the index after the string, or -1 when it never ends
287
+ // A block string ends at its first `"""`: GraphQL's escaped `\"""` ends it later, so a lexer that ends early only turns a read into a write (the strict direction).
288
+ if (text.startsWith('"""', at)) { const end = text.indexOf('"""', at + 3); return end < 0 ? -1 : end + 3; }
289
+ // GraphQL's line terminators are LF, CR and CRLF, and none may appear in a single-line string: ending early is the strict direction.
290
+ for (let i = at + 1; i < text.length; i++) { if (text[i] === "\\") { i++; if (text[i] === "\n" || text[i] === "\r") return -1; } else if (text[i] === '"') return i + 1; else if (text[i] === "\n" || text[i] === "\r") return -1; }
291
+ return -1;
292
+ };
293
+ const skipComment = (at: number): number => { let i = at; while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; return i; }; // a comment ends at LF or CR (so also CRLF), never later than GraphQL ends it
294
+ const closers: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
295
+ const skipBlock = (at: number): number => { // `at` is an opening bracket; the index after its matching close, or -1
296
+ const close = closers[text[at]!]!;
297
+ for (let i = at + 1; i < text.length; i++) {
298
+ const c = text[i]!;
299
+ if (c === '"') { const end = skipString(i); if (end < 0) return -1; i = end - 1; }
300
+ else if (c === "#") i = skipComment(i) - 1;
301
+ else if (c in closers) { const end = skipBlock(i); if (end < 0) return -1; i = end - 1; }
302
+ else if (c === "}" || c === ")" || c === "]") return c === close ? i + 1 : -1;
303
+ }
304
+ return -1;
305
+ };
306
+ let start = true, operations = 0; // `start`: the next token begins a top-level definition
307
+ for (let i = 0; i < text.length;) {
308
+ const c = text[i]!;
309
+ if (/[\s,\uFEFF]/.test(c)) { i++; continue; }
310
+ if (c === "#") { i = skipComment(i); continue; }
311
+ if (c === "{") { const end = skipBlock(i); if (end < 0) return false; if (start) operations++; i = end; start = true; continue; } // shorthand query, or the body that ends a definition
312
+ if (c === "(") { if (start) return false; const end = skipBlock(i); if (end < 0) return false; i = end; continue; }
313
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i, i + 256))?.[0];
314
+ if (name) {
315
+ if (start) { if (name === "query") operations++; else if (name !== "fragment") return false; start = false; }
316
+ i += name.length; continue;
317
+ }
318
+ if (!start && (c === "@" || c === ":")) { i++; continue; } // a directive, or `fragment X on Y`; nothing else is read at the top level
319
+ return false;
320
+ }
321
+ return start && operations > 0;
322
+ }
323
+
324
+ /**
325
+ * `gh api graphql` with fields (#756): undefined when the call does not name the graphql endpoint, true only for ONE literal `query` field whose
326
+ * every operation is a read. A query from a file or stdin (`@file`, `--input`), a second `query` field, an option this does not know,
327
+ * another positional and every unparsable document are false: the caller keeps them external-write.
328
+ */
329
+ function ghGraphqlRead(words: string[]): boolean | undefined {
330
+ const rest = words.slice(1); // `words` is `api ...`
331
+ if (!rest.includes("graphql")) return undefined;
332
+ const fieldOptions = ["-f", "-F", "--field", "--raw-field"];
333
+ const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method"];
334
+ const booleans = ["--paginate", "--slurp", "--silent", "-i", "--include", "--verbose"];
335
+ const positional: string[] = [], queries: string[] = [];
336
+ for (let i = 0; i < rest.length; i++) {
337
+ const word = rest[i]!;
338
+ if (word === "--" || !word.startsWith("-")) { positional.push(word); continue; }
339
+ let field: string | undefined;
340
+ if (fieldOptions.includes(word)) { field = rest[++i]; if (field === undefined) return false; }
341
+ else if (word.startsWith("--field=") || word.startsWith("--raw-field=")) field = word.slice(word.indexOf("=") + 1);
342
+ else if (/^-[fF][^=]/.test(word)) field = word.slice(2);
343
+ else if (valued.includes(word)) { if (++i >= rest.length) return false; }
344
+ else if (valued.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : /^-[HqtpX]./.test(word) && word.startsWith(key))) continue;
345
+ else if (!booleans.includes(word)) return false;
346
+ if (field !== undefined) {
347
+ const eq = field.indexOf("=");
348
+ if (eq < 0) return false;
349
+ if (field.slice(0, eq) === "query") queries.push(field.slice(eq + 1));
350
+ }
351
+ }
352
+ const method = option(rest, "-X", "--method");
353
+ if (method !== undefined && method.toUpperCase() !== "POST") return false;
354
+ const [query] = queries;
355
+ if (queries.some(opaque)) return false; // an expansion can close a brace or add an operation: unknown is never a read
356
+ if (positional.length !== 1 || positional[0] !== "graphql" || queries.length !== 1 || query === undefined || query.startsWith("@")) return false;
357
+ return graphqlQueryOnly(query);
358
+ }
359
+
360
+ /**
361
+ * `gh api` with a word the hub cannot read where an option may stand (#762): an option name with an expansion in it, or a later operand that
362
+ * starts with one (`gh api repos/o/r $FLAGS`, which can hold `-X POST`). The endpoint (the first operand) and the values of the known valued
363
+ * options are data. Method, field and query values are judged by the ordinary rules, where an unknown method is never GET.
364
+ */
365
+ function ghApiDynamic(words: string[]): boolean {
366
+ const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"];
367
+ let operands = 0;
368
+ for (let i = 1; i < words.length; i++) {
369
+ const word = words[i]!;
370
+ if (word === "--") return words.slice(i + 1).some((w, n) => opaque(w) && operands + n > 0);
371
+ if (word.startsWith("-")) { if (opaque(word)) return true; if (valued.includes(word)) i++; continue; }
372
+ if (operands++ > 0 && word.startsWith(OPAQUE)) return true;
373
+ }
374
+ return false;
375
+ }
376
+
191
377
  /** Scan curl options once: data, headers and other option values never become options. */
192
378
  function curlWrites(input: string[]): boolean {
193
379
  const longValues = new Set([
@@ -249,7 +435,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
249
435
  const [program, ...args] = unwrapped(command.words);
250
436
  const target = args[0] === "--" ? args[1] : args[0];
251
437
  // Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
252
- if (program === "cd" && (!target || target.startsWith("~"))) {
438
+ if (program === "cd" && (!target || target.startsWith("~") || opaque(target))) { // an unknown directory is an unknown cwd
253
439
  if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
254
440
  cwd.add("");
255
441
  }
@@ -262,6 +448,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
262
448
  }
263
449
  }
264
450
  function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
451
+ for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth); // a substitution's own cwd state; only its classes come back
265
452
  const words = unwrapped(command.words);
266
453
  const program = basename(words.shift() ?? "");
267
454
  if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
@@ -270,7 +457,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
270
457
  const mode = shell.shift();
271
458
  if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
272
459
  if (shell[0] === "--") shell.shift();
273
- const nested = shell[0] === undefined ? undefined : commands(shell[0]);
460
+ const nested = shell[0] === undefined ? undefined : commands(shell[0].replaceAll(OPAQUE, "$_"));
274
461
  if (nested) sequence(nested, root, cwd, found, depth + 1);
275
462
  }
276
463
  }
@@ -278,11 +465,12 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
278
465
  if (program === "git") {
279
466
  const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
280
467
  ["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
468
+ if (heads.some(head => opaque(head.name))) { found.add("publish"); found.add("destroy"); } // `git $CMD`: the strictest classes git reaches (#762)
281
469
  for (const { name: sub, args } of heads) {
282
470
  if (sub === "push") found.add("publish");
283
- if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
471
+ if (sub === "reset" && (args.some(w => w === "--hard" || w.startsWith("--hard=")) || mayBeFlag(args))) found.add("destroy");
284
472
  if (sub === "clean" && forcedClean(args)) found.add("destroy");
285
- if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
473
+ if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force") || mayBeFlag(args))) found.add("destroy");
286
474
  }
287
475
  }
288
476
  if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
@@ -300,8 +488,9 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
300
488
  for (const head of heads) {
301
489
  const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
302
490
  for (const { name: sub, args } of candidates) {
491
+ if (opaque(sub) && program !== "bun") { found.add("publish"); found.add("install"); } // `npm $CMD`; bun runs a file by default, so its expansion is no subcommand (#762)
303
492
  if (sub === "publish") found.add("publish");
304
- const global = words.includes("--global") || flags(words, "g");
493
+ const global = words.includes("--global") || flags(words, "g") || mayBeFlag(words);
305
494
  if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
306
495
  }
307
496
  }
@@ -310,20 +499,25 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
310
499
  if (program === "gh") {
311
500
  if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
312
501
  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") {
502
+ if (opaque(words[0])) { found.add("publish"); found.add("external-write"); } // `gh $GROUP ...`: the strictest classes gh reaches (#762)
503
+ else if (opaque(words[1]) && ["release"].includes(words[0] ?? "")) found.add("publish");
504
+ else if (opaque(words[1]) && ["issue", "pr", "gist", "run", "workflow", "repo", "secret", "variable", "label"].includes(words[0] ?? "")) found.add("external-write");
505
+ if (words[0] === "repo" && ["delete", "archive", "edit"].includes(words[1] ?? "") || ["secret", "variable"].includes(words[0] ?? "") && ["set", "delete"].includes(words[1] ?? "") || words[0] === "label" && ["create", "edit", "delete"].includes(words[1] ?? "")) found.add("external-write");
506
+ if (words[0] === "run" && ["rerun", "cancel", "delete"].includes(words[1] ?? "") || words[0] === "workflow" && ["run", "enable", "disable"].includes(words[1] ?? "")) found.add("external-write");
507
+ if (words[0] === "api" && ghGraphqlRead(words) !== true) { // a GraphQL document of queries only is a read (#756); everything else keeps the REST rule
314
508
  const method = option(words, "-X", "--method");
315
509
  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");
510
+ if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field || ghApiDynamic(words)) found.add("external-write");
317
511
  }
318
512
  }
319
513
  if (program === "curl" && curlWrites(words)) found.add("external-write");
320
514
  if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
321
515
  if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
322
516
  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"))) {
517
+ if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive") || operands(words).length > 1 && mayBeFlag(words)) && operands(words).some(p => opaque(p) /* an expansion may hold `..` or an absolute path (#762) */ || cwd.some(at => outside(p, root, at)))) found.add("destroy");
518
+ if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force") || words.length > 2 && mayBeFlag(words.slice(0, -1)))) { // with two words the first cannot be a flag: `cp -f x` has no target
325
519
  const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
326
- if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
520
+ if (target && (opaque(target) || cwd.some(at => outside(target, root, at)))) found.add("destroy");
327
521
  }
328
522
  }
329
523
 
@@ -1,5 +1,5 @@
1
1
  import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
2
- import { actionApprovalText, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
2
+ import { actionApprovalView, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
3
3
  import type { ActionGateVerdict } from "../adapters/acp.ts";
4
4
 
5
5
  /**
@@ -66,14 +66,22 @@ export interface ActionGatePorts<T> {
66
66
  * within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
67
67
  * spends here, once per request).
68
68
  */
69
- export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string) => ActionGateVerdict | undefined {
70
- return (call, session) => {
69
+ export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string, onPremToolTitle?: string) => ActionGateVerdict | undefined {
70
+ return (call, session, onPremToolTitle) => {
71
71
  const classes = classifyCall(call, ports.root(peer));
72
72
  if (!classes.length) return undefined;
73
73
  const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
74
74
  if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
75
- const title = actionApprovalText(decision.classes, commandText(call.input), ports.task(peer), {
76
- publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined });
75
+ // Only the host's local/Pi permit callback supplies this full title. Match the exact executed tool form before bypassing public redaction.
76
+ const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? call.input as Record<string, unknown> : undefined;
77
+ const expected = call.names.includes("bash") && typeof input?.command === "string" ? `bash: ${input.command}`
78
+ : call.names.includes("git") && Array.isArray(input?.args) && input.args.every(word => typeof word === "string") ? `git ${input.args.join(" ")}` : undefined;
79
+ const complete = expected !== undefined && expected === onPremToolTitle;
80
+ const view = onPremToolTitle === undefined ? actionApprovalView(decision.classes, commandText(call.input), ports.task(peer), {
81
+ publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined })
82
+ : { title: `Action approval (${decision.classes.join(", ")})\n${complete ? onPremToolTitle : "[complete command unavailable]"}`, complete };
83
+ const { title } = view;
84
+ if (!view.complete) return { title, denyOnly: true };
77
85
  const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
78
86
  // A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
79
87
  if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };