agent-sh 0.12.21 → 0.12.23
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 +2 -2
- package/dist/agent/agent-loop.d.ts +1 -0
- package/dist/agent/agent-loop.js +25 -5
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +27 -14
- package/dist/core.d.ts +3 -1
- package/dist/core.js +20 -24
- package/dist/event-bus.d.ts +9 -1
- package/dist/event-bus.js +9 -0
- package/dist/executor.js +18 -4
- package/dist/extensions/agent-backend.js +49 -3
- package/dist/extensions/slash-commands.js +0 -24
- package/dist/index.js +8 -33
- package/dist/shell/shell.d.ts +26 -7
- package/dist/shell/shell.js +133 -74
- package/dist/utils/floating-panel.d.ts +1 -2
- package/dist/utils/floating-panel.js +35 -36
- package/dist/utils/markdown.js +2 -2
- package/examples/extensions/overlay-agent.ts +51 -43
- package/examples/extensions/pi-bridge/README.md +12 -19
- package/examples/extensions/pi-bridge/index.ts +307 -35
- package/package.json +1 -1
|
@@ -1,41 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Overlay agent extension.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Press Ctrl+\ from anywhere — a shell prompt, vim, ssh, htop, a REPL — to
|
|
5
|
+
* summon the agent in a floating panel composited over the current terminal.
|
|
6
|
+
* The agent sees the live screen as `<terminal_buffer>` context (when a TUI
|
|
7
|
+
* is active) or `<shell_events>` (at a shell prompt), so screen-aware
|
|
8
|
+
* questions answer without a tool round-trip.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Install (from an npm install of agent-sh):
|
|
11
|
+
* mkdir -p ~/.agent-sh/extensions
|
|
12
|
+
* cp "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts" \
|
|
13
|
+
* ~/.agent-sh/extensions/
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* Or load ad-hoc without copying:
|
|
16
|
+
* agent-sh -e "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts"
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* Optional companion extensions (copy the same way) — without them the
|
|
19
|
+
* overlay can read the screen but cannot interact with it:
|
|
20
|
+
* - terminal-buffer.ts → terminal_read / terminal_keys tools
|
|
21
|
+
* - user-shell.ts → user_shell tool (run new shell commands)
|
|
18
22
|
*/
|
|
19
23
|
import type { ExtensionContext, RemoteSession } from "agent-sh/types";
|
|
20
24
|
import type { RenderSurface } from "agent-sh/utils/compositor";
|
|
21
25
|
import { FloatingPanel } from "agent-sh/utils/floating-panel";
|
|
26
|
+
import { formatScreenContext, type TerminalBuffer } from "agent-sh/utils/terminal-buffer";
|
|
22
27
|
|
|
23
28
|
/** Adapt a FloatingPanel to the RenderSurface interface. */
|
|
24
29
|
function createPanelSurface(panel: FloatingPanel): RenderSurface {
|
|
30
|
+
// Track the spinner row so a stop-clear ("\r\x1b[2K") removes it
|
|
31
|
+
// instead of leaving an orphan blank line in the panel.
|
|
32
|
+
let spinnerLine = false;
|
|
25
33
|
return {
|
|
26
34
|
write(text: string): void {
|
|
27
|
-
// Handle \r (carriage return) — overwrite the current line.
|
|
28
|
-
// The spinner uses "\r <content>\x1b[K" to update in-place.
|
|
29
35
|
if (text.startsWith("\r")) {
|
|
30
|
-
// Strip \r and any erase-line sequences
|
|
31
36
|
const cleaned = text.replace(/^\r/, "").replace(/\x1b\[\d*K/g, "");
|
|
32
37
|
if (cleaned.trim()) {
|
|
33
|
-
panel.updateLastLine(() => cleaned);
|
|
38
|
+
if (spinnerLine) panel.updateLastLine(() => cleaned);
|
|
39
|
+
else { panel.appendLine(cleaned); spinnerLine = true; }
|
|
40
|
+
} else if (spinnerLine) {
|
|
41
|
+
panel.popLastLine();
|
|
42
|
+
spinnerLine = false;
|
|
34
43
|
}
|
|
35
44
|
return;
|
|
36
45
|
}
|
|
37
|
-
|
|
38
|
-
// Regular text — may contain newlines
|
|
46
|
+
if (spinnerLine) { panel.popLastLine(); spinnerLine = false; }
|
|
39
47
|
panel.appendText(text);
|
|
40
48
|
},
|
|
41
49
|
writeLine(line: string): void {
|
|
@@ -44,12 +52,23 @@ function createPanelSurface(panel: FloatingPanel): RenderSurface {
|
|
|
44
52
|
get columns(): number {
|
|
45
53
|
return panel.computeGeometry().contentW;
|
|
46
54
|
},
|
|
55
|
+
get rows(): number {
|
|
56
|
+
return panel.computeGeometry().contentH;
|
|
57
|
+
},
|
|
58
|
+
onResize(cb: (cols: number, rows: number) => void): () => void {
|
|
59
|
+
const handler = () => {
|
|
60
|
+
const g = panel.computeGeometry();
|
|
61
|
+
cb(g.contentW, g.contentH);
|
|
62
|
+
};
|
|
63
|
+
process.stdout.on("resize", handler);
|
|
64
|
+
return () => { process.stdout.off("resize", handler); };
|
|
65
|
+
},
|
|
47
66
|
};
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export default function activate(ctx: ExtensionContext): void {
|
|
51
70
|
const { bus, registerInstruction, createRemoteSession } = ctx;
|
|
52
|
-
const terminalBuffer = ctx.call("terminal-buffer");
|
|
71
|
+
const terminalBuffer: TerminalBuffer | null = ctx.call("terminal-buffer");
|
|
53
72
|
|
|
54
73
|
const panel = new FloatingPanel(bus, {
|
|
55
74
|
trigger: "\x1c", // Ctrl+\
|
|
@@ -60,22 +79,21 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
60
79
|
const panelSurface = createPanelSurface(panel);
|
|
61
80
|
let session: RemoteSession | null = null;
|
|
62
81
|
|
|
63
|
-
// Tell the LLM it's running inside an overlay session. The matching
|
|
64
|
-
// system-prompt block (registered via registerInstruction below) describes
|
|
65
|
-
// how to behave in this mode.
|
|
66
82
|
ctx.registerContextProducer("interactive-session", () =>
|
|
67
83
|
session?.active ? "interactive-session: true" : null,
|
|
68
84
|
);
|
|
69
85
|
|
|
86
|
+
// Inject the live screen for TUI / REPL programs. At a plain shell prompt
|
|
87
|
+
// `<shell_events>` already covers the visible scrollback — skip to dedupe.
|
|
88
|
+
ctx.registerContextProducer("terminal-screen", () => {
|
|
89
|
+
if (!session?.active || !terminalBuffer?.altScreen) return null;
|
|
90
|
+
return formatScreenContext(terminalBuffer.readScreen(), 80);
|
|
91
|
+
});
|
|
92
|
+
|
|
70
93
|
registerInstruction("Interactive Overlay Sessions", [
|
|
71
|
-
"When
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"- Start with terminal_read if you need to understand what's on screen.",
|
|
75
|
-
"- Prefer terminal_keys to interact with whatever is currently running.",
|
|
76
|
-
"- Use user_shell only for running new, standalone commands — not for interacting with",
|
|
77
|
-
" what's already on screen.",
|
|
78
|
-
"- Keep responses concise — the user is in the middle of a workflow.",
|
|
94
|
+
"When dynamic context includes `interactive-session: true`, the user summoned you via a",
|
|
95
|
+
"hotkey overlay from their live terminal. They're mid-workflow (shell prompt, vim, ssh, a",
|
|
96
|
+
"REPL, etc.) — keep responses concise and prefer reading what's on screen over asking.",
|
|
79
97
|
].join("\n"));
|
|
80
98
|
|
|
81
99
|
// ── Panel lifecycle ────────────────────────────────────────────
|
|
@@ -84,7 +102,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
84
102
|
if (!session) {
|
|
85
103
|
session = createRemoteSession({
|
|
86
104
|
surface: panelSurface,
|
|
87
|
-
suppressQueryBox: true,
|
|
88
105
|
});
|
|
89
106
|
}
|
|
90
107
|
panel.setActive();
|
|
@@ -92,18 +109,13 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
92
109
|
});
|
|
93
110
|
|
|
94
111
|
panel.handlers.advise("panel:show", (_next) => {
|
|
95
|
-
// Re-establish session if panel is shown while agent is still working
|
|
96
112
|
if (panel.active && !session) {
|
|
97
|
-
session = createRemoteSession({
|
|
98
|
-
surface: panelSurface,
|
|
99
|
-
suppressQueryBox: true,
|
|
100
|
-
});
|
|
113
|
+
session = createRemoteSession({ surface: panelSurface });
|
|
101
114
|
}
|
|
102
115
|
});
|
|
103
116
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// output buffers in the panel and agent can keep executing tools.
|
|
117
|
+
// Keep the session alive while the agent is still working, even after
|
|
118
|
+
// dismiss — so output keeps buffering and tools keep executing.
|
|
107
119
|
panel.handlers.advise("panel:dismiss", (next) => {
|
|
108
120
|
next();
|
|
109
121
|
if (session && !panel.processing) {
|
|
@@ -113,10 +125,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
113
125
|
});
|
|
114
126
|
|
|
115
127
|
bus.on("agent:processing-done", () => {
|
|
116
|
-
if (
|
|
117
|
-
panel.setDone();
|
|
118
|
-
// If panel was hidden while processing (passthrough), setDone()
|
|
119
|
-
// triggers dismiss() which closes the session above.
|
|
120
|
-
// If panel is still visible, session stays for the follow-up prompt.
|
|
128
|
+
if (panel.active) panel.setDone();
|
|
121
129
|
});
|
|
122
130
|
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
# pi-bridge
|
|
2
2
|
|
|
3
|
-
Runs [pi](https://github.com/
|
|
3
|
+
Runs [pi](https://github.com/badlogic/pi-mono) (`@mariozechner/pi-coding-agent`) as an agent-sh backend. Pi brings its own configuration, models, tools, and extensions — agent-sh just provides the terminal.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
# Copy or symlink into your extensions directory
|
|
9
8
|
cp -r examples/extensions/pi-bridge ~/.agent-sh/extensions/pi-bridge
|
|
10
|
-
|
|
11
|
-
# Install dependencies
|
|
12
9
|
cd ~/.agent-sh/extensions/pi-bridge
|
|
13
10
|
npm install
|
|
14
11
|
```
|
|
@@ -26,26 +23,22 @@ Set as default backend in `~/.agent-sh/settings.json`:
|
|
|
26
23
|
Or switch at runtime:
|
|
27
24
|
|
|
28
25
|
```
|
|
29
|
-
|
|
26
|
+
> /backend pi
|
|
30
27
|
```
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- pi must be configured separately (`~/.pi/settings.json`) with API keys and model preferences
|
|
35
|
-
- agent-sh does not override pi's configuration — it uses whatever pi is set up with
|
|
29
|
+
Pi reads its own settings from `~/.pi/agent/settings.json`. Configure API keys and model preferences there (or run `pi` directly to set up auth) — agent-sh does not override pi's configuration.
|
|
36
30
|
|
|
37
|
-
## What
|
|
31
|
+
## What works under pi
|
|
38
32
|
|
|
39
|
-
|
|
33
|
+
These slash commands are routed to pi's SDK when pi is the active backend:
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
- `/model` — lists/switches pi's available models (`session.setModel`)
|
|
36
|
+
- `/thinking` — sets pi's thinking level (`off/minimal/low/medium/high/xhigh`)
|
|
37
|
+
- `/compact` — runs `session.compact()` on pi's session
|
|
38
|
+
- `/context` — reports pi's token usage (`session.getContextUsage()`)
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
agent-sh's per-query context producers (e.g. `<shell_events>` from `shell-context`) are inlined into pi's prompt before each query, so pi sees the user's recent shell activity even though it doesn't subscribe to agent-sh's shell bus directly.
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
- `terminal_keys` — send keystrokes to the user's PTY
|
|
47
|
-
- `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
|
|
48
|
-
|
|
49
|
-
These are opt-in capabilities that belong in their own extensions. If you want any of them with pi, write a small companion extension that registers the tool as a pi `ToolDefinition` (TypeBox schema, wired to the relevant bus event: `shell:pty-write`, `shell:exec-request`, or `ctx.terminalBuffer.readScreen()`) and load it alongside pi-bridge.
|
|
42
|
+
## What this bridge is
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
A pure protocol translator between pi's event stream and agent-sh's bus events. Pi's built-in tools (command execution, file ops, etc.) are used exactly as pi ships them. The bridge adds no tools of its own.
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pi bridge — runs pi's full coding agent in-process as agent-sh's backend.
|
|
3
|
-
*
|
|
4
|
-
* Uses pi's own AgentSession with its full configuration: model registry,
|
|
5
|
-
* provider settings, extensions, session management, and tool system.
|
|
6
|
-
* Agent-sh provides the shell frontend and TUI rendering.
|
|
7
|
-
*
|
|
8
|
-
* The bridge is a pure protocol translator between pi's event stream and
|
|
9
|
-
* agent-sh's bus events. Pi brings its own tools for command execution,
|
|
10
|
-
* file ops, etc. PTY-access tools (`terminal_read`, `terminal_keys`,
|
|
11
|
-
* `user_shell`) are intentionally NOT bundled here — if you want pi to
|
|
12
|
-
* observe or mutate the user's live terminal, load a companion extension
|
|
13
|
-
* that registers those tools in pi's ToolDefinition format.
|
|
3
|
+
* Pure protocol translator between pi's event stream and agent-sh's bus.
|
|
14
4
|
*
|
|
15
5
|
* Setup:
|
|
16
6
|
* npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent
|
|
@@ -26,25 +16,127 @@ import {
|
|
|
26
16
|
SessionManager,
|
|
27
17
|
} from "@mariozechner/pi-coding-agent";
|
|
28
18
|
import type { ExtensionContext } from "agent-sh/types";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { resolve as resolvePath } from "node:path";
|
|
21
|
+
import { diffLines } from "diff";
|
|
22
|
+
|
|
23
|
+
const TOOL_KINDS: Record<string, string> = {
|
|
24
|
+
bash: "execute",
|
|
25
|
+
read: "read",
|
|
26
|
+
ls: "read",
|
|
27
|
+
find: "read",
|
|
28
|
+
grep: "search",
|
|
29
|
+
edit: "execute",
|
|
30
|
+
write: "execute",
|
|
31
|
+
};
|
|
32
|
+
const kindForTool = (name: string): string => TOOL_KINDS[name] ?? "execute";
|
|
33
|
+
|
|
34
|
+
type DiffLineRecord = { type: "context" | "added" | "removed"; oldNo: number | null; newNo: number | null; text: string };
|
|
35
|
+
type DiffHunkRecord = { lines: DiffLineRecord[] };
|
|
36
|
+
type DiffResultRecord = { hunks: DiffHunkRecord[]; added: number; removed: number; isIdentical: boolean; isNewFile: boolean };
|
|
37
|
+
|
|
38
|
+
function buildDiffFromTexts(oldText: string, newText: string, isNewFile: boolean): DiffResultRecord | null {
|
|
39
|
+
if (oldText === newText) return null;
|
|
40
|
+
const changes = diffLines(oldText, newText);
|
|
41
|
+
const allLines: DiffLineRecord[] = [];
|
|
42
|
+
let oldNo = 0;
|
|
43
|
+
let newNo = 0;
|
|
44
|
+
let added = 0;
|
|
45
|
+
let removed = 0;
|
|
46
|
+
for (const change of changes) {
|
|
47
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
48
|
+
for (const text of lines) {
|
|
49
|
+
if (change.added) {
|
|
50
|
+
newNo++;
|
|
51
|
+
allLines.push({ type: "added", oldNo: null, newNo, text });
|
|
52
|
+
added++;
|
|
53
|
+
} else if (change.removed) {
|
|
54
|
+
oldNo++;
|
|
55
|
+
allLines.push({ type: "removed", oldNo, newNo: null, text });
|
|
56
|
+
removed++;
|
|
57
|
+
} else {
|
|
58
|
+
oldNo++;
|
|
59
|
+
newNo++;
|
|
60
|
+
allLines.push({ type: "context", oldNo, newNo, text });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (allLines.length === 0) return null;
|
|
65
|
+
return {
|
|
66
|
+
hunks: [{ lines: allLines }],
|
|
67
|
+
added,
|
|
68
|
+
removed,
|
|
69
|
+
isIdentical: false,
|
|
70
|
+
isNewFile,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pi's edit returns a custom diff string: prefix(+/-/space) + lineNum + " " + text, "..." between hunks.
|
|
75
|
+
function parsePiDiff(raw: unknown): DiffResultRecord | null {
|
|
76
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
77
|
+
const hunks: DiffHunkRecord[] = [];
|
|
78
|
+
let current: DiffLineRecord[] = [];
|
|
79
|
+
let added = 0;
|
|
80
|
+
let removed = 0;
|
|
81
|
+
let hasOriginal = false;
|
|
82
|
+
let delta = 0;
|
|
83
|
+
|
|
84
|
+
const flush = () => {
|
|
85
|
+
if (current.length > 0) hunks.push({ lines: current });
|
|
86
|
+
current = [];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const line of raw.split("\n")) {
|
|
90
|
+
if (line.length === 0) continue;
|
|
91
|
+
const prefix = line[0];
|
|
92
|
+
const rest = line.slice(1);
|
|
93
|
+
if (prefix === " " && rest.trim() === "...") { flush(); continue; }
|
|
94
|
+
const m = rest.match(/^\s*(\d+)\s(.*)$/);
|
|
95
|
+
if (!m) continue;
|
|
96
|
+
const num = parseInt(m[1]!, 10);
|
|
97
|
+
const text = m[2]!;
|
|
98
|
+
if (prefix === "+") {
|
|
99
|
+
current.push({ type: "added", oldNo: null, newNo: num, text });
|
|
100
|
+
added++;
|
|
101
|
+
delta++;
|
|
102
|
+
} else if (prefix === "-") {
|
|
103
|
+
current.push({ type: "removed", oldNo: num, newNo: null, text });
|
|
104
|
+
removed++;
|
|
105
|
+
delta--;
|
|
106
|
+
hasOriginal = true;
|
|
107
|
+
} else if (prefix === " ") {
|
|
108
|
+
current.push({ type: "context", oldNo: num, newNo: num + delta, text });
|
|
109
|
+
hasOriginal = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
flush();
|
|
113
|
+
|
|
114
|
+
if (hunks.length === 0) return null;
|
|
115
|
+
return { hunks, added, removed, isIdentical: added + removed === 0, isNewFile: !hasOriginal };
|
|
116
|
+
}
|
|
29
117
|
|
|
30
|
-
// ── Extension entry point ─────────────────────────────────────────
|
|
31
118
|
export default function activate(ctx: ExtensionContext): void {
|
|
32
|
-
const { bus } = ctx;
|
|
119
|
+
const { bus, call } = ctx;
|
|
33
120
|
const cwd = process.cwd();
|
|
34
121
|
|
|
35
|
-
// ── Boot pi session (async — register backend synchronously first) ──
|
|
36
122
|
let session: any = null;
|
|
37
123
|
let runtime: any = null;
|
|
124
|
+
let modelRegistry: any = null;
|
|
38
125
|
let booting = true;
|
|
39
126
|
|
|
127
|
+
const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
128
|
+
|
|
129
|
+
// Pi's tool_execution_end omits `args` — cache from start so the end handler can use the path.
|
|
130
|
+
const pendingArgs = new Map<string, any>();
|
|
131
|
+
// Snapshot disk content before pi writes; diffed against args.content at end.
|
|
132
|
+
const pendingWriteSnapshot = new Map<string, { oldContent: string; isNewFile: boolean }>();
|
|
133
|
+
|
|
40
134
|
const boot = async () => {
|
|
41
135
|
try {
|
|
42
|
-
// Pi loads its own config: ~/.pi/agent/settings.json, models, extensions
|
|
43
136
|
const services = await createAgentSessionServices({ cwd });
|
|
137
|
+
modelRegistry = services.modelRegistry;
|
|
44
138
|
const sessionManager = SessionManager.inMemory(cwd);
|
|
45
139
|
|
|
46
|
-
// createRuntime factory — returns { session, services, ... } as expected
|
|
47
|
-
// by createAgentSessionRuntime
|
|
48
140
|
const createRuntime = async (opts: any) => {
|
|
49
141
|
const result = await createAgentSessionFromServices({
|
|
50
142
|
services,
|
|
@@ -59,7 +151,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
59
151
|
});
|
|
60
152
|
session = runtime.session;
|
|
61
153
|
|
|
62
|
-
// Subscribe to pi events → agent-sh bus
|
|
63
154
|
let fullResponseText = "";
|
|
64
155
|
|
|
65
156
|
session.subscribe((event: AgentEvent) => {
|
|
@@ -81,13 +172,46 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
81
172
|
break;
|
|
82
173
|
}
|
|
83
174
|
|
|
84
|
-
case "
|
|
175
|
+
case "message_end": {
|
|
176
|
+
// Synthesize agent:tool-batch so tui-renderer groups parallel tool calls under one header.
|
|
177
|
+
const msg = (event as any).message;
|
|
178
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
179
|
+
const groupMap = new Map<string, Array<{ name: string }>>();
|
|
180
|
+
for (const block of msg.content) {
|
|
181
|
+
if (block?.type === "toolCall" && typeof block.name === "string") {
|
|
182
|
+
const kind = kindForTool(block.name);
|
|
183
|
+
if (!groupMap.has(kind)) groupMap.set(kind, []);
|
|
184
|
+
groupMap.get(kind)!.push({ name: block.name });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (groupMap.size > 0) {
|
|
188
|
+
const groups = Array.from(groupMap.entries()).map(([kind, tools]) => ({ kind, tools }));
|
|
189
|
+
bus.emit("agent:tool-batch", { groups });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "tool_execution_start": {
|
|
196
|
+
const ev = event as any;
|
|
197
|
+
if (ev.toolCallId) pendingArgs.set(ev.toolCallId, ev.args);
|
|
198
|
+
if (ev.toolName === "write" && ev.toolCallId && typeof ev.args?.path === "string") {
|
|
199
|
+
const abs = resolvePath(cwd, ev.args.path);
|
|
200
|
+
let oldContent = "";
|
|
201
|
+
let isNewFile = true;
|
|
202
|
+
if (existsSync(abs)) {
|
|
203
|
+
try { oldContent = readFileSync(abs, "utf8"); isNewFile = false; } catch {}
|
|
204
|
+
}
|
|
205
|
+
pendingWriteSnapshot.set(ev.toolCallId, { oldContent, isNewFile });
|
|
206
|
+
}
|
|
85
207
|
bus.emit("agent:tool-started", {
|
|
86
|
-
title:
|
|
87
|
-
toolCallId:
|
|
88
|
-
kind: (
|
|
208
|
+
title: ev.toolName,
|
|
209
|
+
toolCallId: ev.toolCallId,
|
|
210
|
+
kind: kindForTool(ev.toolName),
|
|
211
|
+
rawInput: ev.args,
|
|
89
212
|
});
|
|
90
213
|
break;
|
|
214
|
+
}
|
|
91
215
|
|
|
92
216
|
case "tool_execution_update": {
|
|
93
217
|
const pr = (event as any).partialResult as
|
|
@@ -103,13 +227,41 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
103
227
|
break;
|
|
104
228
|
}
|
|
105
229
|
|
|
106
|
-
case "tool_execution_end":
|
|
230
|
+
case "tool_execution_end": {
|
|
231
|
+
const ev = event as any;
|
|
232
|
+
const args = ev.toolCallId ? pendingArgs.get(ev.toolCallId) : undefined;
|
|
233
|
+
if (ev.toolCallId) pendingArgs.delete(ev.toolCallId);
|
|
234
|
+
let resultDisplay: { body?: { kind: "diff"; diff: unknown; filePath: string } } | undefined;
|
|
235
|
+
if (ev.toolName === "edit" && typeof args?.path === "string") {
|
|
236
|
+
const rawDiff = ev.result?.details?.diff;
|
|
237
|
+
const parsed = parsePiDiff(rawDiff);
|
|
238
|
+
if (parsed) {
|
|
239
|
+
resultDisplay = {
|
|
240
|
+
body: { kind: "diff", diff: parsed, filePath: args.path },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
} else if (ev.toolName === "write" && typeof args?.path === "string" && !ev.isError) {
|
|
244
|
+
const snap = ev.toolCallId ? pendingWriteSnapshot.get(ev.toolCallId) : undefined;
|
|
245
|
+
if (ev.toolCallId) pendingWriteSnapshot.delete(ev.toolCallId);
|
|
246
|
+
if (snap) {
|
|
247
|
+
const newContent = typeof args.content === "string" ? args.content : "";
|
|
248
|
+
const built = buildDiffFromTexts(snap.oldContent, newContent, snap.isNewFile);
|
|
249
|
+
if (built) {
|
|
250
|
+
resultDisplay = {
|
|
251
|
+
body: { kind: "diff", diff: built, filePath: args.path },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
107
256
|
bus.emit("agent:tool-completed", {
|
|
108
|
-
toolCallId:
|
|
109
|
-
exitCode:
|
|
110
|
-
kind: (
|
|
257
|
+
toolCallId: ev.toolCallId,
|
|
258
|
+
exitCode: ev.isError ? 1 : 0,
|
|
259
|
+
kind: kindForTool(ev.toolName),
|
|
260
|
+
rawOutput: ev.result,
|
|
261
|
+
resultDisplay,
|
|
111
262
|
});
|
|
112
263
|
break;
|
|
264
|
+
}
|
|
113
265
|
|
|
114
266
|
case "agent_end":
|
|
115
267
|
bus.emitTransform("agent:response-done", {
|
|
@@ -120,7 +272,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
120
272
|
}
|
|
121
273
|
});
|
|
122
274
|
|
|
123
|
-
// Report model info
|
|
124
275
|
const model = session.model;
|
|
125
276
|
bus.emit("agent:info", {
|
|
126
277
|
name: "pi",
|
|
@@ -137,8 +288,10 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
137
288
|
}
|
|
138
289
|
};
|
|
139
290
|
|
|
140
|
-
|
|
141
|
-
|
|
291
|
+
type ListenerEntry =
|
|
292
|
+
| { kind: "on"; event: string; fn: Function }
|
|
293
|
+
| { kind: "pipe"; event: string; fn: Function };
|
|
294
|
+
const listeners: ListenerEntry[] = [];
|
|
142
295
|
|
|
143
296
|
const wireListeners = () => {
|
|
144
297
|
const onSubmit = async ({ query }: any) => {
|
|
@@ -153,8 +306,12 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
153
306
|
bus.emit("agent:query", { query });
|
|
154
307
|
bus.emit("agent:processing-start", {});
|
|
155
308
|
|
|
309
|
+
// Inline producers raw — outputs already self-tag (<shell_events>...).
|
|
310
|
+
const ctxText = String(call("query-context:build") ?? "").trim();
|
|
311
|
+
const final = ctxText ? `${ctxText}\n\n${query}` : query;
|
|
312
|
+
|
|
156
313
|
try {
|
|
157
|
-
await session.prompt(
|
|
314
|
+
await session.prompt(final);
|
|
158
315
|
} catch (err) {
|
|
159
316
|
bus.emit("agent:error", {
|
|
160
317
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -169,33 +326,148 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
169
326
|
session = runtime?.session;
|
|
170
327
|
};
|
|
171
328
|
|
|
329
|
+
const onListModels = () => {
|
|
330
|
+
if (!session || !modelRegistry) return { models: [], active: null };
|
|
331
|
+
const all = modelRegistry.getAvailable() as Array<{ id: string; provider: string }>;
|
|
332
|
+
const cur = session.model;
|
|
333
|
+
return {
|
|
334
|
+
models: all.map((m) => ({ model: m.id, provider: m.provider })),
|
|
335
|
+
active: cur ? { model: cur.id, provider: cur.provider } : null,
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Slash command emits `model@provider` for disambiguation; pi looks up by (provider, id).
|
|
340
|
+
const onSwitchModel = async ({ model: target }: { model: string }) => {
|
|
341
|
+
if (!session || !modelRegistry) return;
|
|
342
|
+
const atIdx = target.lastIndexOf("@");
|
|
343
|
+
const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
|
|
344
|
+
const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
|
|
345
|
+
|
|
346
|
+
const candidates = (modelRegistry.getAvailable() as Array<{ id: string; provider: string }>)
|
|
347
|
+
.filter((m) => m.id === modelId && (!providerHint || m.provider === providerHint));
|
|
348
|
+
|
|
349
|
+
if (candidates.length === 0) {
|
|
350
|
+
bus.emit("ui:error", { message: `Unknown model: ${target}` });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (candidates.length > 1) {
|
|
354
|
+
const opts = candidates.map((m) => `${m.id}@${m.provider}`).join(", ");
|
|
355
|
+
bus.emit("ui:error", { message: `Ambiguous model "${modelId}". Use one of: ${opts}` });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const picked = candidates[0]!;
|
|
359
|
+
const full = modelRegistry.find(picked.provider, picked.id);
|
|
360
|
+
if (!full) {
|
|
361
|
+
bus.emit("ui:error", { message: `Model not found: ${target}` });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
await session.setModel(full);
|
|
366
|
+
bus.emit("agent:info", {
|
|
367
|
+
name: "pi",
|
|
368
|
+
version: "0.66",
|
|
369
|
+
model: `${picked.provider}/${picked.id}`,
|
|
370
|
+
});
|
|
371
|
+
bus.emit("ui:info", { message: `Model: ${picked.provider}: ${picked.id}` });
|
|
372
|
+
bus.emit("config:changed", {});
|
|
373
|
+
} catch (err) {
|
|
374
|
+
bus.emit("ui:error", {
|
|
375
|
+
message: `Failed to switch model: ${err instanceof Error ? err.message : String(err)}`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const onGetThinking = () => {
|
|
381
|
+
const level = session?.thinkingLevel ?? "off";
|
|
382
|
+
return { level, levels: [...PI_THINKING_LEVELS], supported: true };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const onSetThinking = ({ level }: { level: string }) => {
|
|
386
|
+
if (!session) return;
|
|
387
|
+
if (!PI_THINKING_LEVELS.includes(level as any)) {
|
|
388
|
+
bus.emit("ui:error", {
|
|
389
|
+
message: `Unknown thinking level: ${level}. Use: ${PI_THINKING_LEVELS.join(", ")}`,
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
session.setThinkingLevel(level);
|
|
394
|
+
bus.emit("ui:info", { message: `Thinking: ${level}` });
|
|
395
|
+
bus.emit("config:changed", {});
|
|
396
|
+
};
|
|
397
|
+
|
|
172
398
|
bus.on("agent:submit", onSubmit);
|
|
173
399
|
bus.on("agent:cancel-request", onCancel);
|
|
174
400
|
bus.on("agent:reset-session", onReset);
|
|
401
|
+
bus.on("config:switch-model", onSwitchModel as any);
|
|
402
|
+
bus.on("config:set-thinking", onSetThinking as any);
|
|
403
|
+
bus.onPipe("config:get-models", onListModels as any);
|
|
404
|
+
bus.onPipe("config:get-thinking", onGetThinking as any);
|
|
175
405
|
listeners.push(
|
|
176
|
-
{ event: "agent:submit", fn: onSubmit },
|
|
177
|
-
{ event: "agent:cancel-request", fn: onCancel },
|
|
178
|
-
{ event: "agent:reset-session", fn: onReset },
|
|
406
|
+
{ kind: "on", event: "agent:submit", fn: onSubmit },
|
|
407
|
+
{ kind: "on", event: "agent:cancel-request", fn: onCancel },
|
|
408
|
+
{ kind: "on", event: "agent:reset-session", fn: onReset },
|
|
409
|
+
{ kind: "on", event: "config:switch-model", fn: onSwitchModel },
|
|
410
|
+
{ kind: "on", event: "config:set-thinking", fn: onSetThinking },
|
|
411
|
+
{ kind: "pipe", event: "config:get-models", fn: onListModels },
|
|
412
|
+
{ kind: "pipe", event: "config:get-thinking", fn: onGetThinking },
|
|
179
413
|
);
|
|
180
414
|
};
|
|
181
415
|
|
|
182
416
|
const unwireListeners = () => {
|
|
183
|
-
for (const { event, fn } of listeners)
|
|
417
|
+
for (const { kind, event, fn } of listeners) {
|
|
418
|
+
if (kind === "pipe") bus.offPipe(event as any, fn as any);
|
|
419
|
+
else bus.off(event as any, fn as any);
|
|
420
|
+
}
|
|
184
421
|
listeners.length = 0;
|
|
185
422
|
};
|
|
186
423
|
|
|
187
|
-
// ── Register as backend ───────────────────────────────────────
|
|
188
424
|
bus.emit("agent:register-backend", {
|
|
189
425
|
name: "pi",
|
|
190
426
|
start: async () => {
|
|
191
427
|
await boot();
|
|
192
428
|
wireListeners();
|
|
429
|
+
bus.emit("command:register", {
|
|
430
|
+
name: "/compact",
|
|
431
|
+
description: "Compact pi's session context",
|
|
432
|
+
handler: async () => {
|
|
433
|
+
if (!session) return;
|
|
434
|
+
try {
|
|
435
|
+
await session.compact();
|
|
436
|
+
bus.emit("ui:info", { message: "(compacted)" });
|
|
437
|
+
} catch (err) {
|
|
438
|
+
bus.emit("ui:info", {
|
|
439
|
+
message: `(${err instanceof Error ? err.message : String(err)})`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
bus.emit("command:register", {
|
|
445
|
+
name: "/context",
|
|
446
|
+
description: "Show pi's context budget usage",
|
|
447
|
+
handler: () => {
|
|
448
|
+
if (!session) return;
|
|
449
|
+
const usage = session.getContextUsage() as { tokens: number; contextWindow: number } | undefined;
|
|
450
|
+
if (!usage) {
|
|
451
|
+
bus.emit("ui:info", { message: "Context: not available yet" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const pct = usage.contextWindow > 0
|
|
455
|
+
? Math.round((usage.tokens / usage.contextWindow) * 100)
|
|
456
|
+
: 0;
|
|
457
|
+
bus.emit("ui:info", {
|
|
458
|
+
message: `Active context: ~${usage.tokens.toLocaleString()} tokens / ${usage.contextWindow.toLocaleString()} budget (${pct}%)`,
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
});
|
|
193
462
|
},
|
|
194
463
|
kill: () => {
|
|
464
|
+
bus.emit("command:unregister", { name: "/compact" });
|
|
465
|
+
bus.emit("command:unregister", { name: "/context" });
|
|
195
466
|
unwireListeners();
|
|
196
467
|
runtime?.dispose();
|
|
197
468
|
session = null;
|
|
198
469
|
runtime = null;
|
|
470
|
+
modelRegistry = null;
|
|
199
471
|
booting = true;
|
|
200
472
|
},
|
|
201
473
|
});
|