agent-sh 0.12.0 → 0.12.1

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.
@@ -55,6 +55,13 @@ export declare class ConversationState {
55
55
  addToolResultInline(content: string): void;
56
56
  addSystemNote(text: string): void;
57
57
  getMessages(): ChatCompletionMessageParam[];
58
+ /**
59
+ * If a stream was interrupted mid-tool-execution, an assistant message
60
+ * with tool_calls can land in history without matching tool results.
61
+ * Strict providers (DeepSeek) 400 on this. Stub each missing result
62
+ * with a [cancelled] marker so the protocol stays valid.
63
+ */
64
+ private stubDanglingToolCalls;
58
65
  /**
59
66
  * DeepSeek 400s if any assistant in a thinking-mode conversation is
60
67
  * missing reasoning_content. Cross-alias here (OpenRouter streams as
@@ -111,7 +111,37 @@ export class ConversationState {
111
111
  this.invalidateMessagesCache();
112
112
  }
113
113
  getMessages() {
114
- return this.normalizeReasoningConsistency(this.messages);
114
+ return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.messages));
115
+ }
116
+ /**
117
+ * If a stream was interrupted mid-tool-execution, an assistant message
118
+ * with tool_calls can land in history without matching tool results.
119
+ * Strict providers (DeepSeek) 400 on this. Stub each missing result
120
+ * with a [cancelled] marker so the protocol stays valid.
121
+ */
122
+ stubDanglingToolCalls(messages) {
123
+ const result = [];
124
+ let i = 0;
125
+ while (i < messages.length) {
126
+ const msg = messages[i];
127
+ result.push(msg);
128
+ i++;
129
+ if (msg.role !== "assistant" || !("tool_calls" in msg) || !msg.tool_calls)
130
+ continue;
131
+ const seen = new Set();
132
+ while (i < messages.length && messages[i].role === "tool") {
133
+ const t = messages[i];
134
+ seen.add(t.tool_call_id);
135
+ result.push(t);
136
+ i++;
137
+ }
138
+ for (const tc of msg.tool_calls) {
139
+ if (!seen.has(tc.id)) {
140
+ result.push({ role: "tool", tool_call_id: tc.id, content: "[cancelled]" });
141
+ }
142
+ }
143
+ }
144
+ return result;
115
145
  }
116
146
  /**
117
147
  * DeepSeek 400s if any assistant in a thinking-mode conversation is
package/dist/event-bus.js CHANGED
@@ -6,7 +6,7 @@ import { EventEmitter } from "node:events";
6
6
  * can modify the payload before passing to the next
7
7
  */
8
8
  export class EventBus {
9
- emitter = new EventEmitter();
9
+ emitter = new EventEmitter().setMaxListeners(0);
10
10
  pipeListeners = new Map();
11
11
  asyncPipeListeners = new Map();
12
12
  /** Subscribe to a fire-and-forget event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -89,7 +89,13 @@
89
89
  },
90
90
  "files": [
91
91
  "dist",
92
- "examples"
92
+ "examples/extensions/*.ts",
93
+ "examples/extensions/*/package.json",
94
+ "examples/extensions/*/tsconfig.json",
95
+ "examples/extensions/*/README.md",
96
+ "examples/extensions/*/src",
97
+ "examples/extensions/*/index.ts",
98
+ "examples/extensions/*/index.js"
93
99
  ],
94
100
  "scripts": {
95
101
  "dev": "tsx src/index.ts",
@@ -1,574 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * agent-sh-acp — ACP (Agent Client Protocol) server wrapping agent-sh's
4
- * headless core. Speaks JSON-RPC 2.0 over stdin/stdout so agent-shell
5
- * (Emacs) can drive it as a backend.
6
- *
7
- * Usage:
8
- * agent-sh-acp # uses settings from ~/.agent-sh/settings.json
9
- * agent-sh-acp --model gpt-4o # override model
10
- *
11
- * In agent-shell (Emacs):
12
- * (setq agent-shell-agentsh-acp-command '("agent-sh-acp"))
13
- */
14
- import { createCore, type AgentShellCore } from "agent-sh";
15
- import { loadExtensions } from "agent-sh/extension-loader";
16
- import { loadBuiltinExtensions } from "agent-sh/extensions";
17
- import { getSettings } from "agent-sh/settings";
18
- import type { ContentBlock } from "agent-sh/types";
19
-
20
- // ── JSON-RPC types ──────────────────────────────────────────────────
21
-
22
- interface JsonRpcRequest {
23
- jsonrpc: "2.0";
24
- method: string;
25
- params?: Record<string, unknown>;
26
- id?: number | string;
27
- }
28
-
29
- interface JsonRpcResponse {
30
- jsonrpc: "2.0";
31
- id: number | string;
32
- result?: unknown;
33
- error?: { code: number; message: string; data?: unknown };
34
- }
35
-
36
- interface JsonRpcNotification {
37
- jsonrpc: "2.0";
38
- method: string;
39
- params?: Record<string, unknown>;
40
- }
41
-
42
- // ── ACP content block ───────────────────────────────────────────────
43
-
44
- interface AcpContentBlock {
45
- type: string;
46
- text?: string;
47
- data?: string;
48
- mimeType?: string;
49
- }
50
-
51
- // ── Stdio transport ─────────────────────────────────────────────────
52
-
53
- function send(msg: JsonRpcResponse | JsonRpcNotification): void {
54
- const line = JSON.stringify(msg) + "\n";
55
- process.stdout.write(line);
56
- }
57
-
58
- function sendResult(id: number | string, result: unknown): void {
59
- send({ jsonrpc: "2.0", id, result });
60
- }
61
-
62
- function sendError(id: number | string, code: number, message: string, data?: unknown): void {
63
- send({ jsonrpc: "2.0", id, error: { code, message, data } });
64
- }
65
-
66
- function sendNotification(method: string, params: Record<string, unknown>): void {
67
- send({ jsonrpc: "2.0", method, params });
68
- }
69
-
70
- // ── ACP session/update helpers ──────────────────────────────────────
71
-
72
- function sendSessionUpdate(update: Record<string, unknown>): void {
73
- sendNotification("session/update", { update });
74
- }
75
-
76
- function sendTextChunk(text: string): void {
77
- sendSessionUpdate({
78
- sessionUpdate: "agent_message_chunk",
79
- content: { type: "text", text },
80
- });
81
- }
82
-
83
- function sendThinkingChunk(text: string): void {
84
- sendSessionUpdate({
85
- sessionUpdate: "agent_thought_chunk",
86
- content: { type: "text", text },
87
- });
88
- }
89
-
90
- function sendToolCall(
91
- toolCallId: string,
92
- title: string,
93
- kind: string,
94
- rawInput?: unknown,
95
- ): void {
96
- sendSessionUpdate({
97
- sessionUpdate: "tool_call",
98
- toolCallId,
99
- title,
100
- status: "pending",
101
- kind,
102
- content: [],
103
- rawInput,
104
- });
105
- }
106
-
107
- function sendToolCallUpdate(
108
- toolCallId: string,
109
- status: string,
110
- content: AcpContentBlock[],
111
- kind?: string,
112
- ): void {
113
- sendSessionUpdate({
114
- sessionUpdate: "tool_call_update",
115
- toolCallId,
116
- status,
117
- content,
118
- kind,
119
- });
120
- }
121
-
122
- function sendUsageUpdate(
123
- inputTokens: number,
124
- outputTokens: number,
125
- ): void {
126
- sendSessionUpdate({
127
- sessionUpdate: "usage_update",
128
- inputTokens,
129
- outputTokens,
130
- cacheCreationInputTokens: 0,
131
- cacheReadInputTokens: 0,
132
- });
133
- }
134
-
135
- // ── Permission bridge ───────────────────────────────────────────────
136
-
137
- let nextPermissionId = 1;
138
- const pendingPermissions = new Map<
139
- number,
140
- { resolve: (outcome: string) => void }
141
- >();
142
-
143
- function buildPermissionToolCall(
144
- title: string,
145
- kind: string,
146
- metadata: Record<string, unknown>,
147
- toolCallId: string,
148
- ): { toolCall: Record<string, unknown> } {
149
- const args = (metadata.args ?? {}) as Record<string, unknown>;
150
-
151
- // Map agent-sh permission kinds → ACP tool call shapes
152
- if (kind === "file-write") {
153
- // File edit/write — send diff content block + rawInput for agent-shell
154
- const content: unknown[] = [];
155
- const rawInput: Record<string, unknown> = {};
156
-
157
- // Set path for title display
158
- const filePath = (args.path as string) ?? "";
159
- rawInput.path = filePath;
160
- rawInput.file_path = filePath;
161
-
162
- // For edit_file: old_str/new_str so agent-shell can render a diff
163
- if (typeof args.old_text === "string") {
164
- rawInput.old_str = args.old_text;
165
- rawInput.new_str = args.new_text ?? "";
166
- content.push({
167
- type: "diff",
168
- oldText: args.old_text,
169
- newText: args.new_text ?? "",
170
- path: filePath,
171
- });
172
- } else if (typeof args.content === "string") {
173
- // write_file (new file or full overwrite)
174
- rawInput.new_str = args.content;
175
- rawInput.old_str = "";
176
- content.push({
177
- type: "diff",
178
- oldText: "",
179
- newText: args.content,
180
- path: filePath,
181
- });
182
- }
183
-
184
- if (typeof args.description === "string") {
185
- rawInput.description = args.description;
186
- }
187
-
188
- return {
189
- toolCall: {
190
- toolCallId,
191
- title,
192
- status: "pending",
193
- kind: "diff",
194
- content,
195
- rawInput,
196
- },
197
- };
198
- }
199
-
200
- // Generic tool call (bash, etc.)
201
- const rawInput: Record<string, unknown> = {};
202
- if (typeof args.command === "string") {
203
- rawInput.command = args.command;
204
- }
205
- if (typeof args.description === "string") {
206
- rawInput.description = args.description;
207
- }
208
-
209
- return {
210
- toolCall: {
211
- toolCallId,
212
- title,
213
- status: "pending",
214
- kind: kind === "tool-call" ? "execute" : kind,
215
- content: [],
216
- rawInput,
217
- },
218
- };
219
- }
220
-
221
- function requestPermission(
222
- title: string,
223
- kind: string,
224
- metadata: Record<string, unknown>,
225
- toolCallId?: string,
226
- ): Promise<string> {
227
- const id = nextPermissionId++;
228
- const tcId = toolCallId ?? `perm-${id}`;
229
- return new Promise((resolve) => {
230
- pendingPermissions.set(id, { resolve });
231
- const { toolCall } = buildPermissionToolCall(title, kind, metadata, tcId);
232
- send({
233
- jsonrpc: "2.0",
234
- method: "session/request_permission",
235
- id,
236
- params: {
237
- toolCall,
238
- options: [
239
- { id: "accepted", name: "Accept", description: "Accept this action" },
240
- { id: "rejected", name: "Reject", description: "Reject this action" },
241
- { id: "always", name: "Always allow", description: "Always allow for this session" },
242
- ],
243
- },
244
- } as any);
245
- });
246
- }
247
-
248
- // ── Core setup ──────────────────────────────────────────────────────
249
-
250
- function parseArgs(): { model?: string; provider?: string } {
251
- const args = process.argv.slice(2);
252
- const result: Record<string, string> = {};
253
- for (let i = 0; i < args.length; i++) {
254
- if (args[i] === "--model" && args[i + 1]) result.model = args[++i];
255
- if (args[i] === "--provider" && args[i + 1]) result.provider = args[++i];
256
- }
257
- return result;
258
- }
259
-
260
- const cliArgs = parseArgs();
261
- let core: AgentShellCore | null = null;
262
- let sessionId: string | null = null;
263
- let sessionCwd: string = process.cwd();
264
-
265
- // Track tool output chunks per toolCallId so we can send accumulated content
266
- const toolOutputBuffers = new Map<string, string>();
267
-
268
- // Track the active prompt request id so we can respond when processing is done
269
- let activePromptRequestId: number | string | null = null;
270
-
271
- // Track always-allowed permission kinds
272
- const alwaysAllowed = new Set<string>();
273
-
274
- // Track in-flight async operations so stdin end can wait
275
- let pendingOp: Promise<void> = Promise.resolve();
276
-
277
- // ── Wire agent-sh events → ACP notifications ───────────────────────
278
-
279
- function wireEvents(core: AgentShellCore): void {
280
- const { bus } = core;
281
-
282
- bus.on("agent:response-chunk", ({ blocks }) => {
283
- for (const block of blocks) {
284
- if (block.type === "text") {
285
- sendTextChunk(block.text);
286
- }
287
- // code-block blocks are sent as text (agent-shell renders markdown)
288
- if (block.type === "code-block") {
289
- sendTextChunk("```" + block.language + "\n" + block.code + "\n```");
290
- }
291
- }
292
- });
293
-
294
- bus.on("agent:thinking-chunk", ({ text }) => {
295
- sendThinkingChunk(text);
296
- });
297
-
298
- bus.on("agent:tool-started", (e) => {
299
- const id = e.toolCallId ?? `tool-${Date.now()}`;
300
- toolOutputBuffers.set(id, "");
301
- sendToolCall(id, e.title, e.kind ?? "tool", e.rawInput);
302
- });
303
-
304
- bus.on("agent:tool-output-chunk", ({ chunk }) => {
305
- // Accumulate — we don't know toolCallId here, but only one tool runs at a time
306
- // in sequential mode. For parallel tools this is best-effort.
307
- for (const [id, buf] of toolOutputBuffers) {
308
- toolOutputBuffers.set(id, buf + chunk);
309
- }
310
- });
311
-
312
- bus.on("agent:tool-completed", (e) => {
313
- const id = e.toolCallId ?? [...toolOutputBuffers.keys()].pop() ?? "unknown";
314
- const output = toolOutputBuffers.get(id) ?? "";
315
- toolOutputBuffers.delete(id);
316
-
317
- const status = e.exitCode === 0 || e.exitCode === null ? "completed" : "failed";
318
- const content: AcpContentBlock[] = output
319
- ? [{ type: "text", text: output }]
320
- : [];
321
- sendToolCallUpdate(id, status, content, e.kind);
322
- });
323
-
324
- bus.on("agent:usage", ({ prompt_tokens, completion_tokens }) => {
325
- sendUsageUpdate(prompt_tokens, completion_tokens);
326
- });
327
-
328
- bus.on("agent:processing-done", () => {
329
- if (activePromptRequestId !== null) {
330
- sendResult(activePromptRequestId, { stopReason: "end_turn" });
331
- activePromptRequestId = null;
332
- }
333
- });
334
-
335
- bus.on("agent:error", ({ message }) => {
336
- if (activePromptRequestId !== null) {
337
- sendError(activePromptRequestId, -32603, message);
338
- activePromptRequestId = null;
339
- }
340
- });
341
-
342
- bus.on("agent:cancelled", () => {
343
- if (activePromptRequestId !== null) {
344
- sendResult(activePromptRequestId, { stopReason: "cancelled" });
345
- activePromptRequestId = null;
346
- }
347
- });
348
-
349
- // Permission gating — auto-approve all tool calls.
350
- // agent-sh's built-in tools handle their own safety; the ACP layer
351
- // doesn't add a second permission gate. If you want to bridge
352
- // permissions to agent-shell's UI, replace this with the
353
- // requestPermission() flow.
354
- bus.onPipeAsync("permission:request", async (payload) => {
355
- payload.decision = { outcome: "approved" };
356
- return payload;
357
- });
358
- }
359
-
360
- // ── ACP method handlers ─────────────────────────────────────────────
361
-
362
- function getModelsPayload(): Record<string, unknown> | undefined {
363
- if (!core) return undefined;
364
- const info = core.bus.emitPipe("config:get-models", { models: [], active: null });
365
- if (!info.models.length) return undefined;
366
- return {
367
- currentModelId: info.active ?? info.models[0]?.model,
368
- availableModels: info.models.map((m) => ({
369
- modelId: m.model,
370
- name: m.provider ? `${m.provider}/${m.model}` : m.model,
371
- description: m.provider ? `Provider: ${m.provider}` : "",
372
- })),
373
- };
374
- }
375
-
376
- function handleInitialize(id: number | string): void {
377
- sendResult(id, {
378
- agentCapabilities: {
379
- promptCapabilities: {
380
- image: false,
381
- embeddedContext: true,
382
- },
383
- sessionCapabilities: {},
384
- },
385
- modes: {
386
- currentModeId: "default",
387
- availableModes: [
388
- { id: "default", name: "Default", description: "Standard mode" },
389
- ],
390
- },
391
- });
392
- }
393
-
394
- async function handleSessionNew(id: number | string, params: Record<string, unknown>): Promise<void> {
395
- sessionCwd = (params.cwd as string) ?? process.cwd();
396
- process.chdir(sessionCwd);
397
-
398
- // Create core lazily on first session
399
- if (!core) {
400
- core = createCore({
401
- model: cliArgs.model,
402
- provider: cliArgs.provider,
403
- });
404
- wireEvents(core);
405
-
406
- const extCtx = core.extensionContext({ quit: () => process.exit(0) });
407
- const settings = getSettings();
408
-
409
- // Load built-in extensions first (agent-backend, slash-commands, etc.)
410
- // Skip TUI-only extensions that don't apply in headless mode
411
- const headlessDisabled = [
412
- "tui-renderer",
413
- "file-autocomplete",
414
- "overlay-agent",
415
- ...(settings.disabledBuiltins ?? []),
416
- ];
417
- await loadBuiltinExtensions(extCtx, headlessDisabled);
418
-
419
- // Load user extensions with a timeout (some may hang in headless mode)
420
- const TIMEOUT_MS = 10000;
421
- await Promise.race([
422
- loadExtensions(extCtx),
423
- new Promise<void>((_, reject) =>
424
- setTimeout(() => reject(new Error(`Extension loading timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS),
425
- ),
426
- ]).catch((err) => {
427
- process.stderr.write(`Warning: ${err instanceof Error ? err.message : err}\n`);
428
- });
429
-
430
- // Signal deferred-init listeners (agent-backend) that the provider
431
- // registry is complete — they resolve their LLM config on this event.
432
- core.bus.emit("core:extensions-loaded", {});
433
-
434
- core.activateBackend();
435
- }
436
-
437
- sessionId = `session-${Date.now()}`;
438
- const result: Record<string, unknown> = {
439
- sessionId,
440
- modes: {
441
- currentModeId: "default",
442
- availableModes: [
443
- { id: "default", name: "Default", description: "Standard mode" },
444
- ],
445
- },
446
- };
447
- const models = getModelsPayload();
448
- if (models) result.models = models;
449
- sendResult(id, result);
450
- }
451
-
452
- function handleSessionPrompt(id: number | string, params: Record<string, unknown>): void {
453
- if (!core) {
454
- sendError(id, -32603, "No active session");
455
- return;
456
- }
457
-
458
- // Extract text from prompt content blocks
459
- const prompt = params.prompt as Array<{ type: string; text?: string; resource?: { text?: string } }>;
460
- const parts: string[] = [];
461
- for (const block of prompt) {
462
- if (block.type === "text" && block.text) {
463
- parts.push(block.text);
464
- } else if (block.type === "resource" && block.resource?.text) {
465
- parts.push(block.resource.text);
466
- }
467
- }
468
-
469
- const query = parts.join("\n");
470
- if (!query) {
471
- sendResult(id, { stopReason: "end_turn" });
472
- return;
473
- }
474
-
475
- // Store the request id — we'll respond when agent:processing-done fires
476
- activePromptRequestId = id;
477
- core.bus.emit("agent:submit", { query });
478
- }
479
-
480
- function handleSessionSetMode(id: number | string, _params: Record<string, unknown>): void {
481
- // Acknowledge — agent-sh doesn't have distinct modes yet
482
- sendResult(id, {});
483
- }
484
-
485
- // ── Message dispatcher ──────────────────────────────────────────────
486
-
487
- function dispatch(msg: JsonRpcRequest): void {
488
- const { method, params, id } = msg;
489
-
490
- // Handle responses to our outgoing requests (permission responses)
491
- if (!method && id !== undefined && (msg as any).result !== undefined) {
492
- const pending = pendingPermissions.get(id as number);
493
- if (pending) {
494
- pendingPermissions.delete(id as number);
495
- const result = (msg as any).result;
496
- const outcome = result?.outcome?.optionId ?? result?.outcome?.outcome ?? "rejected";
497
- pending.resolve(outcome);
498
- }
499
- return;
500
- }
501
-
502
- if (!id && !method) return; // ignore malformed
503
-
504
- switch (method) {
505
- case "initialize":
506
- handleInitialize(id!);
507
- break;
508
- case "session/new":
509
- pendingOp = handleSessionNew(id!, params ?? {}).catch((err) => {
510
- sendError(id!, -32603, err instanceof Error ? err.message : String(err));
511
- });
512
- break;
513
- case "session/prompt":
514
- handleSessionPrompt(id!, params ?? {});
515
- break;
516
- case "session/set_mode":
517
- handleSessionSetMode(id!, params ?? {});
518
- break;
519
- case "session/set_model":
520
- if (core && params?.modelId) {
521
- core.bus.emit("config:switch-model", { model: params.modelId as string });
522
- }
523
- sendResult(id!, {
524
- models: getModelsPayload() ?? {},
525
- });
526
- break;
527
- case "session/cancel":
528
- if (core) {
529
- core.bus.emit("agent:cancel-request", {});
530
- }
531
- // Notification — no response needed
532
- break;
533
- default:
534
- if (id !== undefined) {
535
- sendError(id, -32601, `Method not found: ${method}`);
536
- }
537
- }
538
- }
539
-
540
- // ── Stdin line reader ───────────────────────────────────────────────
541
-
542
- let buffer = "";
543
-
544
- process.stdin.setEncoding("utf-8");
545
- process.stdin.on("data", (chunk: string) => {
546
- buffer += chunk;
547
- let newlineIdx: number;
548
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
549
- const line = buffer.slice(0, newlineIdx).trim();
550
- buffer = buffer.slice(newlineIdx + 1);
551
- if (!line) continue;
552
- try {
553
- const msg = JSON.parse(line) as JsonRpcRequest;
554
- dispatch(msg);
555
- } catch {
556
- // Skip malformed JSON
557
- }
558
- }
559
- });
560
-
561
- process.stdin.on("end", async () => {
562
- // Wait for any in-flight async operations (e.g. session/new) to settle
563
- await pendingOp;
564
- core?.kill();
565
- process.exit(0);
566
- });
567
-
568
- // Log unhandled rejections to stderr (don't crash, but don't swallow silently)
569
- process.on("unhandledRejection", (err) => {
570
- process.stderr.write(`[ash-acp-bridge] unhandled rejection: ${err instanceof Error ? err.message : err}\n`);
571
- });
572
-
573
- // Redirect stderr from agent-sh internals so it doesn't pollute the protocol
574
- // (agent-shell reads stdout only; stderr goes to its log)