one-code-extension 0.3.1 → 0.4.1

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 (113) hide show
  1. package/README.md +214 -147
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +189 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +9 -5
  113. package/types/app.d.ts +18 -2
@@ -64,6 +64,7 @@ import {
64
64
  type McpResourceContents,
65
65
  namespacedToolName,
66
66
  } from "./schema.ts";
67
+ import { registerLocalCommand } from "../lib/local-command.ts";
67
68
 
68
69
  /** Anthropic's hard limit on a tool name; a longer one fails the whole request. */
69
70
  const MAX_TOOL_NAME_LENGTH = 128;
@@ -842,9 +843,9 @@ export default function mcpExtension(pi: ExtensionAPI) {
842
843
  });
843
844
  };
844
845
 
845
- pi.registerCommand("mcp", {
846
+ registerLocalCommand(pi, "mcp", {
846
847
  description: "Manage MCP servers (status, reconnect, authenticate, enable/disable)",
847
- handler: async (_args, ctx) => {
848
+ handler: async (args, ctx) => {
848
849
  if (servers.length === 0) {
849
850
  ctx.ui.notify("No MCP servers configured. Add them to .mcp.json or ~/.claude.json.", "info");
850
851
  return;
@@ -12,7 +12,7 @@
12
12
  * concept at all), and the auto-memory folder as the last entry.
13
13
  */
14
14
 
15
- import { basename, dirname, join } from "node:path";
15
+ import { basename, dirname, join, relative, resolve } from "node:path";
16
16
  import {
17
17
  AGENTS_DESCRIPTOR,
18
18
  ancestorDirs,
@@ -25,6 +25,7 @@ import {
25
25
  ONECODE_GLOBAL_DESCRIPTOR,
26
26
  PROJECT_DESCRIPTOR,
27
27
  } from "../lib/claude-context.ts";
28
+ import { forwardSlashes, isRelativeInside, tildify } from "../lib/paths.ts";
28
29
  import { tryReadFile } from "../lib/plugins.ts";
29
30
 
30
31
  export interface MemoryEntry {
@@ -39,12 +40,14 @@ export interface MemoryEntry {
39
40
  exists: boolean;
40
41
  }
41
42
 
42
- /** Display form of a path: a file at the cwd as `./name`, under home as `~/…`, else absolute. */
43
+ /**
44
+ * Display form of a path: a file under the cwd as `./name`, under home as
45
+ * `~/…`, else as given — always with forward slashes (Claude Code's spelling
46
+ * in this picker), whatever the platform's separator.
47
+ */
43
48
  function displayPath(path: string, cwd: string, home: string): string {
44
- if (path.startsWith(`${cwd}/`)) return `./${path.slice(cwd.length + 1)}`;
45
- if (home && path === home) return "~";
46
- if (home && path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`;
47
- return path;
49
+ const rel = relative(resolve(cwd), resolve(path));
50
+ return isRelativeInside(rel) ? `./${forwardSlashes(rel)}` : tildify(path, home);
48
51
  }
49
52
 
50
53
  /**
@@ -40,6 +40,7 @@ import { boundedDockHeight, safeThemeBold, safeThemePaint, truncateLine } from "
40
40
  import { buildMemoryEntries, entryName, type MemoryEntry } from "./entries.ts";
41
41
  import { openPath } from "./open-external.ts";
42
42
  import { applyMemoryKey, decodeMemoryKey, initialMemoryState, renderMemoryPanel } from "./panel.ts";
43
+ import { registerLocalCommand } from "../lib/local-command.ts";
43
44
 
44
45
  const MEMORY_PANEL_MAX_HEIGHT = 20;
45
46
 
@@ -144,9 +145,9 @@ export default function memoryExtension(pi: ExtensionAPI) {
144
145
  // ---- /memory: Claude Code's Memory picker --------------------------------
145
146
  // Pick a memory-related file (or the auto-memory folder) and open it in the
146
147
  // external editor, matching CC. Pure panel logic lives in ./panel.ts.
147
- pi.registerCommand("memory", {
148
+ registerLocalCommand(pi, "memory", {
148
149
  description: "View or edit CLAUDE.md and memory files",
149
- handler: async (_args, ctx) => {
150
+ handler: async (args, ctx) => {
150
151
  const entries = memoryEntriesFor(ctx.cwd);
151
152
  if (!ctx.hasUI) {
152
153
  const lines = entries.map(
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { spawn } from "node:child_process";
17
+ import { commandLaunch } from "../lib/command-launch.ts";
17
18
 
18
19
  export interface OpenPlan {
19
20
  command: string;
@@ -78,7 +79,9 @@ export function openPath(path: string, kind: "file" | "folder"): Promise<OpenRes
78
79
  };
79
80
  let child: ReturnType<typeof spawn>;
80
81
  try {
81
- child = spawn(command, args, { stdio: "ignore", detached: true });
82
+ // `$EDITOR=code` is VS Code's `code.cmd` shim on Windows, which a bare spawn cannot start.
83
+ const launch = commandLaunch(command, args, process.env);
84
+ child = spawn(launch.command, launch.args, { stdio: "ignore", detached: true, windowsVerbatimArguments: launch.windowsVerbatimArguments });
82
85
  } catch (error) {
83
86
  finish({ message: `Could not open ${path}: ${error instanceof Error ? error.message : error}`, ok: false });
84
87
  return;
@@ -14,10 +14,20 @@
14
14
  * startup with its normal precedence (an explicit --model still wins). Safe to
15
15
  * write externally: pi's settings save re-reads the file under its lock and
16
16
  * merges only the fields pi itself modified, so this key survives pi's writes.
17
+ *
18
+ * The switch is also shown to the model the way Claude Code shows `/model`
19
+ * (a local-command breadcrumb before the next prompt — lib/local-command.ts):
20
+ * CC's stdout is "Set model to `<name>` and saved as your default for new
21
+ * sessions", and since persisting is what this extension does, the claim is
22
+ * true here too (a failed write announces the switch without it). pi handles
23
+ * `/model` before any extension sees the text, so `model_select` — which also
24
+ * covers ctrl+p cycling — is the one signal; the debounce means only the model
25
+ * the user settles on is announced.
17
26
  */
18
27
 
19
28
  import { join } from "node:path";
20
29
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { announceLocalCommand } from "../lib/local-command.ts";
21
31
  import { readSettingsForWrite, writeSettings } from "../lib/one-code-settings.ts";
22
32
 
23
33
  /** Pure merge: set the default model, preserving every other settings key. */
@@ -29,11 +39,16 @@ export function applyDefaultModel(
29
39
  return { ...file, defaultProvider: provider, defaultModel: modelId };
30
40
  }
31
41
 
42
+ /** Claude Code's `/model` stdout line, as captured (2.1.278); without the persisted half when the write failed. */
43
+ export function modelSwitchStdout(displayName: string, saved: boolean): string {
44
+ return `Set model to \`${displayName}\`${saved ? " and saved as your default for new sessions" : ""}`;
45
+ }
46
+
32
47
  /** Quiet period after the last switch before it is written (ctrl+p cycles fire one event per step). */
33
48
  export const PERSIST_DEBOUNCE_MS = 400;
34
49
 
35
50
  export default function modelDefaultExtension(pi: ExtensionAPI) {
36
- let pending: { provider: string; id: string; notify: (message: string) => void } | undefined;
51
+ let pending: { provider: string; id: string; name: string; notify: (message: string) => void } | undefined;
37
52
  let timer: ReturnType<typeof setTimeout> | undefined;
38
53
 
39
54
  const flush = () => {
@@ -43,23 +58,27 @@ export default function modelDefaultExtension(pi: ExtensionAPI) {
43
58
  pending = undefined;
44
59
  if (!choice) return;
45
60
  const path = join(getAgentDir(), "settings.json");
61
+ let saved = true;
46
62
  try {
47
63
  writeSettings(path, applyDefaultModel(readSettingsForWrite(path), choice.provider, choice.id));
48
64
  } catch (error) {
49
65
  // Persisting a preference must not break the model switch itself, but
50
66
  // say why it will be forgotten (typically a malformed settings.json).
67
+ saved = false;
51
68
  choice.notify(`Could not save ${choice.provider}/${choice.id} as the default model: ${(error as Error).message}`);
52
69
  }
70
+ announceLocalCommand(pi, { name: "model", stdout: modelSwitchStdout(choice.name, saved) });
53
71
  };
54
72
 
55
73
  // Debounced: a ctrl+p cycle emits model_select per step, and only the model
56
74
  // the user settles on should hit the disk (last one wins).
57
75
  pi.on("model_select", (event, ctx) => {
58
- const model = event.model as { provider?: string; id?: string } | undefined;
76
+ const model = event.model as { provider?: string; id?: string; name?: string } | undefined;
59
77
  if (!model?.provider || !model.id) return;
60
78
  pending = {
61
79
  provider: model.provider,
62
80
  id: model.id,
81
+ name: model.name || model.id,
63
82
  notify: (message) => {
64
83
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
65
84
  },
@@ -22,9 +22,9 @@ import { join } from "node:path";
22
22
  import type { Api, Model } from "@earendil-works/pi-ai";
23
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
24
  import { type ChildAction, SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
25
- import { createTaskNotifier } from "../lib/notifications.ts";
25
+ import type { HandBackVerdict } from "../lib/notifications.ts";
26
26
  import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
27
- import { customMessageText, notificationComponent } from "../lib/tui-render.ts";
27
+
28
28
  import { classify, createClassifierState } from "../auto-mode/classifier.ts";
29
29
  import {
30
30
  type AutoModeConfig,
@@ -52,6 +52,9 @@ import { checkRecoverability } from "../auto-mode/recoverability.ts";
52
52
  import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
53
53
  import { isExecutionPrimitivePath, isSensitivePath } from "../auto-mode/sensitive.ts";
54
54
  import { analyzeShellCommand, type ShellEvidence } from "../auto-mode/shell-analysis.ts";
55
+ import { isGitStatusCommand, powershellReadOnly } from "./powershell-rules.ts";
56
+ import { isShellTool } from "./matcher.ts";
57
+ import { gitStatusClean } from "../lib/git.ts";
55
58
  import { projectMemoryDir } from "../lib/memory.ts";
56
59
  import { sessionResultsDir } from "../lib/persisted-output.ts";
57
60
  import { sessionScratchpadDir } from "../lib/scratchpad.ts";
@@ -67,7 +70,7 @@ import {
67
70
  isPathSubjectTool,
68
71
  } from "./matcher.ts";
69
72
  import { type SessionGrant, sessionGrant } from "./session-grant.ts";
70
- import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "./modes.ts";
73
+ import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
71
74
  import { type ChildToolCall, type ChildGateDecision, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
72
75
  import { trackOriginalCommands } from "../lib/original-command.ts";
73
76
  import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
@@ -80,6 +83,7 @@ import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved
80
83
  import { findProjectRoot } from "../lib/git.ts";
81
84
  import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
82
85
  import { recordUsage } from "../lib/usage-bus.ts";
86
+ import { registerLocalCommand } from "../lib/local-command.ts";
83
87
 
84
88
  const DENIED_BY_USER =
85
89
  "The user doesn't want to proceed with this tool use. The tool use was rejected. Adjust your approach based on the user's feedback instead of retrying the same call.";
@@ -169,12 +173,8 @@ const approved = (choice: string | undefined, grant: SessionGrant | undefined) =
169
173
  choice === YES || (grant !== undefined && choice === grant.label);
170
174
 
171
175
  export default function permissionsExtension(pi: ExtensionAPI) {
172
- const notifyTask = createTaskNotifier(pi);
173
176
  // The background hand-back review rides the same steered path as agent
174
177
  // completions; render it the same compact way (full body on ctrl+o).
175
- pi.registerMessageRenderer("subagent-review", (message, { expanded }, theme) =>
176
- notificationComponent(theme, customMessageText(message.content), expanded),
177
- );
178
178
  pi.registerFlag("permission-mode", {
179
179
  description:
180
180
  "Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
@@ -244,6 +244,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
244
244
  let scratchpadDirPath: string | undefined;
245
245
  /** Where oversized tool outputs are persisted this session — readable like the cwd. */
246
246
  let resultsDirPath: string | undefined;
247
+ /** This project's pi session dir: transcripts + auto-mode-decisions.jsonl (readable, never a write root). */
248
+ let sessionDirPath: string | undefined;
249
+ /**
250
+ * The harness dirs a read may reach besides the cwd, realpath-resolved once
251
+ * per session (they never change after session_start) — one list for both
252
+ * shell pre-gates, which compare against realpaths.
253
+ */
254
+ let readableRoots: string[] = [];
247
255
  /** The session cwd's own realpath (macOS /var → /private/var), for decide()'s containment check. */
248
256
  let resolvedCwd: string | undefined;
249
257
  /** The per-repo One Code settings file, resolved once per session so the auto-mode floor need not re-walk for the project root on every tool call. */
@@ -502,7 +510,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
502
510
 
503
511
  let evidence: ShellEvidence | undefined;
504
512
  if (normalizeToolName(toolName) === "bash" && subject) {
505
- evidence = analyzeShellCommand({ command: subject, cwd, home, protectedDirs });
513
+ evidence = analyzeShellCommand({ command: subject, cwd, home, protectedDirs, readableRoots });
506
514
  if (evidence.verdict === "safe") {
507
515
  logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
508
516
  return allow();
@@ -523,6 +531,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
523
531
  }
524
532
  evidence.notes.push(`git recoverability: ${rec.reason}`);
525
533
  }
534
+ } else if (normalizeToolName(toolName) === "powershell" && subject) {
535
+ // PowerShell has no pre-gate in v1 beyond Claude Code's read-only cmdlet
536
+ // allowlist (powershell-rules.ts): a read-only line runs unclassified,
537
+ // everything else — every write, delete or unknown executable — goes to
538
+ // the classifier. No containment fast path either: the recoverability
539
+ // judge understands bash deletes, not `Remove-Item`, so PowerShell
540
+ // destruction is always classified (docs/decisions/windows.md).
541
+ if (powershellReadOnly(subject, { cwd, home, readableRoots }).readOnly) {
542
+ logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
543
+ return allow();
544
+ }
526
545
  } else if (containmentEligible && isWritingTool(normalizeToolName(toolName))) {
527
546
  // A write/edit whose target is inside the project and not a credential
528
547
  // path is ordinary sandbox work — Claude Code auto-approves it, and so do
@@ -717,6 +736,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
717
736
  scratchpadDirPath = sessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
718
737
  // Resolved like the subjects compared against it (a symlinked parent, macOS /var).
719
738
  resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
739
+ sessionDirPath = resolvedOrSelf(ctx.sessionManager.getSessionDir());
740
+ readableRoots = [memoryDirPath, scratchpadDirPath, resultsDirPath, sessionDirPath].filter((d): d is string => !!d).map(resolvedOrSelf);
720
741
  resolvedCwd = resolveForContainment(ctx.cwd);
721
742
  oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
722
743
  protectedDirs = runtimeProtectedDirs();
@@ -771,8 +792,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
771
792
  // Claude Code cycles permission modes with shift+tab; pi reserves that key
772
793
  // for the thinking dial. ctrl+q is the one ctrl+letter pi leaves unbound
773
794
  // that terminals don't already own (ctrl+m *is* Enter's byte, alt needs
774
- // option-as-meta configured on macOS) — see docs/decisions.md.
775
- pi.registerShortcut("ctrl+q", {
795
+ // option-as-meta configured on macOS) — except on Windows and WSL, where
796
+ // pi binds ctrl+q itself and the key is alt+m (lib/keys.ts) — see
797
+ // docs/decisions.md.
798
+ pi.registerShortcut(CYCLE_KEY, {
776
799
  description: "Cycle permission mode",
777
800
  handler: (ctx) => {
778
801
  badgeCtx = ctx;
@@ -823,7 +846,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
823
846
  // command that actually runs). The lookup is by toolCallId on purpose: a
824
847
  // value inside `event.input` would be the model's to write, and rules would
825
848
  // match a string of its choosing.
826
- const original = normalizedTool === "bash" ? originalCommands.get(event.toolCallId) : undefined;
849
+ const original = isShellTool(normalizedTool) ? originalCommands.get(event.toolCallId) : undefined;
827
850
  const matchSubject = original?.command ?? subject;
828
851
  const callCwd = original?.cwd ?? ctx.cwd;
829
852
 
@@ -835,6 +858,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
835
858
  const recordedInput =
836
859
  original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>);
837
860
  transcript.push({ kind: "tool", tool: normalizedTool, input: recordedInput });
861
+ // Claude Code follows a `git status` call with a ground-truth line the
862
+ // classifier can trust over the model's account of the tree
863
+ // (`{"meta":{"gitStatus":{"clean":…}}}`, captured 2.1.276 — findings §22).
864
+ const recordedCommand = typeof recordedInput.command === "string" ? recordedInput.command : undefined;
865
+ if (isShellTool(normalizedTool) && recordedCommand && isGitStatusCommand(recordedCommand)) {
866
+ const clean = await gitStatusClean(callCwd);
867
+ if (clean !== undefined) transcript.push({ kind: "meta", gitStatus: { clean } });
868
+ }
838
869
  capTranscript();
839
870
  }
840
871
 
@@ -854,6 +885,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
854
885
  memoryDirPath,
855
886
  scratchpadDirPath,
856
887
  resultsDirPath,
888
+ sessionDirPath,
857
889
  protectedDirs,
858
890
  });
859
891
  let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
@@ -1074,6 +1106,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1074
1106
  memoryDirPath,
1075
1107
  scratchpadDirPath,
1076
1108
  resultsDirPath,
1109
+ sessionDirPath,
1077
1110
  protectedDirs,
1078
1111
  });
1079
1112
 
@@ -1111,7 +1144,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1111
1144
  const appendEntry: TranscriptEntry = {
1112
1145
  kind: "tool",
1113
1146
  tool: normalizedTool,
1114
- input: normalizedTool === "bash" ? { command: subject } : input,
1147
+ input: isShellTool(normalizedTool) ? { command: subject } : input,
1115
1148
  };
1116
1149
  const outcome = await runClassifier(toolName, input, subject, ctx, result.cause !== "protected-path", {
1117
1150
  cwd,
@@ -1183,9 +1216,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1183
1216
  };
1184
1217
  };
1185
1218
 
1186
- pi.registerCommand("permissions", {
1219
+ registerLocalCommand(pi, "permissions", {
1187
1220
  description: "Show permission mode and loaded rules",
1188
- handler: async (_args, ctx) => {
1221
+ handler: async (args, ctx) => {
1189
1222
  const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
1190
1223
  const denials = pauseTracker.recentDenials();
1191
1224
  const autoLines =
@@ -1249,7 +1282,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1249
1282
  // there is no harm floor to short-circuit for a run that already happened.
1250
1283
  const childEntries: TranscriptEntry[] = actions.map((action) => {
1251
1284
  const tool = normalizeToolName(action.toolName);
1252
- return { kind: "tool", tool, input: tool === "bash" ? { command: action.subject } : { subject: action.subject } };
1285
+ return { kind: "tool", tool, input: isShellTool(tool) ? { command: action.subject } : { subject: action.subject } };
1253
1286
  });
1254
1287
  const verdict = await classify(
1255
1288
  {
@@ -1307,16 +1340,12 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1307
1340
  }
1308
1341
  // Background/resident: the spawning call already returned, so there is no
1309
1342
  // tool_result to attach to. The emitter holds its completion report until
1310
- // we answer `onReview`, so the verdict travels with the report (a payload
1311
- // without the callback gets the older standalone notice instead). Answer
1343
+ // we answer `onReview`, so the verdict travels with the report. Answer
1312
1344
  // synchronously when no review will run, so the report is never delayed
1313
1345
  // for nothing. A review that throws fails closed: the report goes out
1314
1346
  // flagged, not clean.
1315
- const respond =
1316
- payload.onReview ??
1317
- ((flag: string | undefined) => {
1318
- if (flag) notifyTask("subagent-review", flag);
1319
- });
1347
+ const respond = payload.onReview;
1348
+ if (!respond) return; // every background emitter supplies the callback (hand-back-review.ts)
1320
1349
  const ctx = lastReviewCtx;
1321
1350
  if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) {
1322
1351
  respond(undefined);
@@ -1326,12 +1355,12 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1326
1355
  const epoch = sessionEpoch;
1327
1356
  // A review that outlives its session (a /clear mid-review) is dropped: the
1328
1357
  // agents were stopped with the old session and its ctx no longer renders.
1329
- const respondIfCurrent = (flag: string | undefined) => {
1330
- if (epoch === sessionEpoch) respond(flag);
1358
+ const respondIfCurrent = (verdict: HandBackVerdict | undefined) => {
1359
+ if (epoch === sessionEpoch) respond(verdict);
1331
1360
  };
1332
1361
  reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
1333
- .then((reason) => respondIfCurrent(reason ? reviewFlagged(reason) : undefined))
1334
- .catch((error) => respondIfCurrent(reviewFlagged(`the review itself failed (${(error as Error).message})`)));
1362
+ .then((reason) => respondIfCurrent(reason ? { kind: "blocked", reason } : undefined))
1363
+ .catch((error) => respondIfCurrent({ kind: "unavailable", reason: `the review itself failed (${(error as Error).message})` }));
1335
1364
  });
1336
1365
 
1337
1366
  pi.on("tool_result", async (event, ctx) => {
@@ -1533,7 +1562,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1533
1562
  }
1534
1563
  };
1535
1564
 
1536
- pi.registerCommand("auto-mode", {
1565
+ registerLocalCommand(pi, "auto-mode", {
1537
1566
  description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
1538
1567
  getArgumentCompletions: () =>
1539
1568
  [
@@ -1619,7 +1648,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1619
1648
  },
1620
1649
  });
1621
1650
 
1622
- pi.registerCommand("allow", {
1651
+ registerLocalCommand(pi, "allow", {
1623
1652
  description: 'Persist an allow rule: /allow Bash(npm test:*) [global]',
1624
1653
  handler: async (args, ctx) => {
1625
1654
  const global = /\s+global$/.test(args.trim());