one-code-extension 0.3.1 → 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 (113) hide show
  1. package/README.md +88 -10
  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 +184 -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 +7 -4
  113. package/types/app.d.ts +18 -2
@@ -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);
@@ -0,0 +1,207 @@
1
+ /**
2
+ * btw extension — Claude Code's `/btw` ("by the way") side question.
3
+ *
4
+ * `/btw <question>` asks a one-off question that is answered from the current
5
+ * conversation's context while the main agent keeps working. CC frames the
6
+ * answering model as a separate, lightweight, tool-less instance (the verbatim
7
+ * reminder in prompt.ts, captured from CC 2.1.278) and shows the answer in a
8
+ * focused overlay panel, not in the transcript — so the exchange never enters
9
+ * the main agent's context and cannot steer its work.
10
+ *
11
+ * The call reuses recap's machinery: the exact messages the session last put on
12
+ * the wire (captured from the `context` event), name-only tool stubs so a
13
+ * history carrying tool_use blocks stays valid on strict providers, and
14
+ * withReasoningFallback for models that cannot disable thinking. Unlike recap it
15
+ * uses the SESSION model (not an economical one) — a side question deserves the
16
+ * same quality as the main conversation — and sends the full captured history,
17
+ * not just the recent window.
18
+ *
19
+ * Deviations from CC, logged in docs/decisions/btw.md: the exchange is stateless
20
+ * (each `/btw` sees only the shared main context, never a prior `/btw` — CC
21
+ * keeps a side-session; the "one-off, no follow-up" framing holds either way);
22
+ * `f to fork` is omitted (pi's `ctx.fork` branches from a session entry, not
23
+ * from an injected side exchange); and the call runs with an empty system prompt
24
+ * (the `context` event carries no system prompt to reuse), relying on the shared
25
+ * history plus the reminder.
26
+ */
27
+
28
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
29
+ import type { Api, Model } from "@earendil-works/pi-ai";
30
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
31
+ import { convertToLlm, copyToClipboard, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
32
+ import { withReasoningFallback } from "../lib/model-policy.ts";
33
+ import { answerText, stripImageBlocks, toolStubs, trimToTurnBoundary } from "../lib/side-call.ts";
34
+ import { boundedDockHeight, truncateLine } from "../lib/tui-render.ts";
35
+ import { recordUsage } from "../lib/usage-bus.ts";
36
+ import { applyBtwKey, type BtwBody, BTW_MAX_HEIGHT, decodeBtwKey, initialBtwState, renderBtwPanel } from "./panel.ts";
37
+ import { sideQuestionMessage } from "./prompt.ts";
38
+
39
+ const BTW_MAX_TOKENS = 8192;
40
+ const BTW_TIMEOUT_MS = 120_000;
41
+ /** Description on the name-only tool stubs sent with the side question. */
42
+ const STUB_REASON = "Unavailable during this side question; answer in text.";
43
+
44
+ export default function btwExtension(pi: ExtensionAPI) {
45
+ // The exact request messages the session last sent (after every extension's
46
+ // mutations), captured like recap and the compaction extension do.
47
+ let capturedMessages: AgentMessage[] | undefined;
48
+ pi.on("context", (event) => {
49
+ capturedMessages = event.messages;
50
+ });
51
+
52
+ // The controller for an open panel's in-flight call. Aborted when the session
53
+ // is replaced or torn down, so a late resolve cannot repaint a disposed `tui`
54
+ // or record usage on a stale `pi` (the crash the subagents panel once hit —
55
+ // docs/decisions/tools.md lifecycle review).
56
+ let inFlight: AbortController | undefined;
57
+
58
+ // A new session (including after /clear) shares no context with the old one,
59
+ // and any in-flight side question is abandoned.
60
+ pi.on("session_start", () => {
61
+ capturedMessages = undefined;
62
+ inFlight?.abort();
63
+ inFlight = undefined;
64
+ });
65
+ pi.on("session_shutdown", () => {
66
+ inFlight?.abort();
67
+ inFlight = undefined;
68
+ });
69
+
70
+ /** Run the side question against the session model; returns the answer text. */
71
+ async function ask(ctx: ExtensionCommandContext, question: string, signal: AbortSignal): Promise<string> {
72
+ const model = ctx.model;
73
+ if (!model) throw new Error("No model is configured for this session.");
74
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
75
+ if (!auth.ok) throw new Error("No API key is available for the current model.");
76
+ const baseUrl = (auth as { baseUrl?: string }).baseUrl;
77
+
78
+ const tools = toolStubs(pi.getActiveTools(), STUB_REASON);
79
+ const context = trimToTurnBoundary(capturedMessages ?? []);
80
+ // btw runs on a cheap, possibly text-only reader and answers a text question,
81
+ // so images the conversation carried are stripped before the call
82
+ // (docs/decisions/model-policy.md).
83
+ const messages = [...stripImageBlocks(convertToLlm(context)), { role: "user" as const, content: sideQuestionMessage(question), timestamp: Date.now() }];
84
+
85
+ const result = await withReasoningFallback(
86
+ model as Model<Api>,
87
+ (reasoning) => {
88
+ const timeout = AbortSignal.timeout(BTW_TIMEOUT_MS);
89
+ return completeSimple(
90
+ baseUrl ? ({ ...model, baseUrl } as Model<Api>) : (model as Model<Api>),
91
+ { systemPrompt: "", messages, tools },
92
+ {
93
+ apiKey: auth.apiKey,
94
+ headers: auth.headers,
95
+ env: auth.env,
96
+ signal: AbortSignal.any([signal, timeout]),
97
+ maxTokens: BTW_MAX_TOKENS,
98
+ ...(reasoning ? { reasoning } : {}),
99
+ },
100
+ );
101
+ },
102
+ undefined,
103
+ (usage) => recordUsage(pi, "btw", usage),
104
+ );
105
+ return answerText(result.content);
106
+ }
107
+
108
+ pi.registerCommand("btw", {
109
+ description: "Ask a one-off side question, answered from the current context while the main agent keeps working",
110
+ handler: async (args, ctx) => {
111
+ const question = args.trim();
112
+ if (!question) {
113
+ ctx.ui.notify("Ask a side question: /btw <question>", "info");
114
+ return;
115
+ }
116
+
117
+ // Headless (-p / --mode json): no panel — answer and print it. The
118
+ // handler blocks to completion, so the process does not exit early.
119
+ if (!ctx.hasUI) {
120
+ try {
121
+ const answer = await ask(ctx, question, new AbortController().signal);
122
+ ctx.ui.notify(answer || "(no answer)", "info");
123
+ } catch (error) {
124
+ ctx.ui.notify(`Side question failed: ${error instanceof Error ? error.message : String(error)}`, "error");
125
+ }
126
+ return;
127
+ }
128
+
129
+ // Show the panel at once with a loading line; run the call concurrently
130
+ // and repaint when it lands. Closing the panel — or a session
131
+ // replacement/shutdown — aborts an in-flight call.
132
+ inFlight?.abort();
133
+ const controller = new AbortController();
134
+ inFlight = controller;
135
+ const view: { answer?: string; error?: string } = {};
136
+ const bodyState = (): BtwBody =>
137
+ view.error !== undefined ? { kind: "error", message: view.error } : view.answer !== undefined ? { kind: "answer", text: view.answer } : { kind: "loading" };
138
+
139
+ await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
140
+ const state = initialBtwState();
141
+ let cache: { width: number; lines: string[] } | undefined;
142
+ const repaint = () => {
143
+ cache = undefined;
144
+ // A repaint can be requested from an async callback that raced a
145
+ // session teardown; a stale `tui` throws, so never let it propagate.
146
+ try {
147
+ tui.requestRender();
148
+ } catch {}
149
+ };
150
+
151
+ // Kick off the model call once, at mount, so `repaint` is bound.
152
+ void (async () => {
153
+ try {
154
+ const answer = await ask(ctx, question, controller.signal);
155
+ if (controller.signal.aborted) return;
156
+ view.answer = answer;
157
+ } catch (error) {
158
+ if (controller.signal.aborted) return;
159
+ view.error = error instanceof Error ? error.message : String(error);
160
+ }
161
+ repaint();
162
+ })();
163
+
164
+ return {
165
+ render: (width: number) => {
166
+ if (cache?.width === width) return cache.lines;
167
+ const termRows = (tui as { terminal: { rows: number } }).terminal.rows;
168
+ const height = boundedDockHeight(termRows, BTW_MAX_HEIGHT);
169
+ // Belt-and-suspenders truncate on top of renderBtwPanel's own,
170
+ // matching every sibling dock panel: pi-tui crashes the app on
171
+ // a line wider than the terminal.
172
+ const lines = renderBtwPanel({ state, question, body: bodyState(), width, height }, theme).map((line) => truncateLine(line, width));
173
+ cache = { width, lines };
174
+ return lines;
175
+ },
176
+ handleInput: (data: string) => {
177
+ const key = decodeBtwKey(data);
178
+ if (!key) return;
179
+ const effect = applyBtwKey(state, key);
180
+ switch (effect?.kind) {
181
+ case "close":
182
+ done(null);
183
+ return;
184
+ case "copy":
185
+ // Only offer copy once there is a non-empty answer.
186
+ if (view.answer) {
187
+ void copyToClipboard(view.answer).then(() => {
188
+ state.copied = true;
189
+ repaint();
190
+ });
191
+ }
192
+ return;
193
+ default:
194
+ repaint();
195
+ }
196
+ },
197
+ invalidate: () => {
198
+ cache = undefined;
199
+ },
200
+ };
201
+ });
202
+
203
+ controller.abort();
204
+ if (inFlight === controller) inFlight = undefined;
205
+ },
206
+ });
207
+ }