agent-sh 0.12.1 → 0.12.3

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 (39) hide show
  1. package/README.md +10 -4
  2. package/dist/agent/agent-loop.js +34 -16
  3. package/dist/agent/conversation-state.d.ts +4 -0
  4. package/dist/agent/conversation-state.js +44 -0
  5. package/dist/agent/skills.js +2 -2
  6. package/dist/agent/system-prompt.js +2 -3
  7. package/dist/agent/tools/bash.js +10 -3
  8. package/dist/agent/types.d.ts +3 -1
  9. package/dist/core.d.ts +2 -0
  10. package/dist/core.js +5 -3
  11. package/dist/event-bus.d.ts +22 -0
  12. package/dist/event-bus.js +51 -3
  13. package/dist/extension-loader.js +1 -0
  14. package/dist/extensions/agent-backend.js +4 -1
  15. package/dist/extensions/openrouter.js +32 -0
  16. package/dist/index.js +1 -0
  17. package/dist/init.js +1 -2
  18. package/dist/settings.d.ts +8 -0
  19. package/dist/settings.js +7 -3
  20. package/dist/shell/input-handler.d.ts +8 -18
  21. package/dist/shell/input-handler.js +57 -227
  22. package/dist/shell/output-parser.d.ts +2 -1
  23. package/dist/shell/output-parser.js +33 -18
  24. package/dist/shell/shell.d.ts +1 -0
  25. package/dist/shell/shell.js +9 -7
  26. package/dist/shell/tui-input-view.d.ts +37 -0
  27. package/dist/shell/tui-input-view.js +140 -0
  28. package/dist/types.d.ts +6 -0
  29. package/dist/utils/compositor.d.ts +7 -1
  30. package/dist/utils/compositor.js +13 -1
  31. package/dist/utils/floating-panel.d.ts +6 -2
  32. package/dist/utils/floating-panel.js +17 -17
  33. package/dist/utils/ref-counter.d.ts +9 -0
  34. package/dist/utils/ref-counter.js +9 -0
  35. package/package.json +3 -1
  36. package/dist/utils/frame-renderer.d.ts +0 -26
  37. package/dist/utils/frame-renderer.js +0 -76
  38. package/dist/utils/output-writer.d.ts +0 -36
  39. package/dist/utils/output-writer.js +0 -45
package/README.md CHANGED
@@ -23,16 +23,22 @@ I still use Claude Code and pi for serious coding work — this doesn't replace
23
23
 
24
24
  ## Quick Start
25
25
 
26
- Install the latest from GitHub (recommended — development moves faster than npm releases):
26
+ Install from npm:
27
27
 
28
28
  ```bash
29
- npm install -g github:guanyilun/agent-sh
29
+ npm install -g agent-sh
30
30
  ```
31
31
 
32
- Or the last published npm release:
32
+ Re-run the same command to update. Patch releases ship frequently; `npm update -g agent-sh` works too.
33
+
34
+ For unreleased changes on `main`, clone and link locally — this avoids `npm install -g github:...`, which builds on your machine and requires a working TypeScript toolchain:
33
35
 
34
36
  ```bash
35
- npm install -g agent-sh
37
+ git clone https://github.com/guanyilun/agent-sh.git
38
+ cd agent-sh
39
+ npm install # installs devDependencies (typescript, etc.)
40
+ npm run build # produces dist/
41
+ npm link # exposes `agent-sh` globally
36
42
  ```
37
43
 
38
44
  Pick one of the zero-config paths below — no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
@@ -29,6 +29,16 @@ import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
29
29
  * the LLM via the API `tools` param (or via load_tool in deferred-
30
30
  * lookup mode) — this only trims the always-visible catalog.
31
31
  */
32
+ /** Reject on abort; orphaned `p` keeps running but its result is dropped. */
33
+ function raceAbort(p, signal) {
34
+ if (signal.aborted)
35
+ return Promise.reject(new Error("cancelled"));
36
+ return new Promise((resolve, reject) => {
37
+ const onAbort = () => reject(new Error("cancelled"));
38
+ signal.addEventListener("abort", onAbort, { once: true });
39
+ p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
40
+ });
41
+ }
32
42
  function summarizeDescription(desc) {
33
43
  const firstLine = desc.split("\n", 1)[0];
34
44
  const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
@@ -817,12 +827,11 @@ export class AgentLoop {
817
827
  this.conversation.addSystemNote(text);
818
828
  this.bus.emit("conversation:message-appended", { role: "system", content: text });
819
829
  });
830
+ // Fires on user-abort; extensions advise per tool name for cleanup.
831
+ h.define("tool:cancel", (_ctx) => { });
820
832
  // Wraps each tool call: permission → execute → emit events.
821
- // Extensions advise to add safe-mode, logging, metrics, custom policies.
822
- // The ctx.onChunk callback is exposed so advisors can wrap it to
823
- // intercept/transform streamed tool output (e.g. secret redaction).
824
833
  h.define("tool:execute", async (ctx) => {
825
- const { name, id, args, tool } = ctx;
834
+ const { name, id, args, tool, signal } = ctx;
826
835
  // Validate required input fields before display/permission/execute.
827
836
  // Some models emit wrong arg names (e.g. `file_path` instead of `path`),
828
837
  // and downstream helpers assume required strings are present.
@@ -918,16 +927,21 @@ export class AgentLoop {
918
927
  const onChunk = (tool.showOutput !== false && !diffShown)
919
928
  ? ctx.onChunk
920
929
  : undefined;
921
- const toolCtx = this.compositor
922
- ? { ui: createToolUI(this.bus, this.compositor.surface("agent")) }
923
- : undefined;
924
- // Surface thrown errors as tool results so the agent can self-correct
925
- // instead of the throw killing the whole turn.
930
+ const toolCtx = { signal };
931
+ if (this.compositor) {
932
+ toolCtx.ui = createToolUI(this.bus, this.compositor.surface("agent"));
933
+ }
926
934
  let result;
927
935
  try {
928
- result = await tool.execute(args, onChunk, toolCtx);
936
+ result = await raceAbort(tool.execute(args, onChunk, toolCtx), signal);
929
937
  }
930
938
  catch (err) {
939
+ if (signal.aborted) {
940
+ try {
941
+ this.handlers.call("tool:cancel", { name, args, reason: "user-aborted" });
942
+ }
943
+ catch { }
944
+ }
931
945
  const message = err instanceof Error ? err.message : String(err);
932
946
  result = { content: message, exitCode: 1, isError: true };
933
947
  }
@@ -1169,7 +1183,8 @@ export class AgentLoop {
1169
1183
  this.bus.emit("agent:tool-output-chunk", { chunk });
1170
1184
  };
1171
1185
  const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
1172
- batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined });
1186
+ batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
1187
+ signal });
1173
1188
  // Truncate large outputs to avoid blowing context
1174
1189
  let content = result.content;
1175
1190
  const maxBytes = 16_384; // ~4k tokens
@@ -1584,12 +1599,15 @@ export class AgentLoop {
1584
1599
  tc.argumentsJson = "{}";
1585
1600
  }
1586
1601
  }
1602
+ // Echo reasoning only for modes that opt in (e.g. DeepSeek-R1).
1587
1603
  const extras = {};
1588
- if (reasoning && reasoningField)
1589
- extras[reasoningField] = reasoning;
1590
- if (reasoningDetailsByIndex.size > 0) {
1591
- extras.reasoning_details = [...reasoningDetailsByIndex.entries()]
1592
- .sort((a, b) => a[0] - b[0]).map(([, v]) => v);
1604
+ if (this.currentMode.echoReasoning) {
1605
+ if (reasoning && reasoningField)
1606
+ extras[reasoningField] = reasoning;
1607
+ if (reasoningDetailsByIndex.size > 0) {
1608
+ extras.reasoning_details = [...reasoningDetailsByIndex.entries()]
1609
+ .sort((a, b) => a[0] - b[0]).map(([, v]) => v);
1610
+ }
1593
1611
  }
1594
1612
  return {
1595
1613
  text,
@@ -38,6 +38,7 @@ export declare class ConversationState {
38
38
  private nextSeq;
39
39
  private lastApiTokenCount;
40
40
  private lastApiMessageCount;
41
+ private pendingNotes;
41
42
  constructor(handlers?: HandlerFunctions, instanceId?: string);
42
43
  /** Get JSON.stringify of messages, cached until next mutation. */
43
44
  private getMessagesJson;
@@ -53,7 +54,10 @@ export declare class ConversationState {
53
54
  addToolResult(toolCallId: string, content: string, isError?: boolean): void;
54
55
  /** Add tool results as a user message (for inline tool protocol). */
55
56
  addToolResultInline(content: string): void;
57
+ /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
56
58
  addSystemNote(text: string): void;
59
+ private hasOpenToolCalls;
60
+ private flushPendingNotes;
57
61
  getMessages(): ChatCompletionMessageParam[];
58
62
  /**
59
63
  * If a stream was interrupted mid-tool-execution, an assistant message
@@ -56,6 +56,10 @@ export class ConversationState {
56
56
  nextSeq = 1;
57
57
  lastApiTokenCount = null;
58
58
  lastApiMessageCount = 0;
59
+ // Notes queued when addSystemNote fires mid-tool-pair; flushed once
60
+ // the trailing tool_result lands. Splicing into the gap breaks
61
+ // reasoning_content pairing and is rejected by strict providers.
62
+ pendingNotes = [];
59
63
  constructor(handlers, instanceId = "0000") {
60
64
  this.handlers = handlers ?? null;
61
65
  this.instanceId = instanceId;
@@ -100,16 +104,54 @@ export class ConversationState {
100
104
  if (isError)
101
105
  this.toolErrors.add(toolCallId);
102
106
  this.invalidateMessagesCache();
107
+ this.flushPendingNotes();
103
108
  }
104
109
  /** Add tool results as a user message (for inline tool protocol). */
105
110
  addToolResultInline(content) {
106
111
  this.messages.push({ role: "user", content });
107
112
  this.invalidateMessagesCache();
113
+ this.flushPendingNotes();
108
114
  }
115
+ /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
109
116
  addSystemNote(text) {
117
+ if (this.hasOpenToolCalls()) {
118
+ this.pendingNotes.push(text);
119
+ return;
120
+ }
110
121
  this.messages.push({ role: "user", content: text });
111
122
  this.invalidateMessagesCache();
112
123
  }
124
+ hasOpenToolCalls() {
125
+ for (let i = this.messages.length - 1; i >= 0; i--) {
126
+ const msg = this.messages[i];
127
+ if (msg.role === "tool")
128
+ continue;
129
+ if (msg.role !== "assistant")
130
+ return false;
131
+ if (!("tool_calls" in msg) || !msg.tool_calls)
132
+ return false;
133
+ const answered = new Set();
134
+ for (let j = i + 1; j < this.messages.length; j++) {
135
+ const m = this.messages[j];
136
+ if (m.role !== "tool")
137
+ break;
138
+ answered.add(m.tool_call_id);
139
+ }
140
+ return msg.tool_calls.some((tc) => !answered.has(tc.id));
141
+ }
142
+ return false;
143
+ }
144
+ flushPendingNotes() {
145
+ if (this.pendingNotes.length === 0)
146
+ return;
147
+ if (this.hasOpenToolCalls())
148
+ return;
149
+ for (const text of this.pendingNotes) {
150
+ this.messages.push({ role: "user", content: text });
151
+ }
152
+ this.pendingNotes = [];
153
+ this.invalidateMessagesCache();
154
+ }
113
155
  getMessages() {
114
156
  return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.messages));
115
157
  }
@@ -175,6 +217,7 @@ export class ConversationState {
175
217
  this.invalidateMessagesCache();
176
218
  this.lastApiTokenCount = null;
177
219
  this.lastApiMessageCount = 0;
220
+ this.flushPendingNotes();
178
221
  }
179
222
  pruneToolErrors() {
180
223
  if (this.toolErrors.size === 0)
@@ -474,6 +517,7 @@ export class ConversationState {
474
517
  this.nuclearEntries = [];
475
518
  this.nuclearBySeq.clear();
476
519
  this.recallArchive.clear();
520
+ this.pendingNotes = [];
477
521
  this.invalidateMessagesCache();
478
522
  this.lastApiTokenCount = null;
479
523
  this.lastApiMessageCount = 0;
@@ -13,7 +13,7 @@
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import * as os from "node:os";
16
- import { getSettings } from "../settings.js";
16
+ import { CONFIG_DIR, getSettings } from "../settings.js";
17
17
  /** Parse YAML frontmatter from a SKILL.md file. */
18
18
  function parseFrontmatter(content) {
19
19
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
@@ -133,7 +133,7 @@ export function discoverGlobalSkills() {
133
133
  return _cachedGlobalSkills;
134
134
  const seen = new Set();
135
135
  const skills = [];
136
- addUnique(skills, scanDir(path.join(os.homedir(), ".agent-sh", "skills")), seen);
136
+ addUnique(skills, scanDir(path.join(CONFIG_DIR, "skills")), seen);
137
137
  const settings = getSettings();
138
138
  for (const p of settings.skillPaths ?? []) {
139
139
  addUnique(skills, scanDir(path.resolve(expandHome(p))), seen);
@@ -14,9 +14,8 @@ export function formatSkillsBlock(skills) {
14
14
  + "Load a skill's full content with read_file on its file path when needed.\n\n"
15
15
  + skills.map(s => `- **${s.name}**: ${s.description}\n Path: ${s.filePath}`).join("\n\n");
16
16
  }
17
- // Resolve to the user's home-based config dir — user's standing instructions to the agent
18
- import * as os from "node:os";
19
- const GLOBAL_AGENTS_MD = path.join(os.homedir(), ".agent-sh", "AGENTS.md");
17
+ import { CONFIG_DIR } from "../settings.js";
18
+ const GLOBAL_AGENTS_MD = path.join(CONFIG_DIR, "AGENTS.md");
20
19
  // ── File caches ─────────────────────────────────────────────────────
21
20
  // Convention files (CLAUDE.md/AGENT.md) are walked synchronously from
22
21
  // CWD to root on every query. In practice they almost never change,
@@ -1,4 +1,4 @@
1
- import { executeCommand } from "../../executor.js";
1
+ import { executeCommand, killSession } from "../../executor.js";
2
2
  export function createBashTool(opts) {
3
3
  return {
4
4
  name: "bash",
@@ -33,7 +33,7 @@ export function createBashTool(opts) {
33
33
  icon: "▶",
34
34
  locations: [],
35
35
  }),
36
- async execute(args, onChunk) {
36
+ async execute(args, onChunk, ctx) {
37
37
  const command = args.command;
38
38
  const timeout = (args.timeout ?? 60) * 1000;
39
39
  // Let extensions intercept before execution
@@ -57,7 +57,14 @@ export function createBashTool(opts) {
57
57
  timeout,
58
58
  onOutput: onChunk,
59
59
  });
60
- await done;
60
+ const onAbort = () => killSession(session);
61
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
62
+ try {
63
+ await done;
64
+ }
65
+ finally {
66
+ ctx?.signal?.removeEventListener("abort", onAbort);
67
+ }
61
68
  const content = session.truncated
62
69
  ? `[output truncated, showing last portion]\n${session.output}`
63
70
  : session.output;
@@ -64,7 +64,9 @@ export interface ToolUI {
64
64
  }
65
65
  /** Context passed to tool execute() as optional third parameter. */
66
66
  export interface ToolExecutionContext {
67
- ui: ToolUI;
67
+ ui?: ToolUI;
68
+ /** Aborted on Ctrl-C — tools with subprocess work should listen and clean up. */
69
+ signal?: AbortSignal;
68
70
  }
69
71
  export interface ToolDefinition {
70
72
  name: string;
package/dist/core.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface AgentShellCore {
33
33
  contextManager: ContextManager;
34
34
  /** Handler registry for define/advise/call. */
35
35
  handlers: HandlerRegistry;
36
+ /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
37
+ instanceId: string;
36
38
  /** Activate the agent backend (call after extensions load). */
37
39
  activateBackend(): void;
38
40
  /** Convenience: emit agent:submit and await the response. */
package/dist/core.js CHANGED
@@ -27,9 +27,8 @@ import { TerminalBuffer } from "./utils/terminal-buffer.js";
27
27
  import crypto from "node:crypto";
28
28
  import * as fs from "node:fs";
29
29
  import * as path from "node:path";
30
- import * as os from "node:os";
31
30
  import { DefaultCompositor, StdoutSurface } from "./utils/compositor.js";
32
- const STORAGE_ROOT = path.join(os.homedir(), ".agent-sh");
31
+ import { CONFIG_DIR } from "./settings.js";
33
32
  // Re-export types that library consumers need
34
33
  export { EventBus } from "./event-bus.js";
35
34
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
@@ -43,6 +42,7 @@ export function createCore(config) {
43
42
  // short enough to read/remember. Legacy content may have 16-char iids; any
44
43
  // parsers should accept ≥6 hex chars.
45
44
  const instanceId = crypto.randomBytes(3).toString("hex");
45
+ bus.setSource(instanceId);
46
46
  const settings = settingsMod.getSettings();
47
47
  // Expose raw CLI config so the agent backend extension can resolve
48
48
  // providers and create the LLM client.
@@ -107,6 +107,7 @@ export function createCore(config) {
107
107
  bus,
108
108
  contextManager,
109
109
  handlers,
110
+ instanceId,
110
111
  activateBackend() {
111
112
  // Silent — backend info is shown in the startup banner.
112
113
  // Runtime switches (config:switch-backend) still emit ui:info.
@@ -169,7 +170,7 @@ export function createCore(config) {
169
170
  createFencedBlockTransform: (o) => streamTransform.createFencedBlockTransform(bus, o),
170
171
  getExtensionSettings: settingsMod.getExtensionSettings,
171
172
  getStoragePath: (namespace) => {
172
- const dir = path.join(STORAGE_ROOT, namespace);
173
+ const dir = path.join(CONFIG_DIR, namespace);
173
174
  fs.mkdirSync(dir, { recursive: true });
174
175
  return dir;
175
176
  },
@@ -187,6 +188,7 @@ export function createCore(config) {
187
188
  list: () => handlers.list(),
188
189
  get terminalBuffer() { return getTerminalBuffer(); },
189
190
  compositor,
191
+ onDispose: () => { },
190
192
  createRemoteSession: (opts) => {
191
193
  const { surface } = opts;
192
194
  const cleanups = [];
@@ -291,6 +291,7 @@ export interface ShellEvents {
291
291
  id: string;
292
292
  reasoning?: boolean;
293
293
  contextWindow?: number;
294
+ echoReasoning?: boolean;
294
295
  })[];
295
296
  /** Provider supports the reasoning_effort parameter. Default: true. */
296
297
  supportsReasoningEffort?: boolean;
@@ -357,6 +358,14 @@ export type ContentBlock = {
357
358
  type Listener<T> = (payload: T) => void;
358
359
  type PipeListener<T> = (payload: T) => T;
359
360
  type AsyncPipeListener<T> = (payload: T) => T | Promise<T>;
361
+ /** Envelope stamped on every emitted event. */
362
+ export interface BusMeta {
363
+ source: string;
364
+ ts: number;
365
+ id: string;
366
+ name: string;
367
+ }
368
+ export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => void;
360
369
  /**
361
370
  * Typed event bus with two modes:
362
371
  * - emit/on/off: fire-and-forget notifications
@@ -367,12 +376,25 @@ export declare class EventBus {
367
376
  private emitter;
368
377
  private pipeListeners;
369
378
  private asyncPipeListeners;
379
+ private source;
380
+ private nextSeq;
381
+ private anyListeners;
382
+ /** Set the source id stamped onto every emitted event. */
383
+ setSource(src: string): void;
384
+ /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
385
+ onAny(fn: AnyListener): () => void;
386
+ /** Stamp + dispatch — used by every emit path. */
387
+ private dispatch;
370
388
  /** Subscribe to a fire-and-forget event. */
371
389
  on<K extends keyof ShellEvents>(event: K, fn: Listener<ShellEvents[K]>): void;
372
390
  /** Unsubscribe from a fire-and-forget event. */
373
391
  off<K extends keyof ShellEvents>(event: K, fn: Listener<ShellEvents[K]>): void;
374
392
  /** Emit a fire-and-forget event. */
375
393
  emit<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): void;
394
+ /** Re-dispatch an event with externally-supplied meta. Used by bridges
395
+ * and replay tools to preserve the original source/ts/id of remote or
396
+ * recorded events instead of restamping them as locally originated. */
397
+ relay(meta: BusMeta, payload: unknown): void;
376
398
  /**
377
399
  * Transform-then-notify: run the payload through any registered pipe
378
400
  * listeners (transforms), then emit the final result to regular `on`
package/dist/event-bus.js CHANGED
@@ -9,6 +9,40 @@ export class EventBus {
9
9
  emitter = new EventEmitter().setMaxListeners(0);
10
10
  pipeListeners = new Map();
11
11
  asyncPipeListeners = new Map();
12
+ source = "0000";
13
+ nextSeq = 0;
14
+ anyListeners = [];
15
+ /** Set the source id stamped onto every emitted event. */
16
+ setSource(src) {
17
+ this.source = src;
18
+ }
19
+ /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
20
+ onAny(fn) {
21
+ this.anyListeners.push(fn);
22
+ return () => {
23
+ const i = this.anyListeners.indexOf(fn);
24
+ if (i !== -1)
25
+ this.anyListeners.splice(i, 1);
26
+ };
27
+ }
28
+ /** Stamp + dispatch — used by every emit path. */
29
+ dispatch(name, payload) {
30
+ if (this.anyListeners.length > 0) {
31
+ const meta = {
32
+ source: this.source,
33
+ ts: Date.now(),
34
+ id: `${this.source}:${this.nextSeq++}`,
35
+ name,
36
+ };
37
+ for (const fn of this.anyListeners) {
38
+ try {
39
+ fn(name, payload, meta);
40
+ }
41
+ catch { /* swallow */ }
42
+ }
43
+ }
44
+ this.emitter.emit(name, payload);
45
+ }
12
46
  /** Subscribe to a fire-and-forget event. */
13
47
  on(event, fn) {
14
48
  this.emitter.on(event, fn);
@@ -19,7 +53,21 @@ export class EventBus {
19
53
  }
20
54
  /** Emit a fire-and-forget event. */
21
55
  emit(event, payload) {
22
- this.emitter.emit(event, payload);
56
+ this.dispatch(event, payload);
57
+ }
58
+ /** Re-dispatch an event with externally-supplied meta. Used by bridges
59
+ * and replay tools to preserve the original source/ts/id of remote or
60
+ * recorded events instead of restamping them as locally originated. */
61
+ relay(meta, payload) {
62
+ if (this.anyListeners.length > 0) {
63
+ for (const fn of this.anyListeners) {
64
+ try {
65
+ fn(meta.name, payload, meta);
66
+ }
67
+ catch { /* swallow */ }
68
+ }
69
+ }
70
+ this.emitter.emit(meta.name, payload);
23
71
  }
24
72
  /**
25
73
  * Transform-then-notify: run the payload through any registered pipe
@@ -38,7 +86,7 @@ export class EventBus {
38
86
  }
39
87
  transformed = payload; // fall back to untransformed
40
88
  }
41
- this.emitter.emit(event, transformed);
89
+ this.dispatch(event, transformed);
42
90
  }
43
91
  /** Register a transform listener for a pipeline event. */
44
92
  onPipe(event, fn) {
@@ -103,7 +151,7 @@ export class EventBus {
103
151
  */
104
152
  async emitPipeAsync(event, payload) {
105
153
  // Phase 1: notify (lets renderers prepare for interactive I/O)
106
- this.emitter.emit(event, payload);
154
+ this.dispatch(event, payload);
107
155
  // Phase 2: transform (extensions provide decisions)
108
156
  const listeners = this.asyncPipeListeners.get(event);
109
157
  if (!listeners)
@@ -96,6 +96,7 @@ function createScopedContext(ctx, extensionName) {
96
96
  registerTool: scopedRegisterTool,
97
97
  unregisterTool: ctx.unregisterTool,
98
98
  registerCommand: scopedRegisterCommand,
99
+ onDispose: (fn) => { cleanups.push(fn); },
99
100
  };
100
101
  const dispose = () => {
101
102
  for (const fn of cleanups) {
@@ -32,6 +32,7 @@ export default function agentBackend(ctx) {
32
32
  contextWindow: mc?.contextWindow ?? p.contextWindow,
33
33
  reasoning: mc?.reasoning,
34
34
  supportsReasoningEffort: p.supportsReasoningEffort,
35
+ echoReasoning: mc?.echoReasoning,
35
36
  });
36
37
  }
37
38
  }
@@ -135,7 +136,7 @@ export default function agentBackend(ctx) {
135
136
  }
136
137
  else {
137
138
  modelIds.push(m.id);
138
- caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow });
139
+ caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, echoReasoning: m.echoReasoning });
139
140
  }
140
141
  }
141
142
  providerRegistry.set(p.id, {
@@ -156,6 +157,7 @@ export default function agentBackend(ctx) {
156
157
  contextWindow: mc?.contextWindow,
157
158
  reasoning: mc?.reasoning,
158
159
  supportsReasoningEffort: p.supportsReasoningEffort,
160
+ echoReasoning: mc?.echoReasoning,
159
161
  };
160
162
  });
161
163
  bus.emit("config:add-modes", { modes: addModes });
@@ -197,6 +199,7 @@ export default function agentBackend(ctx) {
197
199
  contextWindow: mc?.contextWindow ?? p.contextWindow,
198
200
  reasoning: mc?.reasoning,
199
201
  supportsReasoningEffort: p.supportsReasoningEffort,
202
+ echoReasoning: mc?.echoReasoning,
200
203
  };
201
204
  });
202
205
  bus.emit("config:set-modes", { modes: newModes });
@@ -1,5 +1,11 @@
1
+ import { getSettings } from "../settings.js";
1
2
  const BASE_URL = "https://openrouter.ai/api/v1";
2
3
  const DEFAULT_MODELS = ["anthropic/claude-sonnet-4.6"];
4
+ // Built-in defaults for models requiring reasoning_content echoed back
5
+ // (server 400s without it). Extend or override in settings.json:
6
+ // providers.openrouter.echoReasoningPatterns = ["deepseek", "..."]
7
+ // providers.openrouter.models[*].echoReasoning = true | false
8
+ const BUILTIN_ECHO_REASONING_PATTERNS = [/deepseek/i];
3
9
  export default function activate(ctx) {
4
10
  const apiKey = process.env.OPENROUTER_API_KEY;
5
11
  if (!apiKey)
@@ -14,6 +20,8 @@ export default function activate(ctx) {
14
20
  fetchModels(apiKey).then((models) => {
15
21
  if (models.length === 0)
16
22
  return;
23
+ const userOverrides = readUserOverrides();
24
+ const patterns = readEchoPatterns();
17
25
  ctx.bus.emit("provider:register", {
18
26
  id: "openrouter",
19
27
  apiKey,
@@ -24,10 +32,34 @@ export default function activate(ctx) {
24
32
  id: m.id,
25
33
  reasoning: m.supported_parameters?.includes("reasoning") ?? false,
26
34
  contextWindow: m.context_length,
35
+ echoReasoning: userOverrides.get(m.id) ?? patterns.some((re) => re.test(m.id)),
27
36
  })),
28
37
  });
29
38
  }).catch(() => { });
30
39
  }
40
+ function readEchoPatterns() {
41
+ const userPatterns = getSettings().providers?.openrouter?.echoReasoningPatterns ?? [];
42
+ const compiled = [];
43
+ for (const src of userPatterns) {
44
+ try {
45
+ compiled.push(new RegExp(src, "i"));
46
+ }
47
+ catch { /* skip invalid pattern */ }
48
+ }
49
+ return [...BUILTIN_ECHO_REASONING_PATTERNS, ...compiled];
50
+ }
51
+ function readUserOverrides() {
52
+ const out = new Map();
53
+ const models = getSettings().providers?.openrouter?.models;
54
+ if (!Array.isArray(models))
55
+ return out;
56
+ for (const m of models) {
57
+ if (typeof m === "object" && m && m.echoReasoning !== undefined) {
58
+ out.set(m.id, m.echoReasoning);
59
+ }
60
+ }
61
+ return out;
62
+ }
31
63
  async function fetchModels(apiKey) {
32
64
  const res = await fetch(`${BASE_URL}/models`, {
33
65
  headers: { Authorization: `Bearer ${apiKey}` },
package/dist/index.js CHANGED
@@ -218,6 +218,7 @@ async function main() {
218
218
  rows,
219
219
  shell: config.shell || process.env.SHELL || "/bin/bash",
220
220
  cwd: process.cwd(),
221
+ instanceId: core.instanceId,
221
222
  onShowAgentInfo: () => {
222
223
  if (agentInfo) {
223
224
  return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
package/dist/init.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import * as os from "node:os";
4
- const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
3
+ import { CONFIG_DIR } from "./settings.js";
5
4
  const EXTENSIONS_DIR = path.join(CONFIG_DIR, "extensions");
6
5
  const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
7
6
  const EXAMPLE_PATH = path.join(CONFIG_DIR, "settings.example.json");
@@ -1,3 +1,5 @@
1
+ /** Root config directory. Override via AGENT_SH_HOME for isolated instances
2
+ * (testing, multi-agent setups). Path is resolved at module load. */
1
3
  export declare const CONFIG_DIR: string;
2
4
  /** Per-model capability overrides. */
3
5
  export interface ModelCapabilityConfig {
@@ -7,6 +9,8 @@ export interface ModelCapabilityConfig {
7
9
  reasoning?: boolean;
8
10
  /** Context window size in tokens for this specific model. */
9
11
  contextWindow?: number;
12
+ /** Echo reasoning_content back on assistant turns. Required by DeepSeek. */
13
+ echoReasoning?: boolean;
10
14
  }
11
15
  /** Provider profile — a named LLM configuration. */
12
16
  export interface ProviderConfig {
@@ -20,6 +24,9 @@ export interface ProviderConfig {
20
24
  models?: (string | ModelCapabilityConfig)[];
21
25
  /** Context window size in tokens (e.g. 128000). Used for usage display. */
22
26
  contextWindow?: number;
27
+ /** Case-insensitive regex sources matched against model id; matches default
28
+ * to echoReasoning=true. Per-model echoReasoning still wins. */
29
+ echoReasoningPatterns?: string[];
23
30
  }
24
31
  export interface Settings {
25
32
  /** Extensions to load (npm packages or file paths). */
@@ -136,6 +143,7 @@ export interface ResolvedProvider {
136
143
  modelCapabilities?: Map<string, {
137
144
  reasoning?: boolean;
138
145
  contextWindow?: number;
146
+ echoReasoning?: boolean;
139
147
  }>;
140
148
  }
141
149
  /**
package/dist/settings.js CHANGED
@@ -7,7 +7,11 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import * as os from "node:os";
10
- export const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
10
+ /** Root config directory. Override via AGENT_SH_HOME for isolated instances
11
+ * (testing, multi-agent setups). Path is resolved at module load. */
12
+ export const CONFIG_DIR = process.env.AGENT_SH_HOME
13
+ ? path.resolve(process.env.AGENT_SH_HOME)
14
+ : path.join(os.homedir(), ".agent-sh");
11
15
  const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
16
  const DEFAULTS = {
13
17
  extensions: [],
@@ -143,8 +147,8 @@ export function resolveProvider(name) {
143
147
  }
144
148
  else {
145
149
  modelIds.push(m.id);
146
- if (m.reasoning !== undefined || m.contextWindow !== undefined) {
147
- caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow });
150
+ if (m.reasoning !== undefined || m.contextWindow !== undefined || m.echoReasoning !== undefined) {
151
+ caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, echoReasoning: m.echoReasoning });
148
152
  }
149
153
  }
150
154
  }