agent-sh 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +66 -96
  2. package/dist/agent/agent-loop.d.ts +85 -0
  3. package/dist/agent/agent-loop.js +611 -0
  4. package/dist/agent/conversation-state.d.ts +27 -0
  5. package/dist/agent/conversation-state.js +59 -0
  6. package/dist/agent/index.d.ts +11 -0
  7. package/dist/agent/index.js +9 -0
  8. package/dist/agent/skills.d.ts +25 -0
  9. package/dist/agent/skills.js +186 -0
  10. package/dist/agent/subagent.d.ts +37 -0
  11. package/dist/agent/subagent.js +117 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +98 -0
  14. package/dist/agent/tool-registry.d.ts +15 -0
  15. package/dist/agent/tool-registry.js +30 -0
  16. package/dist/agent/tools/bash.d.ts +7 -0
  17. package/dist/agent/tools/bash.js +62 -0
  18. package/dist/agent/tools/edit-file.d.ts +2 -0
  19. package/dist/agent/tools/edit-file.js +95 -0
  20. package/dist/agent/tools/glob.d.ts +2 -0
  21. package/dist/agent/tools/glob.js +55 -0
  22. package/dist/agent/tools/grep.d.ts +2 -0
  23. package/dist/agent/tools/grep.js +77 -0
  24. package/dist/agent/tools/list-skills.d.ts +2 -0
  25. package/dist/agent/tools/list-skills.js +28 -0
  26. package/dist/agent/tools/ls.d.ts +2 -0
  27. package/dist/agent/tools/ls.js +43 -0
  28. package/dist/agent/tools/read-file.d.ts +2 -0
  29. package/dist/agent/tools/read-file.js +55 -0
  30. package/dist/agent/tools/user-shell.d.ts +13 -0
  31. package/dist/agent/tools/user-shell.js +57 -0
  32. package/dist/agent/tools/write-file.d.ts +2 -0
  33. package/dist/agent/tools/write-file.js +74 -0
  34. package/dist/agent/types.d.ts +44 -0
  35. package/dist/agent/types.js +1 -0
  36. package/dist/core.d.ts +24 -14
  37. package/dist/core.js +260 -36
  38. package/dist/event-bus.d.ts +84 -14
  39. package/dist/event-bus.js +10 -1
  40. package/dist/extension-loader.js +12 -1
  41. package/dist/extensions/command-suggest.d.ts +10 -0
  42. package/dist/extensions/command-suggest.js +41 -0
  43. package/dist/extensions/slash-commands.d.ts +1 -1
  44. package/dist/extensions/slash-commands.js +161 -64
  45. package/dist/extensions/tui-renderer.js +111 -53
  46. package/dist/index.js +124 -120
  47. package/dist/input-handler.d.ts +17 -8
  48. package/dist/input-handler.js +152 -45
  49. package/dist/output-parser.d.ts +7 -0
  50. package/dist/output-parser.js +27 -0
  51. package/dist/settings.d.ts +53 -2
  52. package/dist/settings.js +45 -2
  53. package/dist/shell.js +36 -27
  54. package/dist/types.d.ts +46 -6
  55. package/dist/utils/box-frame.d.ts +3 -1
  56. package/dist/utils/box-frame.js +12 -5
  57. package/dist/utils/line-editor.js +4 -0
  58. package/dist/utils/llm-client.d.ts +45 -0
  59. package/dist/utils/llm-client.js +60 -0
  60. package/dist/utils/markdown.js +2 -2
  61. package/dist/utils/stream-transform.js +20 -47
  62. package/dist/utils/tool-display.js +15 -5
  63. package/examples/extensions/claude-code-bridge/README.md +35 -0
  64. package/examples/extensions/claude-code-bridge/index.ts +198 -0
  65. package/examples/extensions/claude-code-bridge/package.json +11 -0
  66. package/examples/extensions/openrouter.ts +87 -0
  67. package/examples/extensions/pi-bridge/README.md +35 -0
  68. package/examples/extensions/pi-bridge/index.ts +265 -0
  69. package/examples/extensions/pi-bridge/package.json +13 -0
  70. package/examples/extensions/subagents.ts +87 -0
  71. package/package.json +3 -5
  72. package/dist/acp-client.d.ts +0 -100
  73. package/dist/acp-client.js +0 -656
  74. package/dist/extensions/shell-exec.d.ts +0 -24
  75. package/dist/extensions/shell-exec.js +0 -188
  76. package/dist/mcp-server.d.ts +0 -13
  77. package/dist/mcp-server.js +0 -234
  78. package/examples/pi-agent-sh.ts +0 -166
package/dist/core.js CHANGED
@@ -1,86 +1,310 @@
1
1
  /**
2
2
  * Core kernel — the minimum viable agent-sh.
3
3
  *
4
- * Wires up EventBus + ContextManager + AcpClient without any frontend.
4
+ * Wires up EventBus + ContextManager + AgentBackend without any frontend.
5
5
  * Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
6
- * subscribing to bus events and calling client methods.
6
+ * subscribing to bus events.
7
7
  *
8
- * The core listens for `agent:submit` and `agent:cancel-request` events
9
- * from any frontend, routing them to the AcpClient. This means frontends
10
- * never need a direct reference to AcpClient — they just emit events.
8
+ * The default backend (AgentLoop) is created eagerly but wired lazily —
9
+ * extensions can register alternative backends via agent:register-backend
10
+ * before activateBackend() is called.
11
11
  *
12
12
  * Usage:
13
13
  * import { createCore } from "agent-sh";
14
- * const core = createCore({ agentCommand: "pi-acp" });
15
- * core.bus.on("agent:response-chunk", ({ text }) => ws.send(text));
16
- * await core.start();
17
- * core.bus.emit("agent:submit", { query: "hello" });
14
+ * const core = createCore({ apiKey: "...", model: "gpt-4o" });
15
+ * core.bus.on("agent:response-chunk", ({ blocks }) => { ... });
16
+ * core.activateBackend();
17
+ * const response = await core.query("hello");
18
18
  */
19
19
  import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
- import { AcpClient } from "./acp-client.js";
21
+ import { AgentLoop } from "./agent/agent-loop.js";
22
+ import { LlmClient } from "./utils/llm-client.js";
22
23
  import { setPalette } from "./utils/palette.js";
23
24
  import * as streamTransform from "./utils/stream-transform.js";
24
25
  import * as settingsMod from "./settings.js";
26
+ import { resolveProvider, getProviderNames } from "./settings.js";
25
27
  import { HandlerRegistry } from "./utils/handler-registry.js";
26
28
  // Re-export types that library consumers need
27
29
  export { EventBus } from "./event-bus.js";
28
30
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
31
+ export { runSubagent } from "./agent/subagent.js";
32
+ export { LlmClient } from "./utils/llm-client.js";
29
33
  export function createCore(config) {
30
34
  const bus = new EventBus();
31
35
  const handlers = new HandlerRegistry();
32
36
  const contextManager = new ContextManager(bus);
33
- const client = new AcpClient({ bus, contextManager, config });
34
- let connected = false;
35
- // Route frontend events to the agent — any frontend (Shell, WebSocket,
36
- // REST handler, test harness) can emit these without knowing about AcpClient.
37
- bus.on("agent:submit", ({ query }) => {
38
- (async () => {
39
- // Wait briefly for agent connection if start() is still in progress
40
- if (!connected) {
41
- for (let i = 0; i < 30 && !connected; i++) {
42
- await new Promise((r) => setTimeout(r, 100));
43
- }
37
+ // ── Resolve provider ─────────────────────────────────────────
38
+ const settings = settingsMod.getSettings();
39
+ let activeProvider = null;
40
+ const providerRegistry = new Map();
41
+ for (const name of getProviderNames()) {
42
+ const p = resolveProvider(name);
43
+ if (p)
44
+ providerRegistry.set(name, p);
45
+ }
46
+ const providerName = config.provider ?? settings.defaultProvider;
47
+ if (providerName) {
48
+ activeProvider = providerRegistry.get(providerName) ?? null;
49
+ }
50
+ // Build flat modes list across all providers
51
+ const buildModes = () => {
52
+ const allModes = [];
53
+ for (const [id, p] of providerRegistry) {
54
+ if (!p.apiKey)
55
+ continue;
56
+ for (const model of p.models) {
57
+ const mc = p.modelCapabilities?.get(model);
58
+ allModes.push({
59
+ model,
60
+ provider: id,
61
+ providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
62
+ contextWindow: mc?.contextWindow ?? p.contextWindow,
63
+ reasoning: mc?.reasoning,
64
+ supportsReasoningEffort: p.supportsReasoningEffort,
65
+ });
44
66
  }
45
- if (!connected) {
46
- bus.emit("ui:error", { message: "Agent not connected. Please wait a moment and try again." });
67
+ }
68
+ return allModes;
69
+ };
70
+ const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
71
+ const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
72
+ const effectiveModel = config.model ?? activeProvider?.defaultModel;
73
+ let modes = buildModes();
74
+ if (modes.length === 0 && effectiveApiKey && effectiveModel) {
75
+ modes = [{ model: effectiveModel }];
76
+ }
77
+ const initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
78
+ // Shared LLM client — used by agent loop AND fast-path features
79
+ let llmClient = null;
80
+ if (effectiveApiKey) {
81
+ if (!effectiveModel) {
82
+ throw new Error("No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json");
83
+ }
84
+ llmClient = new LlmClient({
85
+ apiKey: effectiveApiKey,
86
+ baseURL: effectiveBaseURL,
87
+ model: effectiveModel,
88
+ });
89
+ }
90
+ // Create AgentLoop (unwired — tools only, no bus subscriptions yet)
91
+ const agentLoop = llmClient
92
+ ? new AgentLoop(bus, contextManager, llmClient, handlers, modes, initialModeIndex)
93
+ : null;
94
+ const backends = new Map();
95
+ let activeBackendName = null;
96
+ const activateByName = async (name, silent = false) => {
97
+ const backend = name === "agent-sh" ? null : backends.get(name);
98
+ if (name !== "agent-sh" && !backend) {
99
+ bus.emit("ui:error", { message: `Unknown backend: ${name}` });
100
+ return;
101
+ }
102
+ // Deactivate current backend
103
+ if (activeBackendName === "agent-sh") {
104
+ agentLoop?.unwire();
105
+ }
106
+ else if (activeBackendName) {
107
+ backends.get(activeBackendName)?.kill();
108
+ }
109
+ // Activate new backend
110
+ if (name === "agent-sh") {
111
+ if (!agentLoop) {
112
+ bus.emit("ui:error", { message: "No LLM provider configured for built-in backend" });
47
113
  return;
48
114
  }
49
- await client.sendPrompt(query);
50
- })().catch((err) => {
51
- bus.emit("agent:error", {
52
- message: err instanceof Error ? err.message : String(err),
53
- });
115
+ agentLoop.wire();
116
+ activeBackendName = "agent-sh";
117
+ bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: llmClient?.model, provider: activeProvider?.id, contextWindow: activeProvider?.contextWindow });
118
+ }
119
+ else {
120
+ await backend.start?.();
121
+ activeBackendName = name;
122
+ }
123
+ if (!silent) {
124
+ bus.emit("ui:info", { message: `Backend: ${name}` });
125
+ }
126
+ bus.emit("config:changed", {});
127
+ };
128
+ bus.on("agent:register-backend", (backend) => {
129
+ backends.set(backend.name, backend);
130
+ });
131
+ bus.on("config:switch-backend", ({ name }) => {
132
+ activateByName(name);
133
+ });
134
+ bus.on("config:list-backends", () => {
135
+ const names = [];
136
+ if (agentLoop)
137
+ names.push("agent-sh");
138
+ for (const name of backends.keys())
139
+ names.push(name);
140
+ const list = names
141
+ .map((n) => n === activeBackendName ? `${n} (active)` : n)
142
+ .join(", ");
143
+ bus.emit("ui:info", { message: `Backends: ${list}` });
144
+ });
145
+ bus.onPipe("config:get-backends", (payload) => {
146
+ const names = [];
147
+ if (agentLoop)
148
+ names.push("agent-sh");
149
+ for (const name of backends.keys())
150
+ names.push(name);
151
+ return { names, active: activeBackendName };
152
+ });
153
+ // ── Runtime provider management ──────────────────────────────
154
+ bus.on("provider:register", (p) => {
155
+ const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
156
+ const modelIds = [];
157
+ const caps = new Map();
158
+ for (const m of rawModels) {
159
+ if (typeof m === "string") {
160
+ modelIds.push(m);
161
+ }
162
+ else {
163
+ modelIds.push(m.id);
164
+ caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow });
165
+ }
166
+ }
167
+ providerRegistry.set(p.id, {
168
+ id: p.id,
169
+ apiKey: p.apiKey,
170
+ baseURL: p.baseURL,
171
+ defaultModel: p.defaultModel,
172
+ models: modelIds,
173
+ supportsReasoningEffort: p.supportsReasoningEffort,
174
+ modelCapabilities: caps.size > 0 ? caps : undefined,
54
175
  });
55
176
  });
56
- bus.on("agent:cancel-request", () => {
57
- client.cancel().catch(() => { });
177
+ bus.on("config:switch-provider", ({ provider: name }) => {
178
+ const p = providerRegistry.get(name);
179
+ if (!p) {
180
+ bus.emit("ui:error", { message: `Unknown provider: ${name}` });
181
+ return;
182
+ }
183
+ if (!llmClient) {
184
+ bus.emit("ui:error", { message: `Provider switching requires internal agent mode` });
185
+ return;
186
+ }
187
+ const newApiKey = p.apiKey;
188
+ if (!newApiKey) {
189
+ bus.emit("ui:error", { message: `Provider "${name}" has no API key configured` });
190
+ return;
191
+ }
192
+ const switchModel = p.defaultModel ?? p.models[0];
193
+ if (!switchModel) {
194
+ bus.emit("ui:error", { message: `Provider "${name}" has no models configured` });
195
+ return;
196
+ }
197
+ llmClient.reconfigure({
198
+ apiKey: newApiKey,
199
+ baseURL: p.baseURL,
200
+ model: switchModel,
201
+ });
202
+ const newModes = p.models.map((m) => {
203
+ const mc = p.modelCapabilities?.get(m);
204
+ return {
205
+ model: m,
206
+ provider: name,
207
+ providerConfig: { apiKey: newApiKey, baseURL: p.baseURL },
208
+ contextWindow: mc?.contextWindow ?? p.contextWindow,
209
+ reasoning: mc?.reasoning,
210
+ supportsReasoningEffort: p.supportsReasoningEffort,
211
+ };
212
+ });
213
+ bus.emit("config:set-modes", { modes: newModes });
214
+ activeProvider = p;
215
+ bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
216
+ bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
217
+ bus.emit("config:changed", {});
58
218
  });
59
219
  return {
60
220
  bus,
61
221
  contextManager,
62
- client,
63
- async start() {
64
- await client.start();
65
- connected = true;
222
+ llmClient,
223
+ activateBackend() {
224
+ const preferred = settings.defaultBackend;
225
+ if (preferred && backends.has(preferred)) {
226
+ activateByName(preferred);
227
+ }
228
+ else if (backends.size > 0 && !agentLoop) {
229
+ activateByName(backends.keys().next().value);
230
+ }
231
+ else if (agentLoop) {
232
+ agentLoop.wire();
233
+ activeBackendName = "agent-sh";
234
+ bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: llmClient?.model, provider: activeProvider?.id, contextWindow: activeProvider?.contextWindow });
235
+ }
236
+ else if (backends.size > 0) {
237
+ activateByName(backends.keys().next().value);
238
+ }
239
+ if (activeBackendName) {
240
+ bus.emit("ui:info", { message: `Backend: ${activeBackendName}` });
241
+ }
242
+ },
243
+ async query(text, opts) {
244
+ return new Promise((resolve, reject) => {
245
+ let response = "";
246
+ let settled = false;
247
+ const onChunk = (e) => {
248
+ for (const b of e.blocks)
249
+ if (b.type === "text")
250
+ response += b.text;
251
+ };
252
+ const onDone = () => {
253
+ if (settled)
254
+ return;
255
+ settled = true;
256
+ cleanup();
257
+ resolve(response);
258
+ };
259
+ const onError = (e) => {
260
+ if (settled)
261
+ return;
262
+ settled = true;
263
+ cleanup();
264
+ reject(new Error(e.message));
265
+ };
266
+ const cleanup = () => {
267
+ bus.off("agent:response-chunk", onChunk);
268
+ bus.off("agent:processing-done", onDone);
269
+ bus.off("agent:error", onError);
270
+ };
271
+ bus.on("agent:response-chunk", onChunk);
272
+ bus.on("agent:processing-done", onDone);
273
+ bus.on("agent:error", onError);
274
+ bus.emit("agent:submit", {
275
+ query: text,
276
+ modeInstruction: opts?.mode,
277
+ });
278
+ });
279
+ },
280
+ cancel() {
281
+ bus.emit("agent:cancel-request", {});
66
282
  },
67
283
  extensionContext(opts) {
68
284
  return {
69
285
  bus,
70
286
  contextManager,
71
- getAcpClient: () => client,
287
+ llmClient,
72
288
  quit: opts.quit,
73
289
  setPalette,
74
290
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
75
291
  createFencedBlockTransform: (o) => streamTransform.createFencedBlockTransform(bus, o),
76
292
  getExtensionSettings: settingsMod.getExtensionSettings,
293
+ registerCommand: (name, description, handler) => bus.emit("command:register", { name, description, handler }),
294
+ registerTool: (tool) => agentLoop?.registerTool(tool),
295
+ getTools: () => agentLoop?.getTools() ?? [],
77
296
  define: (name, fn) => handlers.define(name, fn),
78
297
  advise: (name, wrapper) => handlers.advise(name, wrapper),
79
298
  call: (name, ...args) => handlers.call(name, ...args),
80
299
  };
81
300
  },
82
301
  kill() {
83
- client.kill();
302
+ if (activeBackendName === "agent-sh") {
303
+ agentLoop?.kill();
304
+ }
305
+ else if (activeBackendName) {
306
+ backends.get(activeBackendName)?.kill();
307
+ }
84
308
  },
85
309
  };
86
310
  }
@@ -1,3 +1,4 @@
1
+ import type { AgentMode } from "./types.js";
1
2
  /**
2
3
  * Typed event map — every event has a known payload shape.
3
4
  */
@@ -22,21 +23,31 @@ export interface ShellEvents {
22
23
  "shell:agent-exec-done": Record<string, never>;
23
24
  "agent:submit": {
24
25
  query: string;
26
+ modeInstruction?: string;
27
+ modeLabel?: string;
25
28
  };
26
- "agent:cancel-request": Record<string, never>;
29
+ "agent:cancel-request": {
30
+ silent?: boolean;
31
+ };
32
+ "input-mode:register": import("./types.js").InputModeConfig;
27
33
  "agent:query": {
28
34
  query: string;
35
+ modeLabel?: string;
29
36
  };
30
37
  "agent:thinking-chunk": {
31
38
  text: string;
32
39
  };
33
40
  "agent:response-chunk": {
34
- text: string;
35
- blocks?: ContentBlock[];
41
+ blocks: ContentBlock[];
36
42
  };
37
43
  "agent:response-done": {
38
44
  response: string;
39
45
  };
46
+ "agent:usage": {
47
+ prompt_tokens: number;
48
+ completion_tokens: number;
49
+ total_tokens: number;
50
+ };
40
51
  "agent:processing-start": Record<string, never>;
41
52
  "agent:processing-done": Record<string, never>;
42
53
  "agent:cancelled": Record<string, never>;
@@ -66,6 +77,7 @@ export interface ShellEvents {
66
77
  toolCallId?: string;
67
78
  exitCode: number | null;
68
79
  rawOutput?: unknown;
80
+ kind?: string;
69
81
  };
70
82
  "agent:tool-output-chunk": {
71
83
  chunk: string;
@@ -76,6 +88,11 @@ export interface ShellEvents {
76
88
  metadata: Record<string, unknown>;
77
89
  decision: Record<string, unknown>;
78
90
  };
91
+ "command:register": {
92
+ name: string;
93
+ description: string;
94
+ handler: (args: string) => Promise<void> | void;
95
+ };
79
96
  "command:execute": {
80
97
  name: string;
81
98
  args: string;
@@ -86,6 +103,9 @@ export interface ShellEvents {
86
103
  "ui:error": {
87
104
  message: string;
88
105
  };
106
+ "ui:suggestion": {
107
+ text: string;
108
+ };
89
109
  "input:keypress": {
90
110
  key: string;
91
111
  };
@@ -105,22 +125,72 @@ export interface ShellEvents {
105
125
  cwd: string;
106
126
  done: boolean;
107
127
  };
108
- "session:configure": {
109
- cwd: string;
110
- mcpServers: {
111
- name: string;
112
- command: string;
113
- args: string[];
114
- env: {
115
- name: string;
116
- value: string;
117
- }[];
118
- }[];
128
+ "agent:info": {
129
+ name: string;
130
+ version: string;
131
+ model?: string;
132
+ provider?: string;
133
+ contextWindow?: number;
134
+ };
135
+ "agent:reset-session": Record<string, never>;
136
+ "agent:register-backend": {
137
+ name: string;
138
+ kill: () => void;
139
+ start?: () => Promise<void>;
140
+ };
141
+ "config:switch-backend": {
142
+ name: string;
143
+ };
144
+ "config:list-backends": Record<string, never>;
145
+ "config:get-backends": {
146
+ names: string[];
147
+ active: string | null;
119
148
  };
120
149
  "config:changed": Record<string, never>;
121
150
  "config:cycle": Record<string, never>;
151
+ "config:switch-model": {
152
+ model: string;
153
+ };
154
+ "config:get-models": {
155
+ models: {
156
+ model: string;
157
+ provider: string;
158
+ }[];
159
+ active: string | null;
160
+ };
161
+ "config:set-thinking": {
162
+ level: string;
163
+ };
164
+ "config:get-thinking": {
165
+ level: string;
166
+ levels: string[];
167
+ supported: boolean;
168
+ };
169
+ "config:switch-provider": {
170
+ provider: string;
171
+ };
172
+ "config:set-modes": {
173
+ modes: AgentMode[];
174
+ };
175
+ "provider:register": {
176
+ id: string;
177
+ apiKey?: string;
178
+ baseURL?: string;
179
+ defaultModel: string;
180
+ models?: (string | {
181
+ id: string;
182
+ reasoning?: boolean;
183
+ contextWindow?: number;
184
+ })[];
185
+ /** Provider supports the reasoning_effort parameter. Default: true. */
186
+ supportsReasoningEffort?: boolean;
187
+ };
122
188
  "autocomplete:request": {
123
189
  buffer: string;
190
+ /** Parsed slash command name (e.g. "/backend"), or null if not a command. */
191
+ command: string | null;
192
+ /** Text after the command name (e.g. "clau" for "/backend clau"), or null. */
193
+ commandArgs: string | null;
124
194
  items: {
125
195
  name: string;
126
196
  description: string;
package/dist/event-bus.js CHANGED
@@ -28,7 +28,16 @@ export class EventBus {
28
28
  * modify data (e.g. render LaTeX → terminal image) before renderers see it.
29
29
  */
30
30
  emitTransform(event, payload) {
31
- const transformed = this.emitPipe(event, payload);
31
+ let transformed;
32
+ try {
33
+ transformed = this.emitPipe(event, payload);
34
+ }
35
+ catch (err) {
36
+ if (process.env.DEBUG) {
37
+ process.stderr.write(`[event-bus] pipe error on ${String(event)}: ${err}\n`);
38
+ }
39
+ transformed = payload; // fall back to untransformed
40
+ }
32
41
  this.emitter.emit(event, transformed);
33
42
  }
34
43
  /** Register a transform listener for a pipeline event. */
@@ -47,7 +47,10 @@ export async function loadExtensions(ctx, cliExtensions) {
47
47
  const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
48
48
  for (const entry of entries) {
49
49
  const fullPath = path.join(EXT_DIR, entry.name);
50
- if (entry.isDirectory()) {
50
+ // Resolve symlinks to check if they point to directories
51
+ const isDir = entry.isDirectory() ||
52
+ (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
53
+ if (isDir) {
51
54
  // Directory extension: look for index.{ts,js,mjs,...}
52
55
  const indexFile = await findIndex(fullPath);
53
56
  if (indexFile) {
@@ -71,6 +74,7 @@ export async function loadExtensions(ctx, cliExtensions) {
71
74
  return true;
72
75
  });
73
76
  // Load each extension
77
+ const loaded = [];
74
78
  for (const specifier of unique) {
75
79
  try {
76
80
  const importPath = await resolveSpecifier(specifier);
@@ -86,6 +90,10 @@ export async function loadExtensions(ctx, cliExtensions) {
86
90
  : mod.activate;
87
91
  if (typeof activate === "function") {
88
92
  activate(ctx);
93
+ // Extract a short name from the specifier
94
+ const base = path.basename(specifier).replace(/\.(ts|js|mjs|mts|tsx)$/, "");
95
+ const name = base === "index" ? path.basename(path.dirname(specifier)) : base;
96
+ loaded.push(name);
89
97
  }
90
98
  }
91
99
  catch (err) {
@@ -94,6 +102,9 @@ export async function loadExtensions(ctx, cliExtensions) {
94
102
  });
95
103
  }
96
104
  }
105
+ if (loaded.length > 0) {
106
+ ctx.bus.emit("ui:info", { message: `Extensions: ${loaded.join(", ")}` });
107
+ }
97
108
  }
98
109
  /**
99
110
  * Find an index file in a directory extension.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Command suggestion extension (fast-path LLM feature).
3
+ *
4
+ * After a shell command fails (non-zero exit), uses llmClient.complete()
5
+ * to suggest a fix. Shows the suggestion below the prompt.
6
+ *
7
+ * Only active when llmClient is available (internal agent mode).
8
+ */
9
+ import type { ExtensionContext } from "../types.js";
10
+ export default function activate({ bus, llmClient }: ExtensionContext): void;
@@ -0,0 +1,41 @@
1
+ export default function activate({ bus, llmClient }) {
2
+ if (!llmClient)
3
+ return;
4
+ let suggesting = false;
5
+ bus.on("shell:command-done", ({ command, output, exitCode, cwd }) => {
6
+ if (exitCode === null || exitCode === 0)
7
+ return;
8
+ if (!command.trim())
9
+ return;
10
+ if (suggesting)
11
+ return; // don't stack suggestions
12
+ suggesting = true;
13
+ // Truncate output to avoid blowing up the prompt
14
+ const truncated = output.length > 1000
15
+ ? output.slice(-1000)
16
+ : output;
17
+ llmClient.complete({
18
+ messages: [
19
+ {
20
+ role: "system",
21
+ content: "You are a shell assistant. The user's command failed. " +
22
+ "Suggest a fix as a single command. Just the command, no explanation, no backticks, no prefix. " +
23
+ "If you can't suggest anything useful, reply with an empty string.",
24
+ },
25
+ {
26
+ role: "user",
27
+ content: `cwd: ${cwd}\n$ ${command}\n${truncated}\nexit code: ${exitCode}`,
28
+ },
29
+ ],
30
+ max_tokens: 150,
31
+ }).then((suggestion) => {
32
+ suggesting = false;
33
+ const trimmed = suggestion.trim().replace(/^`+|`+$/g, ""); // strip backticks
34
+ if (trimmed && trimmed.length < 500) {
35
+ bus.emit("ui:suggestion", { text: trimmed });
36
+ }
37
+ }).catch(() => {
38
+ suggesting = false;
39
+ });
40
+ });
41
+ }
@@ -1,2 +1,2 @@
1
1
  import type { ExtensionContext } from "../types.js";
2
- export default function activate({ bus, getAcpClient, quit }: ExtensionContext): void;
2
+ export default function activate({ bus, contextManager }: ExtensionContext): void;