@howaboua/pi-smart-btw 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/index.ts +180 -0
- package/package.json +59 -0
- package/src/config.ts +43 -0
- package/src/rpc-child.ts +140 -0
- package/src/types.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Warzocha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# pi-smart-btw
|
|
2
|
+
|
|
3
|
+
`@howaboua/pi-smart-btw` adds `/btw <question>` to Pi: an async, ephemeral side session for questions you do not want to derail the main conversation. It starts a fresh no-session Pi RPC subprocess, renders side answers in the transcript, lets you ask follow-ups with more `/btw ...`, and injects the side-session result only when you choose.
|
|
4
|
+
|
|
5
|
+
- Fresh context: child starts with `pi --mode rpc --no-session`.
|
|
6
|
+
- Full tools/extensions/skills: no `--no-skills`; installed extensions load normally except this extension disables itself in the child to avoid nesting UI.
|
|
7
|
+
- Async main session: the command starts work in the background and returns immediately.
|
|
8
|
+
- Compose: press `alt+z` to prefill `/btw ` in the prompt editor.
|
|
9
|
+
- Injection: press `alt+c` from the UI while the btw block is visible.
|
|
10
|
+
- Dismiss: press `alt+x` from the UI while the btw block is visible.
|
|
11
|
+
- Only one slash command is registered: `/btw`.
|
|
12
|
+
- Side answers are rendered as display-only custom transcript messages and filtered from the LLM context with Pi's `context` hook. They are only sent to the main agent when you explicitly inject them. The widget shows status/actions only.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:@howaboua/pi-smart-btw
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or try it for one session without adding it permanently:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi -e npm:@howaboua/pi-smart-btw
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
/btw explain this error without interrupting the current task
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
While the side session is open:
|
|
33
|
+
|
|
34
|
+
- run another `/btw ...` to ask a follow-up in the same child session
|
|
35
|
+
- press `alt+c` to inject all completed side-session turns into the main chat
|
|
36
|
+
- press `alt+x` to dismiss and stop the child session
|
|
37
|
+
- press `alt+z` to prefill `/btw ` in the editor
|
|
38
|
+
|
|
39
|
+
Injection format for one turn:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
The user asked the following question in a separate session:
|
|
43
|
+
[Q]
|
|
44
|
+
The answer was:
|
|
45
|
+
[A]
|
|
46
|
+
Take it into account while executing the current task.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For multiple completed turns, injection includes every question/answer pair in order.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Config is created at `~/.pi/agent/pi-smart-btw.json`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"model": "openai-codex/gpt-5.4-mini",
|
|
58
|
+
"provider": "",
|
|
59
|
+
"thinking": "low",
|
|
60
|
+
"command": "pi",
|
|
61
|
+
"injectShortcut": "alt+c",
|
|
62
|
+
"dismissShortcut": "alt+x",
|
|
63
|
+
"composeShortcut": "alt+z"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install
|
|
71
|
+
npm run check
|
|
72
|
+
npm run pack:dry-run
|
|
73
|
+
```
|
|
74
|
+
|
package/index.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ensureConfig, readConfig } from "./src/config.js";
|
|
4
|
+
import { BtwChild } from "./src/rpc-child.js";
|
|
5
|
+
import type { BtwTurn } from "./src/types.js";
|
|
6
|
+
|
|
7
|
+
const WIDGET_ID = "smart-btw";
|
|
8
|
+
const MESSAGE_TYPE = "smart-btw-result";
|
|
9
|
+
const FALLBACK_COMPOSE_SHORTCUT = "alt+z";
|
|
10
|
+
const FALLBACK_INJECT_SHORTCUT = "alt+c";
|
|
11
|
+
const FALLBACK_DISMISS_SHORTCUT = "alt+x";
|
|
12
|
+
|
|
13
|
+
type State = {
|
|
14
|
+
child?: BtwChild;
|
|
15
|
+
turns: BtwTurn[];
|
|
16
|
+
running: boolean;
|
|
17
|
+
queue: Promise<void>;
|
|
18
|
+
ctx?: ExtensionCommandContext;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function doneTurns(turns: BtwTurn[]) {
|
|
22
|
+
return turns.filter((turn) => turn.answer || turn.error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function injectionText(turns: BtwTurn[]) {
|
|
26
|
+
const completed = doneTurns(turns);
|
|
27
|
+
if (completed.length === 1) {
|
|
28
|
+
const turn = completed[0]!;
|
|
29
|
+
return [
|
|
30
|
+
"The user asked the following question in a separate session:",
|
|
31
|
+
turn.question,
|
|
32
|
+
"The answer was:",
|
|
33
|
+
turn.answer || turn.error || "(no answer)",
|
|
34
|
+
"Take it into account while executing the current task.",
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
"The user asked the following questions in a separate session:",
|
|
40
|
+
...completed.flatMap((turn, index) => [
|
|
41
|
+
"",
|
|
42
|
+
`Question ${index + 1}:`,
|
|
43
|
+
turn.question,
|
|
44
|
+
"Answer:",
|
|
45
|
+
turn.answer || turn.error || "(no answer)",
|
|
46
|
+
]),
|
|
47
|
+
"",
|
|
48
|
+
"Take them into account while executing the current task.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function render(ctx: ExtensionCommandContext, state: State) {
|
|
53
|
+
const t = ctx.ui.theme;
|
|
54
|
+
if (!state.child && state.turns.length === 0) {
|
|
55
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const cfg = readConfig();
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
const status = state.running ? t.fg("warning", "running") : t.fg("success", "ready");
|
|
61
|
+
lines.push(`${t.fg("accent", "╭─ btw")} ${status} ${t.fg("dim", `${cfg.model}:${cfg.thinking}`)}`);
|
|
62
|
+
for (const turn of state.turns.slice(-3)) {
|
|
63
|
+
const q = turn.question.length > 120 ? `${turn.question.slice(0, 117)}...` : turn.question;
|
|
64
|
+
lines.push(`${t.fg("muted", "│ Q")} ${q}`);
|
|
65
|
+
if (turn.error) lines.push(`${t.fg("error", "│ ✗ failed — see btw result in transcript")}`);
|
|
66
|
+
else if (turn.answer) lines.push(`${t.fg("success", "│ ✓ answered — see btw result in transcript")}`);
|
|
67
|
+
else lines.push(`${t.fg("warning", "│ … thinking")}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push(`${t.fg("muted", "╰─")} ${FALLBACK_COMPOSE_SHORTCUT} compose · ${FALLBACK_INJECT_SHORTCUT} inject · ${FALLBACK_DISMISS_SHORTCUT} dismiss`);
|
|
70
|
+
ctx.ui.setWidget(WIDGET_ID, lines, { placement: "aboveEditor" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendResultMessage(pi: ExtensionAPI, turn: BtwTurn) {
|
|
74
|
+
pi.sendMessage({
|
|
75
|
+
customType: MESSAGE_TYPE,
|
|
76
|
+
content: turn.answer || turn.error || "(no answer)",
|
|
77
|
+
display: true,
|
|
78
|
+
details: {
|
|
79
|
+
question: turn.question,
|
|
80
|
+
answer: turn.answer,
|
|
81
|
+
error: turn.error,
|
|
82
|
+
startedAt: turn.startedAt,
|
|
83
|
+
finishedAt: turn.finishedAt,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function (pi: ExtensionAPI) {
|
|
89
|
+
if (process.env.PI_SMART_BTW_CHILD === "1") return;
|
|
90
|
+
ensureConfig();
|
|
91
|
+
pi.registerMessageRenderer(MESSAGE_TYPE, (message, _options, theme) => {
|
|
92
|
+
const details = message.details as { question?: string; answer?: string; error?: string } | undefined;
|
|
93
|
+
const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
|
|
94
|
+
const status = details?.error ? theme.fg("error", "btw failed") : theme.fg("accent", "btw");
|
|
95
|
+
const question = details?.question ?? "";
|
|
96
|
+
const body = details?.answer ?? details?.error ?? String(message.content ?? "");
|
|
97
|
+
box.addChild(new Text(`${status} ${theme.fg("muted", "Q")} ${question}\n\n${body}`, 0, 0));
|
|
98
|
+
return box;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
pi.on("context", async (event) => ({
|
|
102
|
+
messages: event.messages.filter((message) => {
|
|
103
|
+
const candidate = message as { role?: string; customType?: string };
|
|
104
|
+
return !(candidate.role === "custom" && candidate.customType === MESSAGE_TYPE);
|
|
105
|
+
}),
|
|
106
|
+
}));
|
|
107
|
+
const state: State = { turns: [], running: false, queue: Promise.resolve() };
|
|
108
|
+
|
|
109
|
+
const dismiss = async () => {
|
|
110
|
+
await state.child?.stop();
|
|
111
|
+
state.child = undefined;
|
|
112
|
+
state.turns = [];
|
|
113
|
+
state.running = false;
|
|
114
|
+
state.ctx?.ui.setWidget(WIDGET_ID, undefined);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const inject = () => {
|
|
118
|
+
const turns = doneTurns(state.turns);
|
|
119
|
+
if (turns.length === 0) {
|
|
120
|
+
state.ctx?.ui.notify("No /btw answer to inject yet.", "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
pi.sendUserMessage(injectionText(turns), state.ctx?.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cfg = readConfig();
|
|
127
|
+
for (const key of [cfg.composeShortcut, FALLBACK_COMPOSE_SHORTCUT].filter((key, index, keys) => keys.indexOf(key) === index)) {
|
|
128
|
+
pi.registerShortcut(key as any, {
|
|
129
|
+
description: "Prefill /btw in the prompt editor",
|
|
130
|
+
handler: async (ctx) => {
|
|
131
|
+
const current = ctx.ui.getEditorText();
|
|
132
|
+
const prefix = current.trim() ? `${current.trimEnd()} /btw ` : "/btw ";
|
|
133
|
+
ctx.ui.setEditorText(prefix);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
for (const key of [cfg.injectShortcut, FALLBACK_INJECT_SHORTCUT].filter((key, index, keys) => keys.indexOf(key) === index)) {
|
|
138
|
+
pi.registerShortcut(key as any, { description: "Inject latest /btw answer into the main session", handler: async () => inject() });
|
|
139
|
+
}
|
|
140
|
+
for (const key of [cfg.dismissShortcut, FALLBACK_DISMISS_SHORTCUT].filter((key, index, keys) => keys.indexOf(key) === index)) {
|
|
141
|
+
pi.registerShortcut(key as any, { description: "Dismiss active /btw block", handler: async () => { await dismiss(); } });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pi.registerCommand("btw", {
|
|
145
|
+
description: "Ask a fresh async side-session question. Re-run while open to ask a follow-up. UI: inject/dismiss shortcuts shown in the btw block.",
|
|
146
|
+
handler: async (args, ctx) => {
|
|
147
|
+
const question = args.trim();
|
|
148
|
+
state.ctx = ctx;
|
|
149
|
+
if (!question) {
|
|
150
|
+
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const turn: BtwTurn = { question, startedAt: Date.now() };
|
|
154
|
+
state.turns.push(turn);
|
|
155
|
+
render(ctx, state);
|
|
156
|
+
|
|
157
|
+
state.queue = state.queue.then(async () => {
|
|
158
|
+
state.running = true;
|
|
159
|
+
render(ctx, state);
|
|
160
|
+
try {
|
|
161
|
+
if (!state.child) {
|
|
162
|
+
state.child = new BtwChild(ctx.cwd, () => render(ctx, state));
|
|
163
|
+
await state.child.ready();
|
|
164
|
+
}
|
|
165
|
+
turn.answer = await state.child.ask(question) || "(no answer)";
|
|
166
|
+
} catch (error) {
|
|
167
|
+
turn.error = error instanceof Error ? error.message : String(error);
|
|
168
|
+
ctx.ui.notify(`/btw failed: ${turn.error}`, "error");
|
|
169
|
+
} finally {
|
|
170
|
+
turn.finishedAt = Date.now();
|
|
171
|
+
state.running = false;
|
|
172
|
+
render(ctx, state);
|
|
173
|
+
if (turn.answer || turn.error) sendResultMessage(pi, turn);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
pi.on("session_shutdown", async () => { await state.child?.stop(); });
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@howaboua/pi-smart-btw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Async side-session questions for Pi with explicit injection into the main chat.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"check": "tsc --noEmit",
|
|
15
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
16
|
+
"prepack": "npm run check"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@earendil-works/pi-ai": "*",
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
21
|
+
"@earendil-works/pi-tui": "*"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@earendil-works/pi-ai": "^0.74.1",
|
|
25
|
+
"@earendil-works/pi-coding-agent": "^0.74.1",
|
|
26
|
+
"@earendil-works/pi-tui": "^0.74.1",
|
|
27
|
+
"@types/node": "^24.10.0",
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"pi-package",
|
|
37
|
+
"pi",
|
|
38
|
+
"pi-extension",
|
|
39
|
+
"side-session",
|
|
40
|
+
"btw",
|
|
41
|
+
"subagent",
|
|
42
|
+
"async"
|
|
43
|
+
],
|
|
44
|
+
"author": "Igor Warzocha",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/IgorWarzocha/pi-smart-btw.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/IgorWarzocha/pi-smart-btw#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/IgorWarzocha/pi-smart-btw/issues"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20.6.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { BtwConfig, ThinkingLevel } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const ALLOWED = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
7
|
+
const DEFAULT_CONFIG: Required<BtwConfig> = {
|
|
8
|
+
model: "openai-codex/gpt-5.4-mini",
|
|
9
|
+
provider: "",
|
|
10
|
+
thinking: "low",
|
|
11
|
+
command: "pi",
|
|
12
|
+
injectShortcut: "alt+c",
|
|
13
|
+
dismissShortcut: "alt+x",
|
|
14
|
+
composeShortcut: "alt+z",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function agentDir() {
|
|
18
|
+
return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configPath() { return path.join(agentDir(), "pi-smart-btw.json"); }
|
|
22
|
+
|
|
23
|
+
export function ensureConfig() {
|
|
24
|
+
fs.mkdirSync(agentDir(), { recursive: true });
|
|
25
|
+
if (!fs.existsSync(configPath())) fs.writeFileSync(configPath(), JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readConfig(): Required<BtwConfig> {
|
|
29
|
+
ensureConfig();
|
|
30
|
+
let parsed: Partial<BtwConfig> = {};
|
|
31
|
+
try { parsed = JSON.parse(fs.readFileSync(configPath(), "utf8")); } catch {}
|
|
32
|
+
const model = typeof parsed.model === "string" && parsed.model.trim() ? parsed.model.trim() : DEFAULT_CONFIG.model;
|
|
33
|
+
const thinking = parsed.thinking && ALLOWED.has(parsed.thinking) ? parsed.thinking : DEFAULT_CONFIG.thinking;
|
|
34
|
+
return {
|
|
35
|
+
model,
|
|
36
|
+
provider: typeof parsed.provider === "string" ? parsed.provider : DEFAULT_CONFIG.provider,
|
|
37
|
+
thinking,
|
|
38
|
+
command: typeof parsed.command === "string" && parsed.command.trim() ? parsed.command.trim() : DEFAULT_CONFIG.command,
|
|
39
|
+
injectShortcut: typeof parsed.injectShortcut === "string" && parsed.injectShortcut.trim() ? parsed.injectShortcut.trim() : DEFAULT_CONFIG.injectShortcut,
|
|
40
|
+
dismissShortcut: typeof parsed.dismissShortcut === "string" && parsed.dismissShortcut.trim() ? parsed.dismissShortcut.trim() : DEFAULT_CONFIG.dismissShortcut,
|
|
41
|
+
composeShortcut: typeof parsed.composeShortcut === "string" && parsed.composeShortcut.trim() ? parsed.composeShortcut.trim() : DEFAULT_CONFIG.composeShortcut,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/rpc-child.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import type { ChildDetails } from "./types.js";
|
|
3
|
+
import { readConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const READY_TIMEOUT = 10_000;
|
|
6
|
+
const RESPONSE_TIMEOUT = 30_000;
|
|
7
|
+
const QUIET_MS = 500;
|
|
8
|
+
const POLL_MS = 150;
|
|
9
|
+
|
|
10
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
|
|
12
|
+
export function getFinalOutput(messages: any[]): string {
|
|
13
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
14
|
+
const message = messages[i];
|
|
15
|
+
if (message.role !== "assistant") continue;
|
|
16
|
+
for (const part of message.content ?? []) if (part.type === "text") return part.text;
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class BtwChild {
|
|
22
|
+
readonly details: ChildDetails;
|
|
23
|
+
private proc: ChildProcessWithoutNullStreams;
|
|
24
|
+
private requestId = 0;
|
|
25
|
+
private stdoutBuffer = "";
|
|
26
|
+
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeout: ReturnType<typeof setTimeout> }>();
|
|
27
|
+
private lastEventAt = Date.now();
|
|
28
|
+
private agentEndCount = 0;
|
|
29
|
+
private closed = false;
|
|
30
|
+
private exitCode: number | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(private cwd: string, private onUpdate?: () => void) {
|
|
33
|
+
const cfg = readConfig();
|
|
34
|
+
const args = ["--mode", "rpc", "--no-session", "--model", cfg.model, "--thinking", cfg.thinking];
|
|
35
|
+
this.details = {
|
|
36
|
+
cwd,
|
|
37
|
+
model: cfg.model,
|
|
38
|
+
thinking: cfg.thinking,
|
|
39
|
+
messages: [],
|
|
40
|
+
stderr: "",
|
|
41
|
+
usage: { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0 },
|
|
42
|
+
};
|
|
43
|
+
this.proc = spawn(cfg.command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PI_SMART_BTW_CHILD: "1" } });
|
|
44
|
+
this.proc.stdout.on("data", (chunk) => this.onStdout(chunk.toString()));
|
|
45
|
+
this.proc.stderr.on("data", (chunk) => { this.details.stderr += chunk.toString(); this.onUpdate?.(); });
|
|
46
|
+
this.proc.on("close", (code) => { this.closed = true; this.exitCode = code ?? 0; this.rejectAll(new Error(`btw child exited with code ${this.exitCode}`)); });
|
|
47
|
+
this.proc.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error))));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async ready() {
|
|
51
|
+
await this.send({ type: "get_state" }, READY_TIMEOUT);
|
|
52
|
+
await this.send({ type: "set_auto_compaction", enabled: true });
|
|
53
|
+
await this.send({ type: "set_auto_retry", enabled: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async ask(question: string) {
|
|
57
|
+
this.agentEndCount = 0;
|
|
58
|
+
const before = this.details.messages.length;
|
|
59
|
+
const prompt = [
|
|
60
|
+
"Answer the user's question directly.",
|
|
61
|
+
"Use available tools only if they are needed to answer accurately.",
|
|
62
|
+
"Be concise unless the question requires detail.",
|
|
63
|
+
`Question: ${question}`,
|
|
64
|
+
].join("\n\n");
|
|
65
|
+
await this.send({ type: "prompt", message: prompt, streamingBehavior: "followUp" });
|
|
66
|
+
await this.waitForAnswer(before);
|
|
67
|
+
return getFinalOutput(this.details.messages).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stop() {
|
|
71
|
+
if (this.closed) return;
|
|
72
|
+
this.proc.kill("SIGTERM");
|
|
73
|
+
await Promise.race([new Promise<void>((resolve) => this.proc.once("close", () => resolve())), sleep(1000).then(() => { if (!this.closed) this.proc.kill("SIGKILL"); })]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async waitForAnswer(beforeCount: number) {
|
|
77
|
+
while (!this.closed) {
|
|
78
|
+
await sleep(POLL_MS);
|
|
79
|
+
const state = await this.send<{ isStreaming: boolean; isCompacting: boolean; pendingMessageCount: number }>({ type: "get_state" });
|
|
80
|
+
const idle = !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0;
|
|
81
|
+
const quiet = Date.now() - this.lastEventAt >= QUIET_MS;
|
|
82
|
+
if (this.agentEndCount > 0 && idle && quiet && this.details.messages.length > beforeCount) return;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`btw child closed.${this.details.stderr ? ` Stderr: ${this.details.stderr.trim()}` : ""}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private send<T = unknown>(command: Record<string, unknown>, timeoutMs = RESPONSE_TIMEOUT): Promise<T> {
|
|
88
|
+
if (this.closed || !this.proc.stdin.writable) throw new Error("btw child RPC is not available");
|
|
89
|
+
const id = `req_${++this.requestId}`;
|
|
90
|
+
return new Promise<T>((resolve, reject) => {
|
|
91
|
+
const timeout = setTimeout(() => { this.pending.delete(id); reject(new Error(`Timed out waiting for ${String(command.type)}`)); }, timeoutMs);
|
|
92
|
+
this.pending.set(id, { resolve: (v) => resolve(v as T), reject, timeout });
|
|
93
|
+
this.proc.stdin.write(JSON.stringify({ ...command, id }) + "\n", (err) => {
|
|
94
|
+
if (!err) return;
|
|
95
|
+
clearTimeout(timeout); this.pending.delete(id); reject(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private onStdout(chunk: string) {
|
|
101
|
+
this.stdoutBuffer += chunk;
|
|
102
|
+
const lines = this.stdoutBuffer.split("\n");
|
|
103
|
+
this.stdoutBuffer = lines.pop() ?? "";
|
|
104
|
+
for (const line of lines) this.handleLine(line);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private handleLine(line: string) {
|
|
108
|
+
if (!line.trim()) return;
|
|
109
|
+
let data: any;
|
|
110
|
+
try { data = JSON.parse(line); } catch { return; }
|
|
111
|
+
if (data.type === "response" && typeof data.id === "string" && this.pending.has(data.id)) {
|
|
112
|
+
const pending = this.pending.get(data.id)!;
|
|
113
|
+
clearTimeout(pending.timeout); this.pending.delete(data.id);
|
|
114
|
+
data.success === false ? pending.reject(new Error(String(data.error ?? `RPC ${data.command} failed`))) : pending.resolve(data.data);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.lastEventAt = Date.now();
|
|
118
|
+
if (data.type === "agent_end") this.agentEndCount++;
|
|
119
|
+
if (data.type === "message_end" && data.message) {
|
|
120
|
+
this.details.messages.push(data.message);
|
|
121
|
+
if (data.message.role === "assistant") {
|
|
122
|
+
this.details.usage.turns++;
|
|
123
|
+
const u = data.message.usage;
|
|
124
|
+
if (u) {
|
|
125
|
+
this.details.usage.input += u.input || 0; this.details.usage.output += u.output || 0;
|
|
126
|
+
this.details.usage.cacheRead += u.cacheRead || 0; this.details.usage.cacheWrite += u.cacheWrite || 0;
|
|
127
|
+
this.details.usage.cost += u.cost?.total || 0; this.details.usage.contextTokens = u.totalTokens || 0;
|
|
128
|
+
}
|
|
129
|
+
if (data.message.stopReason) this.details.stopReason = data.message.stopReason;
|
|
130
|
+
if (data.message.errorMessage) this.details.errorMessage = data.message.errorMessage;
|
|
131
|
+
}
|
|
132
|
+
this.onUpdate?.();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private rejectAll(error: Error) {
|
|
137
|
+
for (const p of this.pending.values()) { clearTimeout(p.timeout); p.reject(error); }
|
|
138
|
+
this.pending.clear();
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
4
|
+
|
|
5
|
+
export interface BtwConfig {
|
|
6
|
+
model: string;
|
|
7
|
+
thinking?: ThinkingLevel;
|
|
8
|
+
provider?: string;
|
|
9
|
+
command?: string;
|
|
10
|
+
injectShortcut?: string;
|
|
11
|
+
dismissShortcut?: string;
|
|
12
|
+
composeShortcut?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BtwTurn {
|
|
16
|
+
question: string;
|
|
17
|
+
answer?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
finishedAt?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChildDetails {
|
|
24
|
+
cwd: string;
|
|
25
|
+
model: string;
|
|
26
|
+
thinking?: ThinkingLevel;
|
|
27
|
+
messages: Message[];
|
|
28
|
+
stderr: string;
|
|
29
|
+
usage: { turns: number; input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens: number };
|
|
30
|
+
stopReason?: string;
|
|
31
|
+
errorMessage?: string;
|
|
32
|
+
}
|