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
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,
|
|
@@ -210,9 +204,11 @@ export function createCore(config) {
|
|
|
210
204
|
cleanups.push(compositor.redirect("agent", surface));
|
|
211
205
|
cleanups.push(compositor.redirect("query", surface));
|
|
212
206
|
cleanups.push(compositor.redirect("status", surface));
|
|
213
|
-
//
|
|
207
|
+
// Suppress the host shell's mute lifecycle and post-turn
|
|
208
|
+
// redraw nudge. on-processing-done is intentionally not advised
|
|
209
|
+
// — its scope cleanup must always run.
|
|
214
210
|
cleanups.push(handlers.advise("shell:on-processing-start", (next) => active ? undefined : next()));
|
|
215
|
-
cleanups.push(handlers.advise("shell:on-processing-
|
|
211
|
+
cleanups.push(handlers.advise("shell:on-processing-redraw", (next) => active ? undefined : next()));
|
|
216
212
|
// Suppress chrome
|
|
217
213
|
if (opts.suppressBorders !== false) {
|
|
218
214
|
cleanups.push(handlers.advise("tui:response-border", (next, ...a) => active ? null : next(...a)));
|
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;
|
|
@@ -211,6 +214,7 @@ export interface ShellEvents {
|
|
|
211
214
|
};
|
|
212
215
|
"shell:redraw-prompt": {
|
|
213
216
|
cwd: string;
|
|
217
|
+
kind: "fresh" | "redraw";
|
|
214
218
|
handled: boolean;
|
|
215
219
|
};
|
|
216
220
|
"shell:exec-request": {
|
|
@@ -307,7 +311,9 @@ export interface ShellEvents {
|
|
|
307
311
|
"config:add-modes": {
|
|
308
312
|
modes: AgentMode[];
|
|
309
313
|
};
|
|
310
|
-
"core:extensions-loaded":
|
|
314
|
+
"core:extensions-loaded": {
|
|
315
|
+
names: string[];
|
|
316
|
+
};
|
|
311
317
|
"provider:register": {
|
|
312
318
|
id: string;
|
|
313
319
|
apiKey?: string;
|
|
@@ -445,6 +451,8 @@ export declare class EventBus {
|
|
|
445
451
|
* If no listeners are registered, returns the original payload unchanged.
|
|
446
452
|
*/
|
|
447
453
|
emitPipe<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): ShellEvents[K];
|
|
454
|
+
/** Remove an async transform listener from a pipeline event. */
|
|
455
|
+
offPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
|
|
448
456
|
/** Register an async transform listener for a pipeline event. */
|
|
449
457
|
onPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
|
|
450
458
|
/**
|
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);
|
package/dist/executor.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { stripAnsi } from "./utils/ansi.js";
|
|
4
|
+
// Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
|
|
5
|
+
function explainSpawnError(err, cwd) {
|
|
6
|
+
if (err.code === "ENOENT" && !existsSync(cwd)) {
|
|
7
|
+
return `cwd no longer exists: ${cwd} (${err.message})`;
|
|
8
|
+
}
|
|
9
|
+
return err.message;
|
|
10
|
+
}
|
|
3
11
|
let cachedBashPath;
|
|
4
12
|
/** Resolve a usable bash binary, or null if none is on PATH.
|
|
5
13
|
* Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
|
|
@@ -60,7 +68,10 @@ export function executeCommand(opts) {
|
|
|
60
68
|
catch (err) {
|
|
61
69
|
session.exitCode = -1;
|
|
62
70
|
session.spawnFailed = true;
|
|
63
|
-
|
|
71
|
+
const msg = err instanceof Error
|
|
72
|
+
? explainSpawnError(err, opts.cwd)
|
|
73
|
+
: String(err);
|
|
74
|
+
session.output = `Failed to spawn: ${msg}`;
|
|
64
75
|
session.done = true;
|
|
65
76
|
session.resolve?.();
|
|
66
77
|
return { session, done };
|
|
@@ -103,7 +114,7 @@ export function executeCommand(opts) {
|
|
|
103
114
|
const code = err.code;
|
|
104
115
|
if (code === "ENOENT" || code === "EACCES")
|
|
105
116
|
session.spawnFailed = true;
|
|
106
|
-
session.output += `\nProcess error: ${err.
|
|
117
|
+
session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
|
|
107
118
|
session.done = true;
|
|
108
119
|
session.process = null;
|
|
109
120
|
session.resolve?.();
|
|
@@ -149,7 +160,10 @@ export function executeArgv(opts) {
|
|
|
149
160
|
catch (err) {
|
|
150
161
|
session.exitCode = -1;
|
|
151
162
|
session.spawnFailed = true;
|
|
152
|
-
|
|
163
|
+
const msg = err instanceof Error
|
|
164
|
+
? explainSpawnError(err, opts.cwd)
|
|
165
|
+
: String(err);
|
|
166
|
+
session.output = `Failed to spawn ${opts.file}: ${msg}`;
|
|
153
167
|
session.done = true;
|
|
154
168
|
session.resolve?.();
|
|
155
169
|
return { session, done };
|
|
@@ -197,7 +211,7 @@ export function executeArgv(opts) {
|
|
|
197
211
|
const code = err.code;
|
|
198
212
|
if (code === "ENOENT" || code === "EACCES")
|
|
199
213
|
session.spawnFailed = true;
|
|
200
|
-
session.output += `\nProcess error: ${err.
|
|
214
|
+
session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
|
|
201
215
|
session.done = true;
|
|
202
216
|
session.process = null;
|
|
203
217
|
session.resolve?.();
|
|
@@ -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 +
|