agent-sh 0.10.0 → 0.10.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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ Requires Node.js 18+.
44
44
 
45
45
  **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).
46
46
 
47
- **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and cycle between models at runtime with Shift+Tab. Or swap in a completely different agent — [Claude Code](examples/extensions/claude-code-bridge/) and [pi](examples/extensions/pi-bridge/) run as drop-in backend extensions.
47
+ **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 — [Claude Code](examples/extensions/claude-code-bridge/) and [pi](examples/extensions/pi-bridge/) run as drop-in backend extensions.
48
48
 
49
49
  **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.
50
50
 
@@ -52,14 +52,16 @@ Requires Node.js 18+.
52
52
 
53
53
  ## Documentation
54
54
 
55
- - [Usage Guide](docs/usage.md) providers, models, configuration
56
- - [Internal Agent](docs/agent.md) — tools, context, streaming
57
- - [Context Management](docs/context-management.md) — three-tier history, token budget
58
- - [Architecture](docs/architecture.md) — design philosophy, component overview
59
- - [Extensions](docs/extensions.md) — event bus, content transforms, custom backends, theming
60
- - [TUI Composition](docs/tui-composition.md) — compositor, render surfaces, stream routing
61
- - [Library Usage](docs/library.md) — embedding agent-sh in your own apps
62
- - [Troubleshooting](docs/troubleshooting.md) — common errors and debug mode
55
+ Start with **Usage** to get running, then **Architecture** for the mental model.
56
+
57
+ 1. [Usage Guide](docs/usage.md) — install, run, configure providers and models
58
+ 2. [Architecture](docs/architecture.md) — pure kernel + extensions, the shell ↔ agent boundary
59
+ 3. [The Built-in Agent: ash](docs/agent.md) — query flow, tools, system prompt, model switching
60
+ 4. [Context Management](docs/context-management.md) — shell-output spill, three-tier conversation compaction, recall APIs
61
+ 5. [Extensions](docs/extensions.md) — event bus, content transforms, custom agent backends, theming
62
+ 6. [TUI Composition](docs/tui-composition.md) — compositor, render surfaces, stream routing
63
+ 7. [Library Usage](docs/library.md) — embedding agent-sh in your own apps
64
+ 8. [Troubleshooting](docs/troubleshooting.md) — common errors and debug mode
63
65
 
64
66
  ## Development
65
67
 
@@ -4,7 +4,6 @@
4
4
  * Subscribes to bus events in constructor:
5
5
  * - agent:submit → run query through LLM tool loop
6
6
  * - agent:cancel-request → abort current loop
7
- * - config:cycle → cycle through modes
8
7
  *
9
8
  * Emits bus events during execution:
10
9
  * - agent:query, agent:processing-start/done, agent:response-chunk/done
@@ -36,7 +35,6 @@ export declare class AgentLoop implements AgentBackend {
36
35
  private historyFile;
37
36
  private conversation;
38
37
  private fileReadCache;
39
- private tokenBudget;
40
38
  private modes;
41
39
  private currentModeIndex;
42
40
  private boundListeners;
@@ -104,7 +102,6 @@ export declare class AgentLoop implements AgentBackend {
104
102
  private cancel;
105
103
  /** Check if reasoning_effort should be sent for the current model/provider. */
106
104
  private shouldSendReasoningEffort;
107
- private cycleMode;
108
105
  private get currentMode();
109
106
  private get currentModel();
110
107
  /**
@@ -8,7 +8,8 @@ import { HistoryFile } from "./history-file.js";
8
8
  import { nucleate, formatNuclearLine, isReadOnly } from "./nuclear-form.js";
9
9
  import { STATIC_SYSTEM_PROMPT, buildDynamicContext, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
10
10
  import { createToolUI } from "../utils/tool-interactive.js";
11
- import { TokenBudget, RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
11
+ import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
12
+ import { PACKAGE_VERSION } from "../utils/package-version.js";
12
13
  import { getSettings, updateSettings } from "../settings.js";
13
14
  import { createToolProtocol } from "./tool-protocol.js";
14
15
  // Core tool factories
@@ -40,7 +41,6 @@ export class AgentLoop {
40
41
  historyFile;
41
42
  conversation;
42
43
  fileReadCache = new Map();
43
- tokenBudget;
44
44
  modes;
45
45
  currentModeIndex = 0;
46
46
  boundListeners = [];
@@ -105,8 +105,6 @@ export class AgentLoop {
105
105
  ? config.modes
106
106
  : [{ model: config.llmClient.model }];
107
107
  this.currentModeIndex = config.initialModeIndex ?? 0;
108
- // Unified token budget — adapts to current model's context window
109
- this.tokenBudget = new TokenBudget(this.currentMode.contextWindow);
110
108
  // Tool protocol — controls how tools are presented to the LLM
111
109
  this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api");
112
110
  // Register core tools
@@ -115,8 +113,6 @@ export class AgentLoop {
115
113
  const protocolTools = this.toolProtocol.getProtocolTools?.() ?? [];
116
114
  for (const t of protocolTools)
117
115
  this.registerTool(t);
118
- // Update token budget with tool count
119
- this.tokenBudget.update(undefined, this.toolRegistry.all().length);
120
116
  // Register handlers — extensions can advise these
121
117
  this.registerHandlers();
122
118
  // Subscribe to bus-based tool/instruction registration from extensions.
@@ -165,7 +161,6 @@ export class AgentLoop {
165
161
  else {
166
162
  this.llmClient.model = m.model;
167
163
  }
168
- this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
169
164
  this.bus.emit("config:changed", {});
170
165
  });
171
166
  const getToolsPipe = () => ({ tools: this.getTools() });
@@ -184,7 +179,6 @@ export class AgentLoop {
184
179
  on("agent:cancel-request", (e) => {
185
180
  this.abortController?.abort(e.silent ? "silent" : undefined);
186
181
  });
187
- on("config:cycle", () => this.cycleMode());
188
182
  on("config:switch-model", ({ model: target }) => {
189
183
  const idx = this.modes.findIndex((m) => m.model === target);
190
184
  if (idx === -1) {
@@ -199,9 +193,8 @@ export class AgentLoop {
199
193
  else {
200
194
  this.llmClient.model = m.model;
201
195
  }
202
- this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
203
196
  const label = m.provider ? `${m.provider}: ${m.model}` : m.model;
204
- this.bus.emit("agent:info", { name: "ash", version: "0.4", model: m.model, provider: m.provider, contextWindow: m.contextWindow });
197
+ this.bus.emit("agent:info", { name: "ash", version: PACKAGE_VERSION, model: m.model, provider: m.provider, contextWindow: m.contextWindow });
205
198
  // Persist as the new default — selection survives restart.
206
199
  // Safe even for dynamic providers: agent-backend defers mode
207
200
  // resolution to `core:extensions-loaded`, so the extension gets
@@ -428,30 +421,6 @@ export class AgentLoop {
428
421
  return false;
429
422
  return true;
430
423
  }
431
- cycleMode() {
432
- const prevMode = this.modes[this.currentModeIndex];
433
- this.currentModeIndex =
434
- (this.currentModeIndex + 1) % this.modes.length;
435
- const newMode = this.modes[this.currentModeIndex];
436
- // Reconfigure LlmClient if provider changed
437
- if (newMode.provider !== prevMode.provider && newMode.providerConfig) {
438
- this.llmClient.reconfigure({
439
- apiKey: newMode.providerConfig.apiKey,
440
- baseURL: newMode.providerConfig.baseURL,
441
- model: newMode.model,
442
- });
443
- }
444
- else {
445
- this.llmClient.model = newMode.model;
446
- }
447
- this.tokenBudget.update(newMode.contextWindow, this.toolRegistry.all().length);
448
- const label = newMode.provider
449
- ? `${newMode.provider}: ${newMode.model}`
450
- : newMode.model;
451
- this.bus.emit("agent:info", { name: "ash", version: "0.4", model: newMode.model, provider: newMode.provider, contextWindow: newMode.contextWindow });
452
- this.bus.emit("ui:info", { message: `Model: ${label}` });
453
- this.bus.emit("config:changed", {});
454
- }
455
424
  get currentMode() {
456
425
  return this.modes[this.currentModeIndex];
457
426
  }
@@ -1066,7 +1035,15 @@ export class AgentLoop {
1066
1035
  const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1067
1036
  const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * getSettings().autoCompactThreshold);
1068
1037
  if (totalEstimate > threshold) {
1069
- this.compactWithHooks(threshold);
1038
+ const result = this.compactWithHooks(threshold);
1039
+ if (!result) {
1040
+ // Auto-compact fired but nothing was evictable. This can happen
1041
+ // in short conversations with heavy tool output where the pin
1042
+ // fraction consumes all turns. Log it so it's not silent.
1043
+ this.bus.emit("ui:info", {
1044
+ message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
1045
+ });
1046
+ }
1070
1047
  cachedSystemPrompt = undefined;
1071
1048
  }
1072
1049
  const currentCwd = this.contextManager.getCwd();
@@ -217,12 +217,18 @@ export class ConversationState {
217
217
  if (!force && convEstimate <= convTarget)
218
218
  return null;
219
219
  const turns = this.parseTurns();
220
- if (turns.length <= 2)
220
+ // With force, allow compacting down to 1 turn (the current response).
221
+ // Without force, keep at least 2 turns (user + agent) to avoid
222
+ // annihilating a young conversation.
223
+ if (turns.length <= (force ? 1 : 2))
221
224
  return null;
222
225
  // Cap the pinned window so enough turns remain evictable.
223
226
  const maxPinnedFraction = force ? 0.4 : 0.6;
224
227
  const maxPinned = Math.max(2, Math.floor(turns.length * maxPinnedFraction));
225
- const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, maxPinned);
228
+ // Ensure at least 1 turn is evictable when force is true, even in
229
+ // very short conversations (e.g. 3 turns with heavy tool output).
230
+ const maxPinnedForced = force ? Math.min(maxPinned, turns.length - 2) : maxPinned;
231
+ const pinnedCount = Math.min(recentTurnsToKeep, turns.length - 1, Math.max(1, maxPinnedForced));
226
232
  for (let i = 0; i < turns.length; i++) {
227
233
  turns[i].priority = this.inferPriority(turns[i].messages);
228
234
  }
@@ -1,14 +1,10 @@
1
+ /**
2
+ * Shared token-budget constants used by auto-compaction.
3
+ *
4
+ * RESPONSE_RESERVE: tokens reserved for the model's output.
5
+ * DEFAULT_CONTEXT_WINDOW: fallback when the active mode doesn't declare one.
6
+ */
1
7
  /** Response reserve — tokens reserved for the model's output. */
2
- declare const RESPONSE_RESERVE = 8192;
8
+ export declare const RESPONSE_RESERVE = 8192;
3
9
  /** Fallback when contextWindow is unknown. */
4
- declare const DEFAULT_CONTEXT_WINDOW = 60000;
5
- export { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW };
6
- export declare class TokenBudget {
7
- private contextWindow;
8
- private toolCount;
9
- constructor(contextWindow?: number, toolCount?: number);
10
- /** Update when model or tool set changes. */
11
- update(contextWindow?: number, toolCount?: number): void;
12
- /** Token budget for the shell context stream. */
13
- get shellBudgetTokens(): number;
14
- }
10
+ export declare const DEFAULT_CONTEXT_WINDOW = 60000;
@@ -1,45 +1,10 @@
1
1
  /**
2
- * Token budget for shell context sizing.
2
+ * Shared token-budget constants used by auto-compaction.
3
3
  *
4
- * Computes how much of the context window to allocate to shell history
5
- * (user commands and outputs situational awareness). The remaining
6
- * space is for the conversation, system prompt, tools, and response.
7
- *
8
- * Shell context is sized loosely — chars/4 accuracy is fine for this.
9
- * Conversation and compaction decisions use API-grounded token counts
10
- * (see ConversationState.estimatePromptTokens).
4
+ * RESPONSE_RESERVE: tokens reserved for the model's output.
5
+ * DEFAULT_CONTEXT_WINDOW: fallback when the active mode doesn't declare one.
11
6
  */
12
- import { getSettings } from "../settings.js";
13
- const SYSTEM_PROMPT_OVERHEAD = 800;
14
- const DYNAMIC_CONTEXT_OVERHEAD = 500; // conventions, metadata, skills list
15
- const TOKENS_PER_TOOL_DEFINITION = 50;
16
7
  /** Response reserve — tokens reserved for the model's output. */
17
- const RESPONSE_RESERVE = 8192;
8
+ export const RESPONSE_RESERVE = 8192;
18
9
  /** Fallback when contextWindow is unknown. */
19
- const DEFAULT_CONTEXT_WINDOW = 60_000;
20
- export { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW };
21
- export class TokenBudget {
22
- contextWindow;
23
- toolCount;
24
- constructor(contextWindow, toolCount = 0) {
25
- this.contextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW;
26
- this.toolCount = toolCount;
27
- }
28
- /** Update when model or tool set changes. */
29
- update(contextWindow, toolCount) {
30
- if (contextWindow != null)
31
- this.contextWindow = contextWindow;
32
- if (toolCount != null)
33
- this.toolCount = toolCount;
34
- }
35
- /** Token budget for the shell context stream. */
36
- get shellBudgetTokens() {
37
- const overhead = SYSTEM_PROMPT_OVERHEAD +
38
- DYNAMIC_CONTEXT_OVERHEAD +
39
- this.toolCount * TOKENS_PER_TOOL_DEFINITION +
40
- RESPONSE_RESERVE;
41
- const contentBudget = Math.max(0, this.contextWindow - overhead);
42
- const ratio = getSettings().shellContextRatio;
43
- return Math.floor(contentBudget * ratio);
44
- }
45
- }
10
+ export const DEFAULT_CONTEXT_WINDOW = 60_000;
@@ -4,7 +4,6 @@
4
4
  * Backends self-wire to bus events in their constructor:
5
5
  * - agent:submit → handle queries
6
6
  * - agent:cancel-request → handle cancellation
7
- * - config:cycle → handle mode switching
8
7
  *
9
8
  * They emit bus events for results:
10
9
  * - agent:response-chunk, agent:tool-started, agent:tool-completed, etc.
@@ -4,26 +4,13 @@ export declare class ContextManager {
4
4
  private exchanges;
5
5
  private nextId;
6
6
  private currentCwd;
7
- private sessionStart;
8
- private firstPrompt;
9
7
  private agentShellActive;
10
- private handlers;
11
- constructor(bus: EventBus, handlers?: HandlerRegistry);
8
+ constructor(bus: EventBus, _handlers?: HandlerRegistry);
12
9
  getCwd(): string;
13
- /**
14
- * Build the <shell_context> block for the agent prompt.
15
- * Pipeline: window → truncate → format
16
- */
17
- getContext(budget?: number): string;
18
10
  /**
19
11
  * Regex/keyword search across all exchanges. Returns formatted results.
20
12
  */
21
13
  search(query: string): string;
22
- /**
23
- * Return content for specific exchange IDs.
24
- * Optional start/end restrict to a line range (1-indexed).
25
- */
26
- expand(ids: number[], start?: number, end?: number): string;
27
14
  /**
28
15
  * Return shell events with id > afterId, formatted as an incremental
29
16
  * delta suitable for injection into conversation history. Skips
@@ -45,17 +32,10 @@ export declare class ContextManager {
45
32
  * One-line summaries of last N exchanges.
46
33
  */
47
34
  getRecentSummary(n?: number): string;
48
- /**
49
- * Parse and handle shell_recall commands.
50
- */
51
- handleRecallCommand(command: string): string;
52
35
  /**
53
36
  * Clear exchange history (used by /clear command).
54
37
  */
55
38
  clear(): void;
56
- private applyWindow;
57
- private applyTruncation;
58
- private formatContext;
59
39
  private addExchange;
60
40
  private formatExchangeTruncated;
61
41
  private formatExchangeFull;
@@ -1,32 +1,43 @@
1
1
  import { getSettings } from "./settings.js";
2
+ import { spillOutput } from "./utils/shell-output-spill.js";
2
3
  export class ContextManager {
3
4
  exchanges = [];
4
5
  nextId = 1;
5
6
  currentCwd;
6
- sessionStart;
7
- firstPrompt = true;
8
7
  agentShellActive = false; // true while user_shell command is executing
9
- handlers = null;
10
- constructor(bus, handlers) {
11
- if (handlers) {
12
- this.handlers = handlers;
13
- // Extensions can advise this to inject extra context (e.g. terminal buffer)
14
- handlers.define("context:build-extra", () => "");
15
- }
8
+ constructor(bus, _handlers) {
16
9
  this.currentCwd = process.cwd();
17
- this.sessionStart = Date.now();
18
10
  // ── Subscribe to shell events ──
19
11
  bus.on("shell:command-done", (e) => {
20
12
  const lines = e.output.split("\n");
13
+ const s = getSettings();
14
+ // Spill long outputs to a tempfile so the agent can `read_file` them
15
+ // on demand instead of carrying the full text in LLM context.
16
+ let output = e.output;
17
+ let spillPath;
18
+ if (lines.length > s.shellTruncateThreshold) {
19
+ // Reserve the id we're about to assign so the tempfile name matches.
20
+ const id = this.nextId;
21
+ try {
22
+ spillPath = spillOutput(id, e.output);
23
+ output = buildSpillStub(lines, s.shellHeadLines, s.shellTailLines, spillPath);
24
+ }
25
+ catch {
26
+ // If spill fails (e.g. disk full), fall back to keeping output in memory.
27
+ output = e.output;
28
+ spillPath = undefined;
29
+ }
30
+ }
21
31
  this.addExchange({
22
32
  type: "shell_command",
23
33
  command: e.command,
24
- output: e.output,
34
+ output,
25
35
  cwd: e.cwd,
26
36
  exitCode: e.exitCode,
27
37
  outputLines: lines.length,
28
38
  outputBytes: e.output.length,
29
39
  source: this.agentShellActive ? "agent" : "user",
40
+ spillPath,
30
41
  });
31
42
  });
32
43
  bus.on("shell:cwd-change", (e) => {
@@ -46,16 +57,6 @@ export class ContextManager {
46
57
  getCwd() {
47
58
  return this.currentCwd;
48
59
  }
49
- /**
50
- * Build the <shell_context> block for the agent prompt.
51
- * Pipeline: window → truncate → format
52
- */
53
- getContext(budget) {
54
- budget ??= getSettings().contextBudget;
55
- let exchanges = this.applyWindow(this.exchanges);
56
- exchanges = this.applyTruncation(exchanges, budget);
57
- return this.formatContext(exchanges);
58
- }
59
60
  /**
60
61
  * Regex/keyword search across all exchanges. Returns formatted results.
61
62
  */
@@ -106,40 +107,6 @@ export class ContextManager {
106
107
  }
107
108
  return parts.join("\n");
108
109
  }
109
- /**
110
- * Return content for specific exchange IDs.
111
- * Optional start/end restrict to a line range (1-indexed).
112
- */
113
- expand(ids, start, end) {
114
- const results = [];
115
- for (const id of ids) {
116
- const ex = this.exchanges.find((e) => e.id === id);
117
- if (!ex) {
118
- results.push(`#${id}: not found`);
119
- continue;
120
- }
121
- const text = this.formatExchangeFull(ex);
122
- const lines = text.split("\n");
123
- const total = lines.length;
124
- if (start != null || end != null) {
125
- // Line range requested
126
- const s = Math.max(0, (start ?? 1) - 1);
127
- const e = end ?? total;
128
- results.push(lines.slice(s, e).join("\n") +
129
- `\n[showing lines ${s + 1}-${Math.min(e, total)} of ${total}]`);
130
- }
131
- else if (total > getSettings().recallExpandMaxLines) {
132
- // Too large — tell the agent to narrow down
133
- results.push(`#${ex.id}: output is ${total} lines, too large to expand fully. ` +
134
- `Use start/end params to select a line range (e.g. start=1, end=50), ` +
135
- `or use search with a regex to find specific content.`);
136
- }
137
- else {
138
- results.push(text);
139
- }
140
- }
141
- return results.join("\n\n");
142
- }
143
110
  /**
144
111
  * Return shell events with id > afterId, formatted as an incremental
145
112
  * delta suitable for injection into conversation history. Skips
@@ -156,18 +123,8 @@ export class ContextManager {
156
123
  if (fresh.length === 0)
157
124
  return null;
158
125
  const lastSeq = this.exchanges[this.exchanges.length - 1].id;
159
- // Apply per-type truncation so giant outputs don't blow up the turn.
160
- const truncated = fresh.map((ex) => {
161
- if (ex.type === "shell_command") {
162
- const s = getSettings();
163
- return {
164
- ...ex,
165
- output: truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id),
166
- };
167
- }
168
- return { ...ex };
169
- });
170
- const body = truncated.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
126
+ // Outputs already carry head+tail+spillPath stubs from capture time.
127
+ const body = fresh.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
171
128
  return {
172
129
  text: `<shell-events>\n${body}</shell-events>`,
173
130
  lastSeq,
@@ -186,104 +143,13 @@ export class ContextManager {
186
143
  return "No exchanges yet.";
187
144
  return recent.map((ex) => this.exchangeOneLiner(ex)).join("\n");
188
145
  }
189
- /**
190
- * Parse and handle shell_recall commands.
191
- */
192
- handleRecallCommand(command) {
193
- const args = command.replace(/^_*shell_recall\s*/, "").trim();
194
- if (!args || args === "--help") {
195
- return [
196
- "Usage:",
197
- " shell_recall Browse recent exchanges",
198
- " shell_recall --search <query> Search all exchanges",
199
- " shell_recall --expand <id,...> Show full content of exchanges",
200
- "",
201
- "Examples:",
202
- ' shell_recall --search "test fail"',
203
- " shell_recall --expand 41",
204
- " shell_recall --expand 41,42,43",
205
- ].join("\n");
206
- }
207
- const searchMatch = args.match(/^--search\s+(?:"([^"]+)"|(\S+))/);
208
- if (searchMatch) {
209
- return this.search(searchMatch[1] ?? searchMatch[2] ?? "");
210
- }
211
- const expandMatch = args.match(/^--expand\s+([\d,\s]+)/);
212
- if (expandMatch) {
213
- const ids = expandMatch[1]
214
- .split(/[,\s]+/)
215
- .map(Number)
216
- .filter((n) => !isNaN(n));
217
- if (ids.length === 0)
218
- return "No valid IDs provided.";
219
- return this.expand(ids);
220
- }
221
- // Default: browse
222
- return this.getRecentSummary();
223
- }
224
146
  /**
225
147
  * Clear exchange history (used by /clear command).
226
148
  */
227
149
  clear() {
228
150
  this.exchanges = [];
229
- this.firstPrompt = true;
230
151
  // Don't reset nextId — IDs should be globally unique within a session
231
152
  }
232
- // ── Pipeline stages ───────────────────────────────────────────
233
- applyWindow(exchanges, windowSize) {
234
- windowSize ??= getSettings().contextWindowSize;
235
- return exchanges.slice(-windowSize);
236
- }
237
- applyTruncation(exchanges, budget) {
238
- // Deep clone so we don't mutate the source
239
- const result = exchanges.map((e) => ({ ...e }));
240
- // Pass 1: per-type truncation
241
- for (const ex of result) {
242
- if (ex.type === "shell_command") {
243
- const s = getSettings();
244
- ex.output = truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id);
245
- }
246
- // agent_query has no output to truncate
247
- }
248
- // Pass 2: budget enforcement — strip output from oldest if over budget
249
- let totalSize = result.reduce((sum, ex) => sum + this.exchangeSize(ex), 0);
250
- for (let i = 0; i < result.length - 1 && totalSize > budget; i++) {
251
- const ex = result[i];
252
- const before = this.exchangeSize(ex);
253
- if (ex.type === "shell_command") {
254
- ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
255
- }
256
- totalSize -= before - this.exchangeSize(ex);
257
- }
258
- return result;
259
- }
260
- formatContext(exchanges) {
261
- const elapsed = Math.round((Date.now() - this.sessionStart) / 60000);
262
- const totalCount = this.exchanges.length;
263
- let out = "<shell_context>\n";
264
- if (this.firstPrompt) {
265
- out += `You are an AI assistant living inside agent-sh, a shell-first terminal.\n`;
266
- out += `The user interacts with a real shell (PTY) and sends you queries inline. You are there to help them with their tasks.\n`;
267
- out += `\n`;
268
- out += `IMPORTANT tool usage rules:\n`;
269
- out += `- Your internal tools (bash, read, write, ls, etc.) run in an isolated subprocess. The user CANNOT see their output.\n`;
270
- out += `- Only use internal tools when YOU need to reason about content silently (e.g. reading a file to answer a question about it).\n`;
271
- out += `- You can browse or search shell history with shell_recall.\n`;
272
- out += `\n`;
273
- this.firstPrompt = false;
274
- }
275
- out += `cwd: ${this.currentCwd}\n`;
276
- out += `session: ${totalCount} exchanges, ${elapsed}m elapsed\n`;
277
- for (const ex of exchanges) {
278
- out += "\n" + this.formatExchangeTruncated(ex);
279
- }
280
- // Allow extensions to inject extra context (e.g. terminal buffer snapshot)
281
- const extra = this.handlers?.call("context:build-extra");
282
- if (extra)
283
- out += "\n" + extra + "\n";
284
- out += "\n</shell_context>\n";
285
- return out;
286
- }
287
153
  // ── Internal helpers ──────────────────────────────────────────
288
154
  addExchange(partial) {
289
155
  const exchange = {
@@ -352,14 +218,11 @@ export class ContextManager {
352
218
  }
353
219
  }
354
220
  // ── Utility functions ─────────────────────────────────────────
355
- function truncateOutput(text, threshold, headLines, tailLines, id) {
356
- const lines = text.split("\n");
357
- if (lines.length <= threshold)
358
- return text;
221
+ function buildSpillStub(lines, headLines, tailLines, spillPath) {
359
222
  const omitted = lines.length - headLines - tailLines;
360
223
  return [
361
224
  ...lines.slice(0, headLines),
362
- `[... ${omitted} lines truncated, use shell_recall tool with expand and id ${id} to see full output ...]`,
225
+ `[... ${omitted} lines truncated full output at ${spillPath}; use read_file to expand ...]`,
363
226
  ...lines.slice(-tailLines),
364
227
  ].join("\n");
365
228
  }
@@ -238,7 +238,6 @@ export interface ShellEvents {
238
238
  active: string | null;
239
239
  };
240
240
  "config:changed": Record<string, never>;
241
- "config:cycle": Record<string, never>;
242
241
  "config:switch-model": {
243
242
  model: string;
244
243
  };
@@ -5,12 +5,33 @@ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
5
5
  const TS_EXTS = [".ts", ".tsx", ".mts"];
6
6
  const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
7
7
  let tsRegistered = false;
8
- async function ensureTsSupport() {
9
- if (tsRegistered)
8
+ let tsxUnregister = null;
9
+ /**
10
+ * Register tsx's ESM loader for .ts file support.
11
+ *
12
+ * Called before importing .ts extensions. The tsx loader uses Node's
13
+ * module.register() which creates a background thread with a MessageChannel.
14
+ * On reload, the old loader may become stale (the MessageChannel port can be
15
+ * GC'd or the loader thread can stop responding), so we unregister the old
16
+ * handle and re-register on each reload.
17
+ *
18
+ * Initial load: registers fresh.
19
+ * Reload: unregisters old handle, registers new one.
20
+ * Non-reload calls within the same load: no-op (tsRegistered guard).
21
+ */
22
+ async function ensureTsSupport(force = false) {
23
+ if (tsRegistered && !force)
10
24
  return;
11
25
  try {
26
+ // Unregister previous loader if reloading
27
+ if (tsxUnregister) {
28
+ try {
29
+ await tsxUnregister();
30
+ }
31
+ catch { /* ignore stale handle */ }
32
+ }
12
33
  const { register } = await import("tsx/esm/api");
13
- register();
34
+ tsxUnregister = register();
14
35
  tsRegistered = true;
15
36
  }
16
37
  catch {
@@ -166,7 +187,7 @@ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
166
187
  try {
167
188
  let importPath = await resolveSpecifier(specifier);
168
189
  if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
169
- await ensureTsSupport();
190
+ await ensureTsSupport(bustCache);
170
191
  }
171
192
  // Append timestamp query to bust Node's module cache on reload
172
193
  if (bustCache) {
@@ -1,6 +1,7 @@
1
1
  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
+ import { PACKAGE_VERSION } from "../utils/package-version.js";
4
5
  /** Read the user's persisted defaultModel for a provider, if any. */
5
6
  function persistedModelFor(providerName) {
6
7
  if (!providerName)
@@ -71,7 +72,7 @@ export default function agentBackend(ctx) {
71
72
  agentLoop.wire();
72
73
  bus.emit("agent:info", {
73
74
  name: "ash",
74
- version: "0.4",
75
+ version: PACKAGE_VERSION,
75
76
  model: llmClient.model,
76
77
  provider: modes[initialModeIndex]?.provider,
77
78
  contextWindow: modes[initialModeIndex]?.contextWindow,
@@ -181,7 +182,7 @@ export default function agentBackend(ctx) {
181
182
  };
182
183
  });
183
184
  bus.emit("config:set-modes", { modes: newModes });
184
- bus.emit("agent:info", { name: "ash", version: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
185
+ bus.emit("agent:info", { name: "ash", version: PACKAGE_VERSION, model: switchModel, provider: name, contextWindow: p.contextWindow });
185
186
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
186
187
  bus.emit("config:changed", {});
187
188
  });