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.
- package/README.md +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
package/extensions/bash/index.ts
CHANGED
|
@@ -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;
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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 {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
+
}
|