agent-sh 0.12.21 → 0.12.22
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 +16 -22
- package/dist/event-bus.d.ts +8 -1
- package/dist/event-bus.js +9 -0
- package/dist/extensions/agent-backend.js +49 -3
- package/dist/extensions/slash-commands.js +0 -24
- package/dist/index.js +8 -33
- package/examples/extensions/pi-bridge/README.md +12 -19
- package/examples/extensions/pi-bridge/index.ts +307 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
|
|
|
19
19
|
~ $ > draft a commit message # agent reads your diff and shell history
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
I still use
|
|
22
|
+
I still use a proper coding harness for serious work — this doesn't replace that. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, you can swap in [pi](examples/extensions/pi-bridge/) as the backend via a bridge extension.
|
|
23
23
|
|
|
24
24
|
## Quick Start
|
|
25
25
|
|
|
@@ -95,7 +95,7 @@ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fis
|
|
|
95
95
|
|
|
96
96
|
**Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
|
|
97
97
|
|
|
98
|
-
**Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [
|
|
98
|
+
**Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [pi](examples/extensions/pi-bridge/) runs as a drop-in backend extension.
|
|
99
99
|
|
|
100
100
|
**Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
|
|
101
101
|
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -59,6 +59,7 @@ export class AgentLoop {
|
|
|
59
59
|
modes;
|
|
60
60
|
currentModeIndex = 0;
|
|
61
61
|
boundListeners = [];
|
|
62
|
+
boundPipeListeners = [];
|
|
62
63
|
ctorListeners = [];
|
|
63
64
|
ctorPipeListeners = [];
|
|
64
65
|
lastProjectSkillNames = new Set();
|
|
@@ -216,12 +217,24 @@ export class AgentLoop {
|
|
|
216
217
|
this.bus.on(event, fn);
|
|
217
218
|
this.boundListeners.push({ event, fn });
|
|
218
219
|
};
|
|
220
|
+
const onPipe = (event, fn) => {
|
|
221
|
+
this.bus.onPipe(event, fn);
|
|
222
|
+
this.boundPipeListeners.push({ event, fn, async: false });
|
|
223
|
+
};
|
|
224
|
+
const onPipeAsync = (event, fn) => {
|
|
225
|
+
this.bus.onPipeAsync(event, fn);
|
|
226
|
+
this.boundPipeListeners.push({ event, fn, async: true });
|
|
227
|
+
};
|
|
219
228
|
on("agent:submit", ({ query }) => {
|
|
220
229
|
this.handleQuery(query).catch(() => { });
|
|
221
230
|
});
|
|
222
231
|
on("agent:cancel-request", (e) => {
|
|
223
232
|
this.abortController?.abort(e.silent ? "silent" : undefined);
|
|
224
233
|
});
|
|
234
|
+
on("agent:append-user-message", ({ text }) => {
|
|
235
|
+
this.conversation.appendUserMessage(text);
|
|
236
|
+
this.bus.emit("conversation:message-appended", { role: "user", content: text });
|
|
237
|
+
});
|
|
225
238
|
on("config:switch-model", ({ model: target }) => {
|
|
226
239
|
const atIdx = target.lastIndexOf("@");
|
|
227
240
|
const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
|
|
@@ -257,7 +270,7 @@ export class AgentLoop {
|
|
|
257
270
|
}
|
|
258
271
|
this.bus.emit("config:changed", {});
|
|
259
272
|
});
|
|
260
|
-
|
|
273
|
+
onPipe("config:get-models", () => {
|
|
261
274
|
const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
|
|
262
275
|
const cur = this.modes[this.currentModeIndex];
|
|
263
276
|
const active = cur ? { model: cur.model, provider: cur.provider ?? "" } : null;
|
|
@@ -281,7 +294,7 @@ export class AgentLoop {
|
|
|
281
294
|
this.bus.emit("ui:info", { message: `Thinking: ${level}` });
|
|
282
295
|
this.bus.emit("config:changed", {});
|
|
283
296
|
});
|
|
284
|
-
|
|
297
|
+
onPipe("config:get-thinking", () => {
|
|
285
298
|
const mode = this.currentMode;
|
|
286
299
|
const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
|
|
287
300
|
return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
|
|
@@ -303,20 +316,20 @@ export class AgentLoop {
|
|
|
303
316
|
this.bus.emit("ui:info", { message: "(nothing to compact)" });
|
|
304
317
|
}
|
|
305
318
|
});
|
|
306
|
-
|
|
319
|
+
onPipe("context:get-stats", () => ({
|
|
307
320
|
activeTokens: this.conversation.estimateTokens(),
|
|
308
321
|
totalTokens: this.conversation.estimatePromptTokens(),
|
|
309
322
|
nuclearEntries: this.conversation.getNuclearEntryCount(),
|
|
310
323
|
recallArchiveSize: this.conversation.getRecallArchiveSize(),
|
|
311
324
|
budgetTokens: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
312
325
|
}));
|
|
313
|
-
|
|
326
|
+
onPipe("context:snapshot", (payload) => {
|
|
314
327
|
payload.messages = this.conversation.getMessages();
|
|
315
328
|
payload.contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
316
329
|
payload.activeTokens = this.conversation.estimateTokens();
|
|
317
330
|
return payload;
|
|
318
331
|
});
|
|
319
|
-
|
|
332
|
+
onPipeAsync("context:compact", async (payload) => {
|
|
320
333
|
const stats = await this.compactWithHooks(0, undefined, false, payload.strategy);
|
|
321
334
|
if (stats)
|
|
322
335
|
payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
|
|
@@ -366,6 +379,13 @@ export class AgentLoop {
|
|
|
366
379
|
this.bus.off(event, fn);
|
|
367
380
|
}
|
|
368
381
|
this.boundListeners = [];
|
|
382
|
+
for (const { event, fn, async } of this.boundPipeListeners) {
|
|
383
|
+
if (async)
|
|
384
|
+
this.bus.offPipeAsync(event, fn);
|
|
385
|
+
else
|
|
386
|
+
this.bus.offPipe(event, fn);
|
|
387
|
+
}
|
|
388
|
+
this.boundPipeListeners = [];
|
|
369
389
|
}
|
|
370
390
|
/** Register a tool (used by extensions via ctx.registerTool). */
|
|
371
391
|
registerTool(tool) {
|
|
@@ -38,7 +38,7 @@ export declare class ConversationState {
|
|
|
38
38
|
private nextSeq;
|
|
39
39
|
private lastApiTokenCount;
|
|
40
40
|
private lastApiMessageCount;
|
|
41
|
-
private
|
|
41
|
+
private pendingMessages;
|
|
42
42
|
constructor(handlers?: HandlerFunctions, instanceId?: string);
|
|
43
43
|
/** Get JSON.stringify of messages, cached until next mutation. */
|
|
44
44
|
private getMessagesJson;
|
|
@@ -56,8 +56,9 @@ export declare class ConversationState {
|
|
|
56
56
|
addToolResultInline(content: string): void;
|
|
57
57
|
/** Safe from any context: queues if mid-tool-pair, appends otherwise. */
|
|
58
58
|
addSystemNote(text: string): void;
|
|
59
|
+
appendUserMessage(text: string): void;
|
|
59
60
|
private hasOpenToolCalls;
|
|
60
|
-
private
|
|
61
|
+
private flushPendingMessages;
|
|
61
62
|
getMessages(): ChatCompletionMessageParam[];
|
|
62
63
|
/** Drop tool messages with no matching preceding tool_call — strict
|
|
63
64
|
* providers (DeepSeek) 400, and compaction can leave such orphans. */
|
|
@@ -56,10 +56,10 @@ export class ConversationState {
|
|
|
56
56
|
nextSeq = 1;
|
|
57
57
|
lastApiTokenCount = null;
|
|
58
58
|
lastApiMessageCount = 0;
|
|
59
|
-
//
|
|
60
|
-
// the trailing tool_result lands. Splicing into the gap
|
|
61
|
-
// reasoning_content pairing and is rejected by strict providers.
|
|
62
|
-
|
|
59
|
+
// Buffered when addSystemNote/appendUserMessage fires mid-tool-pair;
|
|
60
|
+
// flushed once the trailing tool_result lands. Splicing into the gap
|
|
61
|
+
// breaks reasoning_content pairing and is rejected by strict providers.
|
|
62
|
+
pendingMessages = [];
|
|
63
63
|
constructor(handlers, instanceId = "0000") {
|
|
64
64
|
this.handlers = handlers ?? null;
|
|
65
65
|
this.instanceId = instanceId;
|
|
@@ -114,23 +114,30 @@ export class ConversationState {
|
|
|
114
114
|
if (isError)
|
|
115
115
|
this.toolErrors.add(toolCallId);
|
|
116
116
|
this.invalidateMessagesCache();
|
|
117
|
-
this.
|
|
117
|
+
this.flushPendingMessages();
|
|
118
118
|
}
|
|
119
119
|
/** Add tool results as a user message (for inline tool protocol). */
|
|
120
120
|
addToolResultInline(content) {
|
|
121
121
|
this.messages.push({ role: "user", content });
|
|
122
122
|
this.invalidateMessagesCache();
|
|
123
|
-
this.
|
|
123
|
+
this.flushPendingMessages();
|
|
124
124
|
}
|
|
125
125
|
/** Safe from any context: queues if mid-tool-pair, appends otherwise. */
|
|
126
126
|
addSystemNote(text) {
|
|
127
127
|
if (this.hasOpenToolCalls()) {
|
|
128
|
-
this.
|
|
128
|
+
this.pendingMessages.push({ kind: "system", text });
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
this.messages.push({ role: "user", content: text });
|
|
132
132
|
this.invalidateMessagesCache();
|
|
133
133
|
}
|
|
134
|
+
appendUserMessage(text) {
|
|
135
|
+
if (this.hasOpenToolCalls()) {
|
|
136
|
+
this.pendingMessages.push({ kind: "user", text });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.addUserMessage(text);
|
|
140
|
+
}
|
|
134
141
|
hasOpenToolCalls() {
|
|
135
142
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
136
143
|
const msg = this.messages[i];
|
|
@@ -151,15 +158,21 @@ export class ConversationState {
|
|
|
151
158
|
}
|
|
152
159
|
return false;
|
|
153
160
|
}
|
|
154
|
-
|
|
155
|
-
if (this.
|
|
161
|
+
flushPendingMessages() {
|
|
162
|
+
if (this.pendingMessages.length === 0)
|
|
156
163
|
return;
|
|
157
164
|
if (this.hasOpenToolCalls())
|
|
158
165
|
return;
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
const pending = this.pendingMessages;
|
|
167
|
+
this.pendingMessages = [];
|
|
168
|
+
for (const m of pending) {
|
|
169
|
+
if (m.kind === "user") {
|
|
170
|
+
this.addUserMessage(m.text);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
this.messages.push({ role: "user", content: m.text });
|
|
174
|
+
}
|
|
161
175
|
}
|
|
162
|
-
this.pendingNotes = [];
|
|
163
176
|
this.invalidateMessagesCache();
|
|
164
177
|
}
|
|
165
178
|
getMessages() {
|
|
@@ -244,7 +257,7 @@ export class ConversationState {
|
|
|
244
257
|
this.invalidateMessagesCache();
|
|
245
258
|
this.lastApiTokenCount = null;
|
|
246
259
|
this.lastApiMessageCount = 0;
|
|
247
|
-
this.
|
|
260
|
+
this.flushPendingMessages();
|
|
248
261
|
}
|
|
249
262
|
pruneToolErrors() {
|
|
250
263
|
if (this.toolErrors.size === 0)
|
|
@@ -544,7 +557,7 @@ export class ConversationState {
|
|
|
544
557
|
this.nuclearEntries = [];
|
|
545
558
|
this.nuclearBySeq.clear();
|
|
546
559
|
this.recallArchive.clear();
|
|
547
|
-
this.
|
|
560
|
+
this.pendingMessages = [];
|
|
548
561
|
this.invalidateMessagesCache();
|
|
549
562
|
this.lastApiTokenCount = null;
|
|
550
563
|
this.lastApiMessageCount = 0;
|
package/dist/core.d.ts
CHANGED
|
@@ -37,11 +37,13 @@ export interface AgentShellCore {
|
|
|
37
37
|
/** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
|
|
38
38
|
instanceId: string;
|
|
39
39
|
/** Activate the agent backend (call after extensions load). */
|
|
40
|
-
activateBackend(): void
|
|
40
|
+
activateBackend(): Promise<void>;
|
|
41
41
|
/** Convenience: emit agent:submit and await the response. */
|
|
42
42
|
query(text: string): Promise<string>;
|
|
43
43
|
/** Convenience: emit agent:cancel-request. */
|
|
44
44
|
cancel(): void;
|
|
45
|
+
/** Convenience: emit agent:append-user-message. */
|
|
46
|
+
appendUserMessage(text: string): void;
|
|
45
47
|
/** Build an ExtensionContext for loading extensions against this core. */
|
|
46
48
|
extensionContext(opts: {
|
|
47
49
|
quit: () => void;
|
package/dist/core.js
CHANGED
|
@@ -56,33 +56,30 @@ export function createCore(config) {
|
|
|
56
56
|
handlers.define("query-context:build", () => "");
|
|
57
57
|
const backends = new Map();
|
|
58
58
|
let activeBackendName = null;
|
|
59
|
-
const activateByName = async (name
|
|
59
|
+
const activateByName = async (name) => {
|
|
60
60
|
const backend = backends.get(name);
|
|
61
61
|
if (!backend) {
|
|
62
62
|
bus.emit("ui:error", { message: `Unknown backend: ${name}` });
|
|
63
|
-
return;
|
|
63
|
+
return false;
|
|
64
64
|
}
|
|
65
|
-
// Deactivate current backend
|
|
66
65
|
if (activeBackendName) {
|
|
67
66
|
backends.get(activeBackendName)?.kill();
|
|
68
67
|
}
|
|
69
|
-
// Activate new backend
|
|
70
68
|
await backend.start?.();
|
|
71
69
|
activeBackendName = name;
|
|
72
|
-
|
|
73
|
-
bus.emit("ui:info", { message: `Backend: ${name}` });
|
|
74
|
-
}
|
|
75
|
-
bus.emit("config:changed", {});
|
|
70
|
+
return true;
|
|
76
71
|
};
|
|
77
72
|
bus.on("agent:register-backend", (backend) => {
|
|
78
73
|
backends.set(backend.name, backend);
|
|
79
74
|
});
|
|
80
75
|
bus.on("config:switch-backend", ({ name }) => {
|
|
81
|
-
activateByName(name).then(() => {
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
activateByName(name).then((ok) => {
|
|
77
|
+
if (!ok)
|
|
78
|
+
return;
|
|
79
|
+
settingsMod.updateSettings({ defaultBackend: name });
|
|
80
|
+
// Single ui:info; config:changed (which triggers prompt redraw) follows it.
|
|
81
|
+
bus.emit("ui:info", { message: `Backend: ${name} (saved as default)` });
|
|
82
|
+
bus.emit("config:changed", {});
|
|
86
83
|
});
|
|
87
84
|
});
|
|
88
85
|
bus.on("config:list-backends", () => {
|
|
@@ -105,18 +102,12 @@ export function createCore(config) {
|
|
|
105
102
|
bus,
|
|
106
103
|
handlers,
|
|
107
104
|
instanceId,
|
|
108
|
-
activateBackend() {
|
|
109
|
-
// Silent — backend info is shown in the startup banner.
|
|
110
|
-
// Runtime switches (config:switch-backend) still emit ui:info.
|
|
105
|
+
async activateBackend() {
|
|
111
106
|
if (backends.size === 0)
|
|
112
107
|
return;
|
|
113
108
|
const preferred = settings.defaultBackend;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
activateByName(backends.keys().next().value, true);
|
|
119
|
-
}
|
|
109
|
+
const name = preferred && backends.has(preferred) ? preferred : backends.keys().next().value;
|
|
110
|
+
await activateByName(name);
|
|
120
111
|
},
|
|
121
112
|
async query(text) {
|
|
122
113
|
return new Promise((resolve, reject) => {
|
|
@@ -155,6 +146,9 @@ export function createCore(config) {
|
|
|
155
146
|
cancel() {
|
|
156
147
|
bus.emit("agent:cancel-request", {});
|
|
157
148
|
},
|
|
149
|
+
appendUserMessage(text) {
|
|
150
|
+
bus.emit("agent:append-user-message", { text });
|
|
151
|
+
},
|
|
158
152
|
extensionContext(opts) {
|
|
159
153
|
const ctx = {
|
|
160
154
|
bus,
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -47,6 +47,9 @@ export interface ShellEvents {
|
|
|
47
47
|
"agent:cancel-request": {
|
|
48
48
|
silent?: boolean;
|
|
49
49
|
};
|
|
50
|
+
"agent:append-user-message": {
|
|
51
|
+
text: string;
|
|
52
|
+
};
|
|
50
53
|
"input-mode:register": import("./types.js").InputModeConfig;
|
|
51
54
|
"agent:query": {
|
|
52
55
|
query: string;
|
|
@@ -307,7 +310,9 @@ export interface ShellEvents {
|
|
|
307
310
|
"config:add-modes": {
|
|
308
311
|
modes: AgentMode[];
|
|
309
312
|
};
|
|
310
|
-
"core:extensions-loaded":
|
|
313
|
+
"core:extensions-loaded": {
|
|
314
|
+
names: string[];
|
|
315
|
+
};
|
|
311
316
|
"provider:register": {
|
|
312
317
|
id: string;
|
|
313
318
|
apiKey?: string;
|
|
@@ -445,6 +450,8 @@ export declare class EventBus {
|
|
|
445
450
|
* If no listeners are registered, returns the original payload unchanged.
|
|
446
451
|
*/
|
|
447
452
|
emitPipe<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): ShellEvents[K];
|
|
453
|
+
/** Remove an async transform listener from a pipeline event. */
|
|
454
|
+
offPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
|
|
448
455
|
/** Register an async transform listener for a pipeline event. */
|
|
449
456
|
onPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
|
|
450
457
|
/**
|
package/dist/event-bus.js
CHANGED
|
@@ -131,6 +131,15 @@ export class EventBus {
|
|
|
131
131
|
}
|
|
132
132
|
return result;
|
|
133
133
|
}
|
|
134
|
+
/** Remove an async transform listener from a pipeline event. */
|
|
135
|
+
offPipeAsync(event, fn) {
|
|
136
|
+
const listeners = this.asyncPipeListeners.get(event);
|
|
137
|
+
if (!listeners)
|
|
138
|
+
return;
|
|
139
|
+
const idx = listeners.indexOf(fn);
|
|
140
|
+
if (idx !== -1)
|
|
141
|
+
listeners.splice(idx, 1);
|
|
142
|
+
}
|
|
134
143
|
/** Register an async transform listener for a pipeline event. */
|
|
135
144
|
onPipeAsync(event, fn) {
|
|
136
145
|
let listeners = this.asyncPipeListeners.get(event);
|
|
@@ -2,6 +2,7 @@ import { AgentLoop } from "../agent/agent-loop.js";
|
|
|
2
2
|
import { LlmClient } from "../utils/llm-client.js";
|
|
3
3
|
import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
|
|
4
4
|
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
5
|
+
import { discoverSkills } from "../agent/skills.js";
|
|
5
6
|
/** Read the user's persisted defaultModel for a provider, if any. */
|
|
6
7
|
function persistedModelFor(providerName) {
|
|
7
8
|
if (!providerName)
|
|
@@ -88,6 +89,8 @@ export default function agentBackend(ctx) {
|
|
|
88
89
|
let modes = [];
|
|
89
90
|
let initialModeIndex = 0;
|
|
90
91
|
let resolved = false;
|
|
92
|
+
// Gates late-registration reconcile so its config:switch-model emit doesn't misroute under a non-ash backend.
|
|
93
|
+
let ashActive = false;
|
|
91
94
|
bus.onPipe("config:get-initial-modes", () => ({ modes, initialModeIndex }));
|
|
92
95
|
// AgentLoop must be constructed *before* user extensions activate,
|
|
93
96
|
// because its ctor defines handlers (history:append, etc.) that
|
|
@@ -104,7 +107,9 @@ export default function agentBackend(ctx) {
|
|
|
104
107
|
instanceId: ctx.instanceId,
|
|
105
108
|
history: config.history,
|
|
106
109
|
});
|
|
107
|
-
|
|
110
|
+
let loadedExtensionNames = [];
|
|
111
|
+
bus.on("core:extensions-loaded", ({ names }) => {
|
|
112
|
+
loadedExtensionNames = names;
|
|
108
113
|
const settings = getSettings();
|
|
109
114
|
// If the user didn't pick a default, fall back to the first registered
|
|
110
115
|
// provider (built-in load order biases to openrouter → openai).
|
|
@@ -149,9 +154,37 @@ export default function agentBackend(ctx) {
|
|
|
149
154
|
resolved = true;
|
|
150
155
|
bus.emit("agent:register-backend", {
|
|
151
156
|
name: "ash",
|
|
152
|
-
kill: () =>
|
|
157
|
+
kill: () => {
|
|
158
|
+
ashActive = false;
|
|
159
|
+
bus.emit("command:unregister", { name: "/compact" });
|
|
160
|
+
bus.emit("command:unregister", { name: "/context" });
|
|
161
|
+
agentLoop.kill();
|
|
162
|
+
},
|
|
153
163
|
start: async () => {
|
|
154
164
|
agentLoop.wire();
|
|
165
|
+
ashActive = true;
|
|
166
|
+
bus.emit("command:register", {
|
|
167
|
+
name: "/compact",
|
|
168
|
+
description: "Compact conversation via the active compaction strategy",
|
|
169
|
+
handler: () => bus.emit("agent:compact-request", {}),
|
|
170
|
+
});
|
|
171
|
+
bus.emit("command:register", {
|
|
172
|
+
name: "/context",
|
|
173
|
+
description: "Show context budget usage",
|
|
174
|
+
handler: () => {
|
|
175
|
+
const stats = bus.emitPipe("context:get-stats", {
|
|
176
|
+
activeTokens: 0,
|
|
177
|
+
totalTokens: 0,
|
|
178
|
+
budgetTokens: 0,
|
|
179
|
+
});
|
|
180
|
+
const pct = stats.budgetTokens > 0
|
|
181
|
+
? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
|
|
182
|
+
: 0;
|
|
183
|
+
bus.emit("ui:info", {
|
|
184
|
+
message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
});
|
|
155
188
|
bus.emit("agent:info", {
|
|
156
189
|
name: "ash",
|
|
157
190
|
version: PACKAGE_VERSION,
|
|
@@ -215,7 +248,7 @@ export default function agentBackend(ctx) {
|
|
|
215
248
|
// Late-registration reconcile: if this completes the user's persisted
|
|
216
249
|
// default (openrouter's async fetch delivers the full catalog after
|
|
217
250
|
// we've already fallen back to mode 0), quietly switch to it.
|
|
218
|
-
if (!resolved)
|
|
251
|
+
if (!resolved || !ashActive)
|
|
219
252
|
return;
|
|
220
253
|
const pendingProvider = getSettings().defaultProvider;
|
|
221
254
|
if (pendingProvider !== p.id)
|
|
@@ -259,4 +292,17 @@ export default function agentBackend(ctx) {
|
|
|
259
292
|
bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
|
|
260
293
|
bus.emit("config:changed", {});
|
|
261
294
|
});
|
|
295
|
+
bus.onPipe("banner:collect", (e) => {
|
|
296
|
+
const settings = getSettings();
|
|
297
|
+
if (settings.defaultBackend && settings.defaultBackend !== "ash")
|
|
298
|
+
return e;
|
|
299
|
+
if (loadedExtensionNames.length > 0) {
|
|
300
|
+
e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
|
|
301
|
+
}
|
|
302
|
+
const skills = discoverSkills(ctx.call("cwd") ?? process.cwd());
|
|
303
|
+
if (skills.length > 0) {
|
|
304
|
+
e.sections.push({ label: "Skills", items: skills.map((s) => s.name) });
|
|
305
|
+
}
|
|
306
|
+
return e;
|
|
307
|
+
});
|
|
262
308
|
}
|
|
@@ -76,30 +76,6 @@ export default function activate(ctx) {
|
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
});
|
|
79
|
-
register({
|
|
80
|
-
name: "/compact",
|
|
81
|
-
description: "Compact conversation via the active compaction strategy",
|
|
82
|
-
handler: () => {
|
|
83
|
-
bus.emit("agent:compact-request", {});
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
register({
|
|
87
|
-
name: "/context",
|
|
88
|
-
description: "Show context budget usage",
|
|
89
|
-
handler: () => {
|
|
90
|
-
const stats = bus.emitPipe("context:get-stats", {
|
|
91
|
-
activeTokens: 0,
|
|
92
|
-
totalTokens: 0,
|
|
93
|
-
budgetTokens: 0,
|
|
94
|
-
});
|
|
95
|
-
const pct = stats.budgetTokens > 0
|
|
96
|
-
? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
|
|
97
|
-
: 0;
|
|
98
|
-
bus.emit("ui:info", {
|
|
99
|
-
message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
|
|
100
|
-
});
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
79
|
register({
|
|
104
80
|
name: "/reload",
|
|
105
81
|
description: "Reload user extensions from ~/.agent-sh/extensions/",
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,6 @@ import { palette as p } from "./utils/palette.js";
|
|
|
7
7
|
import { loadBuiltinExtensions } from "./extensions/index.js";
|
|
8
8
|
import { loadExtensions } from "./extension-loader.js";
|
|
9
9
|
import { getSettings } from "./settings.js";
|
|
10
|
-
import { discoverSkills } from "./agent/skills.js";
|
|
11
10
|
import { runInit } from "./init.js";
|
|
12
11
|
import { PACKAGE_VERSION } from "./utils/package-version.js";
|
|
13
12
|
/**
|
|
@@ -270,11 +269,8 @@ async function main() {
|
|
|
270
269
|
if (process.env.DEBUG) {
|
|
271
270
|
console.error('[agent-sh] Extensions loaded');
|
|
272
271
|
}
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
core.bus.emit("core:extensions-loaded", {});
|
|
276
|
-
// ── Discover skills ───────────────────────────────────────────
|
|
277
|
-
const skills = discoverSkills(process.cwd());
|
|
272
|
+
// Names ride along so backend extensions can build banner sections.
|
|
273
|
+
core.bus.emit("core:extensions-loaded", { names: loadedExtensions });
|
|
278
274
|
// ── Activate agent backend ────────────────────────────────────
|
|
279
275
|
// Extensions had their chance to register via agent:register-backend.
|
|
280
276
|
// If none did, the built-in AgentLoop gets wired to bus events.
|
|
@@ -288,6 +284,7 @@ async function main() {
|
|
|
288
284
|
" Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
|
|
289
285
|
process.exit(1);
|
|
290
286
|
}
|
|
287
|
+
// No await: banner must out-race the shell's PS1 arriving via PTY.
|
|
291
288
|
core.activateBackend();
|
|
292
289
|
// ── Startup banner ───────────────────────────────────────────
|
|
293
290
|
const settings = getSettings();
|
|
@@ -295,31 +292,11 @@ async function main() {
|
|
|
295
292
|
const termW = process.stdout.columns || 80;
|
|
296
293
|
const bannerW = Math.min(termW, 60);
|
|
297
294
|
const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const model = info?.model;
|
|
302
|
-
const provider = info?.provider;
|
|
303
|
-
const modelValue = model
|
|
304
|
-
? provider ? `${model} [${provider}]` : model
|
|
305
|
-
: null;
|
|
295
|
+
const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
|
|
296
|
+
? settings.defaultBackend
|
|
297
|
+
: backendNames[0];
|
|
306
298
|
let sections = "";
|
|
307
|
-
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${
|
|
308
|
-
if (modelValue) {
|
|
309
|
-
sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
|
|
310
|
-
}
|
|
311
|
-
if (loadedExtensions.length > 0) {
|
|
312
|
-
sections += `\n\n ${p.muted}Extensions:${p.reset}`;
|
|
313
|
-
for (const name of loadedExtensions) {
|
|
314
|
-
sections += `\n ${p.dim}${name}${p.reset}`;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (skills.length > 0) {
|
|
318
|
-
sections += `\n\n ${p.muted}Skills:${p.reset}`;
|
|
319
|
-
for (const s of skills) {
|
|
320
|
-
sections += `\n ${p.dim}${s.name}${p.reset}`;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
299
|
+
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
|
|
323
300
|
const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
|
|
324
301
|
for (const sec of extSections) {
|
|
325
302
|
sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
|
|
@@ -327,9 +304,7 @@ async function main() {
|
|
|
327
304
|
sections += `\n ${p.dim}${item}${p.reset}`;
|
|
328
305
|
}
|
|
329
306
|
}
|
|
330
|
-
const hint =
|
|
331
|
-
? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
|
|
332
|
-
: `${p.muted}Set ${p.warning}OPENROUTER_API_KEY${p.muted} or ${p.warning}OPENAI_API_KEY${p.muted} and restart to enable AI${p.reset}`;
|
|
307
|
+
const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
|
|
333
308
|
const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
|
|
334
309
|
process.stdout.write("\n" + borderLine + "\n" +
|
|
335
310
|
" " + productName +
|
|
@@ -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
|
});
|