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
@@ -12,11 +12,11 @@
12
12
  * notification and task_output can never disagree.
13
13
  */
14
14
 
15
- import { spawn } from "node:child_process";
16
15
  import { createWriteStream } from "node:fs";
17
16
  import type { BackgroundTask } from "../background/registry.ts";
18
17
  import { whenAborted } from "../lib/abort.ts";
19
- import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree } from "../lib/process-tree.ts";
18
+ import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
19
+ import { bashSpawnOrThrow, type ShellSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
20
20
 
21
21
  export const STORED_OUTPUT_CAP = 200_000;
22
22
 
@@ -45,12 +45,18 @@ export interface StartBackgroundBashOptions {
45
45
  /** Kill the process tree after this many seconds. */
46
46
  timeoutSeconds?: number;
47
47
  logPath?: string;
48
+ /**
49
+ * The interpreter to run `command` under. Default: the session's bash
50
+ * (lib/shell-spawn.ts — Git Bash on Windows, `CLAUDE_CODE_GIT_BASH_PATH`
51
+ * honoured); the powershell extension passes its own spec.
52
+ */
53
+ shell?: ShellSpawn;
48
54
  onFinished(task: BackgroundTask, summary: BashFinishSummary): void;
49
55
  }
50
56
 
51
57
  export function startBackgroundBash(options: StartBackgroundBashOptions): BackgroundTask {
52
58
  // Own process group, so stop/timeout can signal the whole tree (lib/process-tree.ts).
53
- const child = spawn(process.env.SHELL || "/bin/sh", ["-c", options.command], {
59
+ const child = spawnShellCommand(options.shell ?? bashSpawnOrThrow(), options.command, {
54
60
  cwd: options.cwd,
55
61
  ...detachedSpawnOptions(),
56
62
  stdio: ["ignore", "pipe", "pipe"],
@@ -80,6 +86,7 @@ export function startBackgroundBash(options: StartBackgroundBashOptions): Backgr
80
86
 
81
87
  const task: BackgroundTask = {
82
88
  id: options.id,
89
+ // "bash" for a PowerShell run too: the kind is what the shell panel lists.
83
90
  kind: "bash",
84
91
  description: options.description,
85
92
  command: options.command,
@@ -124,13 +131,15 @@ export function startBackgroundBash(options: StartBackgroundBashOptions): Backgr
124
131
  else complete();
125
132
  };
126
133
 
127
- child.on("error", (error) => {
128
- stored = stored ? `${stored}\n${error.message}` : error.message;
129
- end("failed", null, null);
130
- });
131
- child.on("close", (code, signal) => {
132
- end(stopRequested ? "stopped" : timedOut || code !== 0 ? "failed" : "completed", code, signal);
133
- });
134
+ // Exit plus a short stdio grace, not `close`: a descendant the stop missed
135
+ // must not keep the task "running" for its whole life (lib/process-tree.ts).
136
+ waitForChildExit(child).then(
137
+ ({ code, signal }) => end(stopRequested ? "stopped" : timedOut || code !== 0 ? "failed" : "completed", code, signal),
138
+ (error: Error) => {
139
+ stored = stored ? `${stored}\n${error.message}` : error.message;
140
+ end("failed", null, null);
141
+ },
142
+ );
134
143
 
135
144
  return task;
136
145
  }
@@ -34,7 +34,7 @@ import {
34
34
  type Token,
35
35
  } from "../auto-mode/shell-analysis.ts";
36
36
 
37
- const clip = (text: string, max = 200): string => (text.length > max ? `${text.slice(0, max)}…` : text);
37
+ export const clip = (text: string, max = 200): string => (text.length > max ? `${text.slice(0, max)}…` : text);
38
38
 
39
39
  export function bashGuardReason(command: string, opts: { background: boolean }): string | undefined {
40
40
  // A heredoc/here-string body is data, but parseCommand tokenizes it as live
@@ -72,34 +72,54 @@ function sleepSeconds(args: Token[]): number | undefined {
72
72
  // A segment's raw slice can keep half of the operator that ended the previous
73
73
  // one (`a && b` → second raw is `& b`) — strip it for a clean echo.
74
74
 
75
- function waitReason(segments: Segment[]): string | undefined {
76
- // Measure the run of LEADING sleep segments: `sleep 1 && sleep 1 && …` is
77
- // one wait spelled as a chain, so the pacing exemption applies only to a
78
- // single provably-short sleep. Unprovable durations (`sleep "$DELAY"`)
79
- // count as long — the model controls the literal.
75
+ /**
76
+ * The wait guard's grammar-independent half, shared with the PowerShell
77
+ * guards: measure the run of LEADING sleep statements — `sleep 1 && sleep 1 &&
78
+ * …` is one wait spelled as a chain, so the pacing exemption applies only to a
79
+ * single provably-short sleep. Unprovable durations (`sleep "$DELAY"`) count
80
+ * as long: the model controls the literal. `sleepSeconds` returns undefined
81
+ * for a non-sleep statement.
82
+ */
83
+ export function leadingSleepReason<T>(
84
+ statements: T[],
85
+ sleepSeconds: (statement: T) => number | undefined | null,
86
+ raw: (statement: T) => string,
87
+ opts: { sleepName: string; joiner: string; untilExample: string },
88
+ ): string | undefined {
80
89
  let leadSleeps = 0;
81
90
  let total: number | undefined = 0;
82
- for (const seg of segments) {
83
- const { command: cmd, args } = resolvePayload(leadTokens(seg));
84
- if (cmd !== "sleep") break;
91
+ for (const statement of statements) {
92
+ const seconds = sleepSeconds(statement);
93
+ if (seconds === null) break; // not a sleep
85
94
  leadSleeps++;
86
- const seconds = sleepSeconds(args);
87
95
  total = total === undefined || seconds === undefined ? undefined : total + seconds;
88
96
  }
89
97
  if (leadSleeps === 0) return undefined;
90
98
  if (leadSleeps === 1 && total !== undefined && total < 2) return undefined;
91
- const rest = segments.slice(leadSleeps).map((seg) => seg.raw).filter(Boolean).join("; ");
92
- const shown = segments.slice(0, leadSleeps).map((seg) => seg.raw).join(" && ");
99
+ const rest = statements.slice(leadSleeps).map(raw).filter(Boolean).join("; ");
100
+ const shown = statements.slice(0, leadSleeps).map(raw).join(opts.joiner);
93
101
  const echo = rest ? `\`${shown}\` followed by: ${clip(rest)}` : `standalone \`${shown}\``;
94
102
  return (
95
- `Blocked: ${echo}. A foreground sleep stalls the whole session while it runs. ` +
96
- "To wait for a command you started, run it with run_in_background: true — its completion arrives as a system notification on its own, so you never need to poll. " +
97
- "To wait for a condition, use the monitor tool with an until-loop (e.g. `until <check>; do sleep 2; done`) — monitor is deferred, load it with tool_search select:monitor. " +
103
+ `Blocked: ${echo}. A foreground ${opts.sleepName} stalls the whole session while it runs. ` +
104
+ "To wait for a command you started, run it with run_in_background: true — its completion arrives as a task notification on its own, so you never need to poll. " +
105
+ `To wait for a condition, use the monitor tool with an until-loop (e.g. \`${opts.untilExample}\`) — monitor is deferred, load it with tool_search select:monitor. ` +
98
106
  "If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds. " +
99
107
  "Do not chain shorter sleeps to work around this block."
100
108
  );
101
109
  }
102
110
 
111
+ function waitReason(segments: Segment[]): string | undefined {
112
+ return leadingSleepReason(
113
+ segments,
114
+ (seg) => {
115
+ const { command: cmd, args } = resolvePayload(leadTokens(seg));
116
+ return cmd === "sleep" ? sleepSeconds(args) : null;
117
+ },
118
+ (seg) => seg.raw,
119
+ { sleepName: "sleep", joiner: " && ", untilExample: "until <check>; do sleep 2; done" },
120
+ );
121
+ }
122
+
103
123
  // ---------------------------------------------------------------------------
104
124
  // Poll-loop guard
105
125
 
@@ -173,7 +193,7 @@ export function hasBackgroundAmp(command: string): boolean {
173
193
  function orphanReason(command: string, segments: Segment[]): string | undefined {
174
194
  const message = (via: string) =>
175
195
  `Blocked: this command detaches a process with ${via}, leaving an orphan this session cannot manage — its output and exit status would be lost. ` +
176
- "Run it with run_in_background: true instead: it returns a task id immediately, completion arrives as a system notification, output stays readable with task_output, and it can be stopped with task_stop. " +
196
+ "Run it with run_in_background: true instead: it returns a task id immediately, completion arrives as a task notification, output stays readable with task_output, and it can be stopped with task_stop. " +
177
197
  "If you need shell-level parallelism inside one command, end it with `wait` so the children are reaped.";
178
198
 
179
199
  for (const seg of segments) {
@@ -6,39 +6,24 @@
6
6
  * is how pi's official sandbox example does it too. The foreground path
7
7
  * delegates to pi's real executor (`createBashToolDefinition`) so upstream
8
8
  * bash behavior — timeout handling, truncation, PI_* env, spawn hooks — stays
9
- * exactly pi's; this file only adds the background branch.
10
- *
11
- * A background run spawns detached, returns a task id immediately, spools
12
- * output to `<sessionDir>/bash/<taskId>/output.log`, registers on the shared
13
- * background registry (task_output/task_stop just work — they are
14
- * kind-agnostic), and announces completion as a steered system notification
15
- * (lib/notifications.ts). The permission gate and auto-mode classifier run before
16
- * execute like any bash call — a background command is NOT auto-allowed, and
17
- * the gate fires before anything detaches.
9
+ * exactly pi's; the shared body in lib/shell-tool.ts adds the background
10
+ * branch (spool to `<sessionDir>/bash/<taskId>/output.log`, the shared
11
+ * background registry, a steered completion notification), and this file only
12
+ * supplies what is bash's: pi's definition, the bash the session resolved
13
+ * (lib/shell-spawn.ts — Git Bash on Windows, `CLAUDE_CODE_GIT_BASH_PATH`
14
+ * honoured), the CC-shaped description and the bash guards. The permission
15
+ * gate and auto-mode classifier run before execute like any bash call — a
16
+ * background command is NOT auto-allowed, and the gate fires before anything
17
+ * detaches.
18
18
  */
19
19
 
20
- import { mkdirSync } from "node:fs";
21
- import { join } from "node:path";
22
- import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
21
  import { createBashToolDefinition } from "@earendil-works/pi-coding-agent";
24
22
  import { Type } from "typebox";
25
- import { generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
26
- import { type BashFinishSummary, runBackgroundBashBlocking, startBackgroundBash, tailCap } from "./background.ts";
27
- import { bashGuardReason } from "./guards.ts";
28
- import { commandToEvaluate, trackOriginalCommands } from "../lib/original-command.ts";
29
- import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, systemNotification } from "../lib/notifications.ts";
30
- import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
31
23
  import { perCwd } from "../lib/per-cwd.ts";
32
- import { ccWrapBuiltinRenderers, linesComponent, resultLines } from "../lib/tui-render.ts";
33
-
34
- // The completion notification carries status + exit code + where the output is,
35
- // plus only a short tail: a finished build used to push 30 KB (~8k tokens) into
36
- // context unasked (review T7). The full tail stays behind task_output / the log.
37
- const NOTIFY_OUTPUT_CAP = 2_000;
38
- /** One-shot modes return the whole output in the result; past this it is persisted (file + preview), never cut. */
39
- const ONE_SHOT_OUTPUT_CAP = 30_000;
40
- /** Claude Code's Bash cap; the tool's `timeout` is milliseconds, as CC's is. */
41
- const MAX_TIMEOUT_MS = 600_000;
24
+ import { bashSpawn } from "../lib/shell-spawn.ts";
25
+ import { registerShellTool } from "../lib/shell-tool.ts";
26
+ import { bashGuardReason } from "./guards.ts";
42
27
 
43
28
  const BashParams = Type.Object({
44
29
  command: Type.String({ description: "Bash command to execute" }),
@@ -46,7 +31,7 @@ const BashParams = Type.Object({
46
31
  run_in_background: Type.Optional(
47
32
  Type.Boolean({
48
33
  description:
49
- "Run detached and return a task id immediately instead of waiting. Completion arrives as a system notification; inspect with task_output, stop with task_stop. Use this instead of nohup/'&' — those leave an unmanaged orphan process",
34
+ "Run detached and return a task id immediately instead of waiting. Completion arrives as a task notification; inspect with task_output, stop with task_stop. Use this instead of nohup/'&' — those leave an unmanaged orphan process",
50
35
  }),
51
36
  ),
52
37
  description: Type.Optional(
@@ -55,11 +40,23 @@ const BashParams = Type.Object({
55
40
  });
56
41
 
57
42
  export default function bashExtension(pi: ExtensionAPI) {
43
+ // The session's bash, resolved once (lib/shell-spawn.ts). An ignored
44
+ // CLAUDE_CODE_GIT_BASH_PATH is reported once, at the first session start.
45
+ const bash = bashSpawn();
46
+ const shellPath = bash.spawn?.commandTransport === "stdin" ? undefined : bash.spawn?.shell;
47
+ let warned = false;
48
+ pi.on("session_start", (_event, ctx) => {
49
+ if (warned || !bash.warning) return;
50
+ warned = true;
51
+ if (ctx.hasUI) ctx.ui.notify(bash.warning, "warning");
52
+ else process.stderr.write(`${bash.warning}\n`);
53
+ });
54
+
58
55
  // pi's definition supplies the description and TUI renderers; the executor
59
56
  // is re-created per working directory because it closes over cwd (worktree
60
- // switches change ctx.cwd mid-session).
61
- const base = createBashToolDefinition(process.cwd());
62
- const foreground = perCwd(createBashToolDefinition);
57
+ // switches change ctx.cwd mid-session). The same bash drives pi's foreground
58
+ // executor, so the override applies there too.
59
+ const base = createBashToolDefinition(process.cwd(), { shellPath });
63
60
  // pi's base sentence is "Optionally provide a timeout in seconds." — but the
64
61
  // `timeout` parameter and the execute path both use milliseconds (Claude
65
62
  // Code's Bash unit; the executor divides by 1000). The two must not
@@ -71,141 +68,14 @@ export default function bashExtension(pi: ExtensionAPI) {
71
68
  "`timeout` is in milliseconds: default 120000, max 600000.",
72
69
  );
73
70
 
74
- const notifyTask = createTaskNotifier(pi);
75
- // A worktree session cd-wraps input.command; the pre-wrapper original arrives
76
- // over the bus keyed by toolCallId (never read from params — model-writable).
77
- const originalCommands = trackOriginalCommands(pi);
78
- const notify = (text: string, details: Record<string, unknown>) => notifyTask("task-notification", text, details);
79
-
80
- const taskLogPath = (ctx: ExtensionContext, taskId: string): string | undefined => {
81
- try {
82
- const dir = join(ctx.sessionManager.getSessionDir(), "bash", taskId);
83
- mkdirSync(dir, { recursive: true });
84
- return join(dir, "output.log");
85
- } catch {
86
- return undefined;
87
- }
88
- };
89
-
90
- const finishLine = (summary: BashFinishSummary, timeoutSeconds?: number): string => {
91
- if (summary.stopped) return "stopped";
92
- if (summary.timedOut) return `failed (timed out after ${timeoutSeconds}s)`;
93
- if (summary.exitCode === 0) return "completed";
94
- return `failed (${summary.exitCode !== null ? `exit code ${summary.exitCode}` : `terminated by ${summary.signal ?? "unknown signal"}`})`;
95
- };
96
-
97
- pi.registerTool({
71
+ registerShellTool(pi, {
98
72
  name: "bash",
99
- label: base.label,
100
- description: `${baseDescription} Pass run_in_background: true for long-running commands (builds, servers, watches): it returns a task id immediately so you can keep working, completion arrives as a system notification, and the output is retrievable with task_output / stoppable with task_stop (both deferred — load them with tool_search; in a one-shot print/json session the call runs to completion and returns the output directly). Foreground \`sleep\` is blocked; to wait on a condition use the monitor tool (deferred — load it with tool_search select:monitor) with an until-loop.`,
101
- promptSnippet: base.promptSnippet,
102
- promptGuidelines: base.promptGuidelines,
103
- executionMode: base.executionMode,
104
- ...(() => {
105
- // `● Bash(cmd)` / elbow-indented output, like every other One Code tool.
106
- // Not passing base.renderCall also drops its timer state, so the
107
- // misleading "Took Ns" that counted permission-prompt wait disappears.
108
- const wrapped = ccWrapBuiltinRenderers<{ command?: string }>("Bash", base, { title: (a) => a?.command });
109
- return {
110
- renderShell: wrapped.renderShell,
111
- renderCall: wrapped.renderCall as ToolDefinition<typeof BashParams>["renderCall"],
112
- renderResult: ((result, options, theme, context) => {
113
- // A background start returns a model-facing instruction paragraph;
114
- // the transcript needs one line (Claude Code: "Running in the background").
115
- const details = result.details as { taskId?: string; logPath?: string } | undefined;
116
- if (details?.taskId && !context.isError) {
117
- const line = options.expanded
118
- ? `Running in the background (task ${details.taskId}${details.logPath ? ` · log: ${details.logPath}` : ""})`
119
- : "Running in the background (↓ to manage)";
120
- return linesComponent(() => resultLines(theme as any, line, options.expanded, false));
121
- }
122
- return wrapped.renderResult(result, options, theme, context);
123
- }) as ToolDefinition<typeof BashParams>["renderResult"],
124
- };
125
- })(),
73
+ ccLabel: "Bash",
74
+ description: `${baseDescription} Pass run_in_background: true for long-running commands (builds, servers, watches): it returns a task id immediately so you can keep working, completion arrives as a task notification, and the output is retrievable with task_output / stoppable with task_stop (both deferred — load them with tool_search; in a one-shot print/json session the call runs to completion and returns the output directly). Foreground \`sleep\` is blocked; to wait on a condition use the monitor tool (deferred — load it with tool_search select:monitor) with an until-loop.`,
126
75
  parameters: BashParams,
127
- async execute(toolCallId, params, signal, onUpdate, ctx) {
128
- // Guards check the model's original command — a worktree session rewrites
129
- // input.command to `cd '<wt>' && (…)`, which would hide the real lead.
130
- const originalCommand = commandToEvaluate(originalCommands, toolCallId, params.command);
131
- const guardReason = bashGuardReason(originalCommand, { background: params.run_in_background === true });
132
- if (guardReason) return { content: [{ type: "text" as const, text: guardReason }], isError: true, details: {} };
133
- const timeoutSeconds =
134
- params.timeout !== undefined && Number.isFinite(params.timeout) && params.timeout > 0
135
- ? Math.min(params.timeout, MAX_TIMEOUT_MS) / 1000
136
- : undefined;
137
-
138
- if (!params.run_in_background) {
139
- // pi's executor takes seconds; the model-facing unit is milliseconds
140
- // (Claude Code's Bash), so a habitual `timeout: 120000` is 2 minutes,
141
- // not 33 hours (review T11).
142
- return foreground(ctx.cwd).execute(
143
- toolCallId,
144
- { command: params.command, timeout: timeoutSeconds },
145
- signal,
146
- onUpdate,
147
- ctx,
148
- );
149
- }
150
-
151
- const id = generateTaskId();
152
- const logPath = taskLogPath(ctx, id);
153
- const description = params.description || params.command.slice(0, 80);
154
-
155
- // One-shot modes (`-p` / `--mode json`) exit when the turn settles: a
156
- // detached task would be orphaned, and its completion callback would
157
- // call sendMessage on the disposed session and crash pi (measured,
158
- // STEERING-REVIEW-2026-09-05 H3). Run blocking there — the same rule the
159
- // Agent tool applies — and return the output in the result.
160
- if (!sessionOutlivesTurn(ctx.mode)) {
161
- const summary = await runBackgroundBashBlocking(
162
- { id, command: params.command, description, cwd: ctx.cwd, timeoutSeconds, logPath },
163
- signal,
164
- );
165
- const output = persistIfLarge(summary.output, { dir: sessionResultsDir(ctx), id: `bash-${id}`, maxBytes: ONE_SHOT_OUTPUT_CAP });
166
- return {
167
- content: [
168
- {
169
- type: "text",
170
- text: `Bash task ${id} (${description}) ${finishLine(summary, timeoutSeconds)}. ${oneShotNote("command")}${logPath ? ` Log: ${logPath}.` : ""}\n\n${output}`,
171
- },
172
- ],
173
- // No taskId: nothing was registered behind task_output/task_stop, and
174
- // its presence is what renderResult reads as "running in the background".
175
- details: { logPath },
176
- isError: summary.exitCode !== 0 && !summary.stopped,
177
- };
178
- }
179
-
180
- const task = startBackgroundBash({
181
- id,
182
- command: params.command,
183
- description,
184
- cwd: ctx.cwd,
185
- timeoutSeconds,
186
- logPath,
187
- onFinished: (finishedTask, summary) => {
188
- const tail = tailCap(summary.output, NOTIFY_OUTPUT_CAP).trim();
189
- const where = `Full output: task_output ${id}${logPath ? ` (or read ${logPath})` : ""}.`;
190
- notify(
191
- systemNotification(
192
- `Background bash ${id} (${description}) ${finishLine(summary, timeoutSeconds)}. ${where}${tail ? `\n\nLast output:\n${tail}` : ""}`,
193
- ),
194
- { taskId: id, status: finishedTask.status, exitCode: summary.exitCode, logPath },
195
- );
196
- },
197
- });
198
- pi.events.emit(TASK_REGISTER_CHANNEL, task);
199
-
200
- return {
201
- content: [
202
- {
203
- type: "text",
204
- text: `⏳ Bash task ${id} running in background (${description}).\n\nCompletion (with output) will arrive as a system notification on its own — you do not need to wait for it or poll; keep working.${logPath ? ` To check interim output, read ${logPath}.` : ""} If your next step cannot proceed without the result, task_output with block=true waits for it. Stop with task_stop.`,
205
- },
206
- ],
207
- details: { taskId: id, logPath },
208
- };
209
- },
76
+ base,
77
+ foreground: perCwd((cwd: string) => createBashToolDefinition(cwd, { shellPath })),
78
+ guard: bashGuardReason,
79
+ backgroundShell: () => bash.spawn,
210
80
  });
211
81
  }
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import { existsSync, readFileSync } from "node:fs";
17
+ import { modeCycleKey } from "../lib/keys.ts";
17
18
  import os from "node:os";
18
19
  import { fileURLToPath } from "node:url";
19
- import { dirname, join } from "node:path";
20
+ import { basename, dirname, join } from "node:path";
20
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
22
  import { getAgentDir, SettingsManager, VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent";
22
23
  import { piVersionWarning } from "../lib/pi-version.ts";
@@ -91,8 +92,9 @@ const HINTS: [key: string, what: string][] = [
91
92
  // it as Claude Code's "effort" so the key and /effort agree on the name.
92
93
  ["shift+tab", "effort"],
93
94
  // Claude Code cycles permission modes on shift+tab; pi owns that key, and
94
- // ctrl+q is the one ctrl+letter both pi and terminals leave free.
95
- ["ctrl+q", "permissions"],
95
+ // ctrl+q is the one ctrl+letter both pi and terminals leave free — except
96
+ // on Windows and WSL, where pi takes ctrl+q and the key is alt+m.
97
+ [modeCycleKey(), "permissions"],
96
98
  ["ctrl+t", "thinking"],
97
99
  ["ctrl+o", "expand output"],
98
100
  ["/", "commands"],
@@ -280,7 +282,37 @@ export default function brandingExtension(pi: ExtensionAPI) {
280
282
  }
281
283
  // The label and prompt marker apply in every session; CC_NO_BANNER only
282
284
  // disables the header.
285
+ // The terminal title. pi writes "π - [<session name> - ]<cwd>" at the end of
286
+ // its startup, on every session replacement and whenever the name changes —
287
+ // each AFTER our session_start handler ran, so a title set there alone lost
288
+ // (seen live 2026-09-19: "π - proj"). Re-assert it once the current task
289
+ // has drained (setTimeout 0, past pi's synchronous tail) and on every
290
+ // agent_start, and follow the name like pi does, brand first.
291
+ let lastTitle: string | undefined;
292
+ // A ctx captured for the deferred write is stale once the runtime is replaced
293
+ // (a second /clear inside the 0 ms window); pi's stale-ctx guard throws, and a
294
+ // throw inside a timer is uncaught — so the write is inert after shutdown.
295
+ let live = false;
296
+ const retitle = (ctx: { hasUI: boolean; cwd: string; ui: { setTitle(title: string): void } }) => {
297
+ if (!live || !ctx.hasUI) return;
298
+ const name = pi.getSessionName?.()?.trim();
299
+ const title = name ? `${NAME} - ${name} - ${basename(ctx.cwd)}` : NAME;
300
+ if (title === lastTitle) return; // agent_start fires every turn; write only on change
301
+ lastTitle = title;
302
+ ctx.ui.setTitle(title);
303
+ };
304
+ pi.on("session_info_changed", (_event, ctx) => retitle(ctx));
305
+ pi.on("agent_start", (_event, ctx) => retitle(ctx));
306
+ pi.on("session_shutdown", () => {
307
+ live = false;
308
+ });
309
+
283
310
  pi.on("session_start", (_event, ctx) => {
311
+ // pi's own write lands after this handler, so only the deferred write
312
+ // sticks; a new session may carry a name (resume), so forget the last.
313
+ lastTitle = undefined;
314
+ live = true;
315
+ setTimeout(() => retitle(ctx), 0).unref?.();
284
316
  ctx.ui.setHiddenThinkingLabel(THINKING_LABEL);
285
317
  installPromptMarker(ctx as unknown as { hasUI: boolean; mode: string; ui: BrandingEditorUI });
286
318
  // Soft drift guard: warn once at startup when the hosting pi is outside
@@ -337,7 +369,6 @@ export default function brandingExtension(pi: ExtensionAPI) {
337
369
  ? collectStartupSections(ctx.cwd, home, join(dirname(fileURLToPath(import.meta.url)), "..", "..", "themes"), agentDir)
338
370
  : undefined;
339
371
 
340
- ctx.ui.setTitle(NAME);
341
372
  ctx.ui.setHeader((tui: unknown, theme: unknown) => {
342
373
  const paint = safeThemePaint(theme);
343
374
  // Width-memoized (pi-tui renders every mounted component every frame);