one-code-extension 0.3.0 → 0.4.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 (119) hide show
  1. package/README.md +189 -94
  2. package/docs/guide/README.md +3 -2
  3. package/docs/guide/bring-your-claude-code-setup.md +7 -5
  4. package/docs/guide/configuration.md +7 -5
  5. package/docs/guide/differences-from-claude-code.md +25 -14
  6. package/docs/guide/doctor.md +5 -3
  7. package/docs/guide/hooks.md +20 -6
  8. package/docs/guide/installation.md +62 -12
  9. package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
  10. package/docs/guide/providers-and-models.md +12 -10
  11. package/docs/guide/reference.md +9 -4
  12. package/docs/guide/sessions-and-context.md +23 -3
  13. package/docs/guide/skills-plugins-and-mcp.md +7 -5
  14. package/docs/guide/subagents-and-workflows.md +13 -11
  15. package/docs/guide/tasks-and-background-work.md +10 -8
  16. package/docs/guide/terminal-interface.md +7 -5
  17. package/docs/guide/tools.md +29 -5
  18. package/docs/guide/troubleshooting.md +26 -14
  19. package/docs/guide/windows.md +184 -0
  20. package/extensions/auto-mode/actions.ts +3 -1
  21. package/extensions/auto-mode/paths.ts +21 -23
  22. package/extensions/auto-mode/safety-floor.ts +44 -6
  23. package/extensions/auto-mode/shell-analysis.ts +25 -10
  24. package/extensions/auto-mode/transcript.ts +15 -5
  25. package/extensions/background/index.ts +83 -34
  26. package/extensions/background/monitor-batch.ts +7 -5
  27. package/extensions/background/wakeup.ts +18 -24
  28. package/extensions/bash/background.ts +19 -10
  29. package/extensions/bash/guards.ts +36 -16
  30. package/extensions/bash/index.ts +36 -166
  31. package/extensions/branding/index.ts +35 -4
  32. package/extensions/btw/index.ts +207 -0
  33. package/extensions/btw/panel.ts +179 -0
  34. package/extensions/btw/prompt.ts +45 -0
  35. package/extensions/clear/index.ts +15 -0
  36. package/extensions/compaction/fit.ts +9 -6
  37. package/extensions/doctor/build.ts +22 -2
  38. package/extensions/doctor/dependencies.ts +63 -31
  39. package/extensions/doctor/fix-prompt.ts +12 -4
  40. package/extensions/doctor/index.ts +1 -0
  41. package/extensions/doctor/models.ts +2 -1
  42. package/extensions/doctor/report.ts +2 -3
  43. package/extensions/doctor/update-lookup.ts +19 -11
  44. package/extensions/effort/index.ts +2 -1
  45. package/extensions/footer/footer-line.ts +19 -0
  46. package/extensions/footer/index.ts +25 -5
  47. package/extensions/hooks/executor.ts +84 -18
  48. package/extensions/hooks/index.ts +1 -0
  49. package/extensions/hooks/matcher.ts +23 -9
  50. package/extensions/hooks/settings.ts +15 -0
  51. package/extensions/lib/branch-restore.ts +12 -0
  52. package/extensions/lib/claude-context.ts +2 -3
  53. package/extensions/lib/claude-settings.ts +19 -0
  54. package/extensions/lib/command-launch.ts +71 -0
  55. package/extensions/lib/deferred.ts +173 -33
  56. package/extensions/lib/git.ts +16 -0
  57. package/extensions/lib/keys.ts +38 -0
  58. package/extensions/lib/local-command.ts +112 -0
  59. package/extensions/lib/model-facts.generated.json +263 -68
  60. package/extensions/lib/model-policy.ts +13 -0
  61. package/extensions/lib/model-tier.ts +16 -5
  62. package/extensions/lib/notifications.ts +547 -53
  63. package/extensions/lib/paths.ts +130 -2
  64. package/extensions/lib/pi-ai-estimate.ts +57 -26
  65. package/extensions/lib/pi-version.ts +1 -1
  66. package/extensions/lib/process-tree.ts +131 -5
  67. package/extensions/lib/reminders.ts +80 -27
  68. package/extensions/lib/scratchpad.ts +11 -10
  69. package/extensions/lib/shell-spawn.ts +298 -0
  70. package/extensions/lib/shell-tool.ts +252 -0
  71. package/extensions/lib/shell-tools.ts +13 -0
  72. package/extensions/lib/side-call.ts +76 -0
  73. package/extensions/lib/tui-render.ts +62 -11
  74. package/extensions/lib/update-check.mjs +155 -0
  75. package/extensions/lib/usage-bus.ts +1 -1
  76. package/extensions/lib/which.ts +62 -0
  77. package/extensions/lib/worktree-channel.ts +18 -0
  78. package/extensions/lib/worktree-isolation.ts +2 -1
  79. package/extensions/lsp/client.ts +14 -3
  80. package/extensions/lsp/index.ts +12 -5
  81. package/extensions/lsp/install-hints.ts +33 -3
  82. package/extensions/lsp/plugin-servers.ts +2 -2
  83. package/extensions/lsp/servers.ts +29 -7
  84. package/extensions/mcp/index.ts +3 -2
  85. package/extensions/memory/entries.ts +9 -6
  86. package/extensions/memory/index.ts +3 -2
  87. package/extensions/memory/open-external.ts +4 -1
  88. package/extensions/model-default/index.ts +21 -2
  89. package/extensions/permissions/index.ts +57 -28
  90. package/extensions/permissions/matcher.ts +120 -33
  91. package/extensions/permissions/modes.ts +14 -6
  92. package/extensions/permissions/powershell-rules.ts +549 -0
  93. package/extensions/permissions/session-grant.ts +9 -9
  94. package/extensions/plugins/index.ts +3 -2
  95. package/extensions/plugins/install/install.ts +1 -1
  96. package/extensions/powershell/description.ts +139 -0
  97. package/extensions/powershell/guards.ts +91 -0
  98. package/extensions/powershell/index.ts +89 -0
  99. package/extensions/powershell/policy.ts +91 -0
  100. package/extensions/recap/index.ts +10 -24
  101. package/extensions/recap/prompt.ts +3 -3
  102. package/extensions/session-title/index.ts +120 -0
  103. package/extensions/session-title/title.ts +111 -0
  104. package/extensions/skill/index.ts +40 -18
  105. package/extensions/skill/invoke.ts +1 -0
  106. package/extensions/subagents/hand-back-review.ts +10 -12
  107. package/extensions/subagents/index.ts +221 -78
  108. package/extensions/subagents/model-select.ts +75 -7
  109. package/extensions/system-prompt/environment.ts +14 -1
  110. package/extensions/system-reminder/index.ts +9 -0
  111. package/extensions/tasks/index.ts +2 -1
  112. package/extensions/tool-search/index.ts +41 -20
  113. package/extensions/workflow/agent-session.ts +6 -4
  114. package/extensions/workflow/index.ts +28 -5
  115. package/extensions/worktree/guards.ts +5 -4
  116. package/extensions/worktree/index.ts +15 -2
  117. package/extensions/worktree/rewrite.ts +17 -0
  118. package/package.json +7 -4
  119. package/types/app.d.ts +18 -2
@@ -8,11 +8,20 @@
8
8
  */
9
9
 
10
10
  import { homedir } from "node:os";
11
- import { isAbsolute, relative, resolve, sep } from "node:path";
11
+ import { isAbsolute, relative, resolve } from "node:path";
12
12
  import { analyzeShellCommand, hasInjectionSyntax, leadTokens, parseCommand, resolvePayload } from "../auto-mode/shell-analysis.ts";
13
- import { pathArgument } from "../auto-mode/paths.ts";
13
+ import { pathArgument, resolveForContainment } from "../auto-mode/paths.ts";
14
14
  import { isProtectedPath, isWritingTool } from "./protected-paths.ts";
15
- import { expandTilde } from "../lib/paths.ts";
15
+ import {
16
+ canonicalCommandName,
17
+ canonicalizeStatement,
18
+ powershellInjectionSyntax,
19
+ powershellMatchForms,
20
+ powershellReadOnly,
21
+ powershellStatements,
22
+ } from "./powershell-rules.ts";
23
+ import { comparablePath, expandTilde, foldsCase, forwardSlashes, isRelativeInside, toPosixPath } from "../lib/paths.ts";
24
+ import { isShellToolName } from "../lib/shell-tools.ts";
16
25
 
17
26
  export type PermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" | "dontAsk" | "auto";
18
27
  /** "classify" is auto mode's outcome: hand the call to the approval classifier. */
@@ -21,6 +30,7 @@ export type PermissionDecision = "allow" | "deny" | "ask" | "classify";
21
30
  /** Claude Code tool name (lowercased) → One Code tool name. */
22
31
  const CC_TOOL_NAMES: Record<string, string> = {
23
32
  bash: "bash",
33
+ powershell: "powershell",
24
34
  read: "read",
25
35
  edit: "edit",
26
36
  write: "write",
@@ -153,7 +163,7 @@ export function escapeLiteral(command: string): string {
153
163
  }
154
164
 
155
165
  /** Glob → RegExp. `**` crosses path separators, `*` does not. `\*` / `\\` are literals. */
156
- function globToRegex(glob: string, pathMode: boolean): RegExp {
166
+ function globToRegex(glob: string, pathMode: boolean, ignoreCase = false): RegExp {
157
167
  let out = "";
158
168
  for (let i = 0; i < glob.length; i++) {
159
169
  const ch = glob[i];
@@ -174,7 +184,7 @@ function globToRegex(glob: string, pathMode: boolean): RegExp {
174
184
  out += ch;
175
185
  }
176
186
  }
177
- return new RegExp(`^${out}$`);
187
+ return new RegExp(`^${out}$`, ignoreCase ? "i" : "");
178
188
  }
179
189
 
180
190
  /**
@@ -279,13 +289,41 @@ export function bashMatchForms(command: string, depth = 0): string[] {
279
289
  * `Bash(rm:*)` (CC: "deny/ask rules must match compound commands so they can't
280
290
  * be bypassed"). Unparseable lines are matched as a whole only.
281
291
  */
282
- function bashPatternMatchesAny(pattern: string, command: string): boolean {
283
- // decide() tests every deny rule, then every ask rule, against the same
284
- // command; the forms depend on the command alone, so the last parse is kept.
285
- if (lastForms?.command !== command) lastForms = { command, forms: bashMatchForms(command) };
286
- return lastForms.forms.some((form) => matchesBashPattern(pattern, form));
292
+ /**
293
+ * "Any form matches" with a one-entry memo of the forms: decide() tests every
294
+ * deny rule, then every ask rule, against the same command, and the forms
295
+ * depend on the command alone, so the last parse is kept per shell grammar.
296
+ */
297
+ function anyFormMatcher(formsOf: (command: string) => string[], matches: (pattern: string, form: string) => boolean) {
298
+ let last: { command: string; forms: string[] } | undefined;
299
+ return (pattern: string, command: string): boolean => {
300
+ if (last?.command !== command) last = { command, forms: formsOf(command) };
301
+ return last.forms.some((form) => matches(pattern, form));
302
+ };
303
+ }
304
+
305
+ const bashPatternMatchesAny = anyFormMatcher(bashMatchForms, matchesBashPattern);
306
+
307
+ /**
308
+ * PowerShell's Bash-pattern match against ONE statement: both sides have their
309
+ * command word alias-canonicalized (`ls` ≡ `Get-ChildItem`) and are compared
310
+ * case-insensitively (cmdlet names are), then Claude Code's Bash pattern rules
311
+ * apply unchanged — exact, `prefix:*`, wildcards (powershell-rules.ts).
312
+ */
313
+ export function matchesPowerShellPattern(pattern: string, statement: string): boolean {
314
+ const canonicalPattern = pattern.endsWith(":*")
315
+ ? `${canonicalizeStatement(pattern.slice(0, -2))}:*`
316
+ : canonicalizeStatement(pattern);
317
+ return matchesBashPattern(canonicalPattern.toLowerCase(), canonicalizeStatement(statement).toLowerCase());
318
+ }
319
+
320
+ /** Deny/ask semantics for PowerShell: the pattern covers the line, any statement, or any canonical/nested form. */
321
+ const powershellPatternMatchesAny = anyFormMatcher(powershellMatchForms, matchesPowerShellPattern);
322
+
323
+ /** The tools whose subject is a shell command line and whose rules follow the Bash shape (lib/shell-tools.ts). */
324
+ export function isShellTool(toolName: string): boolean {
325
+ return isShellToolName(normalizeToolName(toolName));
287
326
  }
288
- let lastForms: { command: string; forms: string[] } | undefined;
289
327
 
290
328
  /**
291
329
  * The allow rule that lets a bash command run, or undefined. CC semantics: an
@@ -312,12 +350,17 @@ export function findBashAllowRule(rules: PermissionRule[], command: string, tool
312
350
  (r) => r.pattern !== undefined && !hasUnescapedWildcard(r.pattern) && unescapeLiteral(r.pattern) === cmd,
313
351
  );
314
352
  if (exact) return exact;
315
- if (hasInjectionSyntax(cmd)) return undefined;
316
- const subs = bashSubcommands(cmd);
353
+ // PowerShell lines split on PowerShell's separators and match alias-
354
+ // canonicalized, case-insensitively (powershell-rules.ts); the shape of
355
+ // the rule — every statement covered, no injection syntax — is the same.
356
+ const powershell = normalizeToolName(toolName) === "powershell";
357
+ if (powershell ? powershellInjectionSyntax(cmd) : hasInjectionSyntax(cmd)) return undefined;
358
+ const subs = powershell ? powershellStatements(cmd) : bashSubcommands(cmd);
317
359
  if (!subs || subs.length === 0) return undefined;
360
+ const matches = powershell ? matchesPowerShellPattern : matchesBashPattern;
318
361
  let first: PermissionRule | undefined;
319
362
  for (const sub of subs) {
320
- const rule = bashRules.find((r) => r.pattern !== undefined && matchesBashPattern(r.pattern, sub));
363
+ const rule = bashRules.find((r) => r.pattern !== undefined && matches(r.pattern, sub));
321
364
  if (!rule) return undefined;
322
365
  first ??= rule;
323
366
  }
@@ -329,23 +372,44 @@ export function findBashAllowRule(rules: PermissionRule[], command: string, tool
329
372
  * (with `.`/`..` normalised), cwd-relative, and `~/`-relative — never the raw
330
373
  * spelling: `Edit(docs/**)` must not match `docs/../src/main.ts`, and a
331
374
  * `~/.ssh/id_rsa` subject must hit `Read(~/.ssh/**)` (review P6).
375
+ *
376
+ * Every candidate is spelled with forward slashes: patterns are `/`-globs
377
+ * (gitignore semantics, as in Claude Code), so on Windows the native
378
+ * `C:\Users\x\docs\a.md` is matched as Claude Code matches it — its POSIX form
379
+ * `/c/Users/x/docs/a.md` (`lib/paths.ts toPosixPath`, the shape CC's own
380
+ * Windows rules such as `Read(//c/Users/x/**)` are written in) — and also as
381
+ * `C:/Users/x/docs/a.md`, so a rule spelled with the drive letter works too.
382
+ * darwin and win32 compare case-insensitively, like their filesystems.
332
383
  */
333
384
  export function matchesPathPattern(pattern: string, subject: string, cwd: string): boolean {
334
385
  const home = homedir();
386
+ const win32 = process.platform === "win32";
335
387
  // Claude Code's rule syntax: `//path` is an absolute filesystem path (the
336
388
  // doubled slash distinguishes it from `/path`, which CC reads relative to
337
389
  // the project root). `Read(//etc/**)` is the form CC's own suggestions write.
338
- const expandedPattern = pattern.startsWith("//") ? expandTilde(pattern.slice(1), home) : expandTilde(pattern, home);
390
+ // A Windows pattern spelled with backslash separators is read as its `/`
391
+ // form; `\*` and `\\` stay the literal escapes globToRegex documents.
392
+ const spelled = win32 ? pattern.replace(/\\(?![*\\])/g, "/") : pattern;
393
+ // `~` in a pattern expands to the home dir spelled with `/` (never `join`,
394
+ // whose backslashes would read as escapes on Windows); it matches the
395
+ // `C:/Users/x/…` candidate below.
396
+ const expandHome = (p: string) => (p === "~" ? forwardSlashes(home) : p.startsWith("~/") ? `${forwardSlashes(home)}/${p.slice(2)}` : p);
397
+ const expandedPattern = expandHome(spelled.startsWith("//") ? spelled.slice(1) : spelled);
339
398
  const expandedSubject = expandTilde(subject, home);
340
399
 
341
400
  const candidates = new Set<string>();
342
401
  const absolute = isAbsolute(expandedSubject) ? resolve(expandedSubject) : resolve(cwd, expandedSubject);
343
- candidates.add(absolute);
402
+ candidates.add(toPosixPath(absolute));
403
+ if (win32) candidates.add(forwardSlashes(absolute));
344
404
  const rel = relative(cwd, absolute);
345
- if (rel && !rel.startsWith("..")) candidates.add(rel);
346
- if (absolute.startsWith(`${home}/`)) candidates.add(`~/${absolute.slice(home.length + 1)}`);
347
-
348
- const regex = globToRegex(expandedPattern, true);
405
+ if (isRelativeInside(rel)) candidates.add(forwardSlashes(rel));
406
+ const homeRel = relative(resolve(home), absolute);
407
+ if (isRelativeInside(homeRel)) candidates.add(`~/${forwardSlashes(homeRel)}`);
408
+
409
+ // Case-insensitive where the filesystem is (darwin, win32): `Read(~/Notes/**)`
410
+ // covers `~/notes/a.md`, the same file there — the rule every other
411
+ // containment check in this file compares by (comparablePath).
412
+ const regex = globToRegex(expandedPattern, true, foldsCase());
349
413
  for (const candidate of candidates) {
350
414
  if (regex.test(candidate)) return true;
351
415
  }
@@ -362,7 +426,7 @@ export type SubjectKind = "command" | "path" | "url" | "text";
362
426
 
363
427
  export function subjectKind(toolName: string): SubjectKind {
364
428
  const name = normalizeToolName(toolName);
365
- if (name === "bash" || name === "monitor") return "command";
429
+ if (isShellToolName(name) || name === "monitor") return "command";
366
430
  if (name === "web_fetch") return "url";
367
431
  if (isPathSubjectTool(name)) return "path";
368
432
  return "text";
@@ -391,6 +455,7 @@ export function extractSubject(toolName: string, input: Record<string, unknown>)
391
455
  const str = (key: string): string | undefined => (typeof input[key] === "string" ? (input[key] as string) : undefined);
392
456
  switch (name) {
393
457
  case "bash":
458
+ case "powershell":
394
459
  case "monitor":
395
460
  return str("command") ?? "";
396
461
  case "web_fetch":
@@ -444,8 +509,7 @@ function matchesUrlPattern(pattern: string, url: string): boolean {
444
509
  * `/home/u/project`.
445
510
  */
446
511
  function toAbsoluteFolded(p: string, cwd: string): string {
447
- const absolute = resolve(cwd, expandTilde(p, homedir()));
448
- return process.platform === "linux" ? absolute : absolute.toLowerCase();
512
+ return comparablePath(resolve(cwd, expandTilde(p, homedir())));
449
513
  }
450
514
 
451
515
  function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): boolean {
@@ -460,7 +524,7 @@ function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): b
460
524
  * case-folded from `resolveForContainment`.
461
525
  */
462
526
  export function isInsideDir(candidate: string, dir: string, cwd: string): boolean {
463
- return toAbsoluteFolded(candidate, cwd).startsWith(toAbsoluteFolded(dir, cwd) + sep);
527
+ return toAbsoluteFolded(candidate, cwd).startsWith(`${toAbsoluteFolded(dir, cwd)}/`);
464
528
  }
465
529
 
466
530
  /** `isInsideDir`, plus the directory itself (`ls <cwd>` lists the working directory). */
@@ -478,7 +542,9 @@ export function ruleMatches(rule: PermissionRule, toolName: string, subject: str
478
542
  if (!subject) return false;
479
543
  switch (subjectKind(toolName)) {
480
544
  case "command":
481
- return bashPatternMatchesAny(rule.pattern, subject);
545
+ return normalizeToolName(toolName) === "powershell"
546
+ ? powershellPatternMatchesAny(rule.pattern, subject)
547
+ : bashPatternMatchesAny(rule.pattern, subject);
482
548
  case "path":
483
549
  return matchesPathPattern(rule.pattern, subject, cwd);
484
550
  case "url":
@@ -514,7 +580,7 @@ export function toolTier(toolName: string): ToolTier {
514
580
  const name = normalizeToolName(toolName);
515
581
  if (SAFE_TOOLS.has(name)) return "safe";
516
582
  if (EDIT_TOOLS.has(name)) return "edit";
517
- if (name === "bash") return "execute";
583
+ if (isShellToolName(name)) return "execute";
518
584
  return "custom";
519
585
  }
520
586
 
@@ -625,6 +691,17 @@ export interface DecideInput {
625
691
  * told to read those files back.
626
692
  */
627
693
  resultsDirPath?: string;
694
+ /**
695
+ * This project's pi session directory (`<agentDir>/sessions/<encoded-cwd>`):
696
+ * the session transcripts and, next to them, auto mode's decision log.
697
+ * Readable like the working directory — Claude Code lets a session read
698
+ * its own project directory under its config root, which is where its
699
+ * transcripts live (`checkReadableInternalPath`, "Project directory files
700
+ * are allowed for reading"), and /doctor's evidence gathering depends on
701
+ * it. Other projects' session directories are not in the set. Never a
702
+ * write root: the agent dir stays protected.
703
+ */
704
+ sessionDirPath?: string;
628
705
  /**
629
706
  * Directories protected at runtime, beyond the static list in
630
707
  * protected-paths.ts (see `runtimeProtectedDirs()`, the sole producer): pi's
@@ -684,7 +761,7 @@ const DELEGATION_TOOLS = new Set(["Agent", "workflow", "SendMessage"]);
684
761
  * surely as `Bash(*)` does.
685
762
  */
686
763
  const INTERPRETERS_AND_RUNNERS =
687
- /^(python[0-9.]*|python3|node|deno|bun|ruby|perl|php|osascript|bash|sh|zsh|fish|pwsh|powershell|eval|exec|env|xargs|nohup|setsid|timeout|make|npx|pnpx|yarn|npm|pnpm|bunx|uv|uvx|pip[0-9]*|poetry|cargo|go|dotnet|java|mvn|gradle|docker|kubectl|ssh)\b/;
764
+ /^(python[0-9.]*|python3|node|deno|bun|ruby|perl|php|osascript|bash|sh|zsh|fish|pwsh|powershell|cmd|wsl|eval|exec|env|xargs|nohup|setsid|timeout|make|npx|pnpx|yarn|npm|pnpm|bunx|uv|uvx|pip[0-9]*|poetry|cargo|go|dotnet|java|mvn|gradle|docker|kubectl|ssh|invoke-expression|invoke-command|start-process|start-job)\b/;
688
765
 
689
766
  /**
690
767
  * An allow rule broad enough to grant arbitrary code execution. Auto mode
@@ -700,7 +777,7 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
700
777
  // Delegation rules are dropped outright: a subagent is a fresh agent loop, so
701
778
  // pre-approving one pre-approves whatever that loop decides to do.
702
779
  if (DELEGATION_TOOLS.has(rule.tool)) return true;
703
- if (rule.tool !== "bash") return false;
780
+ if (!isShellToolName(rule.tool)) return false;
704
781
 
705
782
  if (!rule.pattern) return true;
706
783
  const pattern = rule.pattern.trim().toLowerCase();
@@ -710,10 +787,13 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
710
787
  if (!hasUnescapedWildcard(pattern)) return false;
711
788
  if (/^(\*|:\*|\*\*)$/.test(pattern)) return true;
712
789
 
713
- const head = pattern.split(/[\s*]/)[0] ?? "";
790
+ const rawHead = pattern.split(/[\s*]/)[0] ?? "";
791
+ // A PowerShell alias is as broad as its cmdlet: `PowerShell(iex *)` is
792
+ // `Invoke-Expression *`, `PowerShell(saps *)` is `Start-Process *`.
793
+ const head = rule.tool === "powershell" ? canonicalCommandName(rawHead).toLowerCase() : rawHead;
714
794
  if (!INTERPRETERS_AND_RUNNERS.test(head)) return false;
715
795
 
716
- const rest = pattern.slice(head.length).trim();
796
+ const rest = pattern.slice(rawHead.length).trim();
717
797
  // Nothing constrains the arguments: `python*`, `node *`, `npm *`.
718
798
  if (/^[:\s]*\*+$/.test(rest)) return true;
719
799
  // The runner's own escape hatch takes arbitrary code: `npm run *`, `npx *`.
@@ -762,14 +842,21 @@ export function decide(params: DecideInput): Decision {
762
842
  // redirect writes, which it records; a safe verdict with no writes is
763
843
  // exactly read-only. Anything it cannot vouch for is denied as before.
764
844
  // Without this the frontier tier (no grep/find/ls) was left with `read` alone.
845
+ // The harness's readable session dirs, as the auto-mode pre-gate sees them
846
+ // (permissions/index.ts `readableRoots`): a plan-mode read of this project's
847
+ // transcripts is a read like any other.
848
+ const readableRoots = [params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath, params.sessionDirPath].filter((d): d is string => !!d).map((d) => resolveForContainment(d) ?? d);
765
849
  if (tool === "bash" && subject) {
766
- const evidence = analyzeShellCommand({ command: subject, cwd, home: homedir(), protectedDirs: params.protectedDirs });
850
+ const evidence = analyzeShellCommand({ command: subject, cwd, home: homedir(), protectedDirs: params.protectedDirs, readableRoots });
767
851
  if (evidence.verdict === "safe" && evidence.writes.length === 0) return { decision: "allow", cause: "plan-readonly" };
768
852
  // Read-only, but of a path outside the working directory: still a read,
769
853
  // so it is put to the user rather than refused as a plan-mode mutation
770
854
  // (the read tools ask for the same path below).
771
855
  if (evidence.readOnlyOutside && evidence.writes.length === 0) return { decision: "ask", cause: "working-dir" };
772
856
  }
857
+ // The PowerShell counterpart: Claude Code's read-only cmdlet allowlist,
858
+ // with in-project paths by shape (powershell-rules.ts).
859
+ if (tool === "powershell" && subject && powershellReadOnly(subject, { cwd, home: homedir(), readableRoots }).readOnly) return { decision: "allow", cause: "plan-readonly" };
773
860
  if (PLAN_READ_ONLY_TOOLS.has(tool)) return { decision: "allow", cause: "plan-readonly" };
774
861
  return { decision: "deny", cause: "plan-mode" };
775
862
  }
@@ -825,7 +912,7 @@ export function decide(params: DecideInput): Decision {
825
912
  mode === "auto"
826
913
  ? allow.filter((rule) => {
827
914
  if (isBroadExecutionRule(rule)) return false;
828
- if (params.classifyAllShell && rule.tool === "bash") return false;
915
+ if (params.classifyAllShell && isShellTool(rule.tool)) return false;
829
916
  return true;
830
917
  })
831
918
  : allow;
@@ -857,7 +944,7 @@ export function decide(params: DecideInput): Decision {
857
944
  */
858
945
  const inWorkingSpace = (): boolean => {
859
946
  const target = params.resolvedSubject ?? subject;
860
- const roots = [cwd, params.resolvedCwd, params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath];
947
+ const roots = [cwd, params.resolvedCwd, params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath, params.sessionDirPath];
861
948
  if (roots.some((dir) => dir && isAtOrInsideDir(target, dir, cwd))) return true;
862
949
  return params.planFilePath ? isPlanFilePath(target, params.planFilePath, cwd) : false;
863
950
  };
@@ -10,12 +10,18 @@
10
10
  */
11
11
 
12
12
  import type { PermissionMode } from "./matcher.ts";
13
+ import { modeCycleKey, pauseGlyph } from "../lib/keys.ts";
13
14
 
14
- /** Footer badge per mode — Claude Code's exact strings and icons. */
15
+ /**
16
+ * Footer badge per mode — Claude Code's exact strings and icons. The pause
17
+ * icon is swapped for a single-cell glyph on Windows and WSL, where Windows
18
+ * Terminal draws U+23F8 two cells wide and over the mode name (lib/keys.ts).
19
+ */
20
+ const PAUSE = pauseGlyph();
15
21
  export const MODE_BADGES: Record<PermissionMode, string> = {
16
- default: "⏸ manual mode on",
22
+ default: `${PAUSE} manual mode on`,
17
23
  acceptEdits: "⏵⏵ accept edits on",
18
- plan: "⏸ plan mode on",
24
+ plan: `${PAUSE} plan mode on`,
19
25
  bypassPermissions: "⏵⏵ bypass permissions on",
20
26
  dontAsk: "⏵⏵ don't ask on",
21
27
  auto: "⏵⏵ auto mode on",
@@ -86,9 +92,11 @@ export function isRealModel(id: string | undefined): id is string {
86
92
  /**
87
93
  * The key that cycles permission modes. Claude Code cycles on shift+tab and
88
94
  * says so in this footer; pi reserves shift+tab for the effort dial, so One Code
89
- * cycles on ctrl+q (see docs/decisions/modes.md) and the hint names that key.
95
+ * cycles on ctrl+q (see docs/decisions/modes.md) — alt+m on Windows and WSL,
96
+ * where pi owns ctrl+q (lib/keys.ts) — and the hint names that key.
90
97
  */
91
- export const CYCLE_KEY_HINT = "ctrl+q to cycle";
98
+ export const CYCLE_KEY = modeCycleKey();
99
+ export const CYCLE_KEY_HINT = `${CYCLE_KEY} to cycle`;
92
100
 
93
101
  /**
94
102
  * The footer badge, with auto mode naming the model screening the calls. That
@@ -110,7 +118,7 @@ export function modeBadge(
110
118
  function badgeBase(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
111
119
  if (mode !== "auto") return MODE_BADGES[mode];
112
120
  const suffix = opts?.classifierModel ? ` · ${shortModelName(opts.classifierModel)}` : "";
113
- return opts?.paused ? `⏸ auto mode paused${suffix}` : `${MODE_BADGES.auto}${suffix}`;
121
+ return opts?.paused ? `${PAUSE} auto mode paused${suffix}` : `${MODE_BADGES.auto}${suffix}`;
114
122
  }
115
123
 
116
124
  /**