agent-sh 0.2.0 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +21 -0
  2. package/dist/acp-client.d.ts +24 -0
  3. package/dist/acp-client.js +155 -33
  4. package/dist/context-manager.d.ts +5 -3
  5. package/dist/context-manager.js +62 -31
  6. package/dist/core.js +10 -0
  7. package/dist/event-bus.d.ts +26 -0
  8. package/dist/event-bus.js +10 -0
  9. package/dist/extension-loader.js +3 -14
  10. package/dist/extensions/shell-exec.js +27 -22
  11. package/dist/extensions/tui-renderer.d.ts +1 -1
  12. package/dist/extensions/tui-renderer.js +369 -126
  13. package/dist/index.js +184 -37
  14. package/dist/input-handler.d.ts +10 -0
  15. package/dist/input-handler.js +169 -10
  16. package/dist/mcp-server.js +37 -8
  17. package/dist/settings.d.ts +44 -0
  18. package/dist/settings.js +61 -0
  19. package/dist/shell.d.ts +1 -0
  20. package/dist/shell.js +44 -4
  21. package/dist/types.d.ts +17 -0
  22. package/dist/utils/ansi.d.ts +4 -1
  23. package/dist/utils/ansi.js +60 -2
  24. package/dist/utils/box-frame.js +2 -1
  25. package/dist/utils/diff-renderer.js +1 -1
  26. package/dist/utils/frame-renderer.d.ts +26 -0
  27. package/dist/utils/frame-renderer.js +76 -0
  28. package/dist/utils/handler-registry.d.ts +41 -0
  29. package/dist/utils/handler-registry.js +52 -0
  30. package/dist/utils/line-editor.d.ts +21 -1
  31. package/dist/utils/line-editor.js +193 -99
  32. package/dist/utils/markdown.d.ts +15 -6
  33. package/dist/utils/markdown.js +106 -67
  34. package/dist/utils/output-writer.d.ts +22 -0
  35. package/dist/utils/output-writer.js +29 -0
  36. package/dist/utils/stream-transform.d.ts +70 -0
  37. package/dist/utils/stream-transform.js +229 -0
  38. package/dist/utils/tool-display.d.ts +11 -8
  39. package/dist/utils/tool-display.js +69 -46
  40. package/examples/extensions/latex-images.ts +142 -0
  41. package/examples/pi-agent-sh.ts +166 -0
  42. package/package.json +10 -2
package/README.md CHANGED
@@ -77,6 +77,7 @@ See the [Usage Guide](docs/usage.md) for all options, model configuration, and e
77
77
  | `Ctrl-C` | Standard signal to shell, or cancels active agent response |
78
78
  | `Ctrl-O` | Expand/collapse truncated diff preview |
79
79
  | `Ctrl-T` | Toggle thinking/reasoning text display |
80
+ | `Shift-Tab` | Cycle thinking level (off → minimal → low → medium → high → xhigh) |
80
81
  | `Escape` | Exit agent input mode (when typing after `>`) |
81
82
 
82
83
  ### Agent Input Keybindings
@@ -85,6 +86,10 @@ When typing after `>`, full readline-style keybindings are available:
85
86
 
86
87
  | Key | Action |
87
88
  |---|---|
89
+ | `↑` / `↓` | Browse query history (persisted across sessions) |
90
+ | `Shift-Enter` | Insert newline (multiline input) |
91
+ | `Shift-Tab` | Cycle thinking level |
92
+ | `Ctrl-D` | Exit agent input mode (on empty line) |
88
93
  | `Ctrl-A` / `Home` | Move to start of line |
89
94
  | `Ctrl-E` / `End` | Move to end of line |
90
95
  | `Ctrl-B` / `←` | Move back one character |
@@ -96,6 +101,16 @@ When typing after `>`, full readline-style keybindings are available:
96
101
  | `Ctrl-W` / `Option-Backspace` | Delete word backward |
97
102
  | `Option-D` | Delete word forward |
98
103
 
104
+ ### Thinking Level
105
+
106
+ The agent prompt shows the current thinking level next to the model name:
107
+
108
+ ```
109
+ pi (claude-3.5-sonnet) [medium] ● ❯
110
+ ```
111
+
112
+ Press **Shift-Tab** in agent input mode to cycle through levels. The levels are advertised by the agent via ACP session modes — different agents may offer different options. The spinner label reflects the mode: "Thinking" when thinking is enabled, "Working" when it's off.
113
+
99
114
  ### Slash Commands
100
115
 
101
116
  | Command | Description |
@@ -106,6 +121,12 @@ When typing after `>`, full readline-style keybindings are available:
106
121
  | `/compact` | Ask agent to summarize the conversation |
107
122
  | `/quit` | Exit agent-sh |
108
123
 
124
+ ## Configuration
125
+
126
+ agent-sh stores settings and history in `~/.agent-sh/`. Behavior is configurable via `~/.agent-sh/settings.json` — context window size, truncation thresholds, display limits, and more. All fields are optional with sensible defaults.
127
+
128
+ See the [Usage Guide](docs/usage.md#configuration) for the full settings reference.
129
+
109
130
  ## Development
110
131
 
111
132
  ```bash
@@ -16,8 +16,11 @@ export declare class AcpClient {
16
16
  private terminalCounter;
17
17
  private fileWatcher;
18
18
  private pendingToolCalls;
19
+ private autoCancelled;
19
20
  private pendingToolCounter;
20
21
  private agentInfo;
22
+ private modes;
23
+ private currentModeId;
21
24
  constructor(opts: {
22
25
  bus: EventBus;
23
26
  contextManager: ContextManager;
@@ -28,6 +31,12 @@ export declare class AcpClient {
28
31
  * Send a user query to the agent.
29
32
  */
30
33
  sendPrompt(query: string): Promise<void>;
34
+ /**
35
+ * Silently cancel the prompt after a shell tool completes.
36
+ * Unlike user-initiated cancel(), this doesn't show "(cancelled)" —
37
+ * the tool already ran, we just skip the unnecessary LLM follow-up.
38
+ */
39
+ private autoCancel;
31
40
  /**
32
41
  * Cancel the current prompt and force-recover shell mode.
33
42
  */
@@ -48,10 +57,25 @@ export declare class AcpClient {
48
57
  version: string;
49
58
  } | null;
50
59
  getModel(): string | undefined;
60
+ /**
61
+ * Get the current mode (e.g. thinking level).
62
+ */
63
+ getCurrentMode(): {
64
+ id: string;
65
+ name: string;
66
+ } | null;
51
67
  /**
52
68
  * Check if agent is connected.
53
69
  */
54
70
  isConnected(): boolean;
71
+ /**
72
+ * Parse modes from a session response and notify listeners.
73
+ */
74
+ private updateModes;
75
+ /**
76
+ * Cycle to the next session mode.
77
+ */
78
+ private cycleMode;
55
79
  private log;
56
80
  /**
57
81
  * Create the Client handler that responds to agent requests.
@@ -22,8 +22,11 @@ export class AcpClient {
22
22
  terminalCounter = 0;
23
23
  fileWatcher;
24
24
  pendingToolCalls = new Map();
25
+ autoCancelled = false;
25
26
  pendingToolCounter = 0;
26
27
  agentInfo = null;
28
+ modes = [];
29
+ currentModeId = null;
27
30
  constructor(opts) {
28
31
  this.bus = opts.bus;
29
32
  this.contextManager = opts.contextManager;
@@ -33,8 +36,16 @@ export class AcpClient {
33
36
  async start() {
34
37
  this.log(`Starting agent: ${this.config.agentCommand} ${this.config.agentArgs.join(" ")}`);
35
38
  // Spawn the agent subprocess with the user's full shell environment
36
- // (includes vars from .zshrc/.bashrc that process.env may not have)
37
- const agentEnv = this.config.shellEnv ?? process.env;
39
+ // (includes vars from .zshrc/.bashrc that process.env may not have).
40
+ // Merge in any runtime env vars set by extensions (e.g. AGENT_SH_SOCKET)
41
+ // that weren't present when shellEnv was captured at startup.
42
+ const baseEnv = this.config.shellEnv ?? process.env;
43
+ const agentEnv = { ...baseEnv };
44
+ for (const [k, v] of Object.entries(process.env)) {
45
+ if (v !== undefined && !(k in agentEnv)) {
46
+ agentEnv[k] = v;
47
+ }
48
+ }
38
49
  this.agentProcess = spawn(this.config.agentCommand, this.config.agentArgs, {
39
50
  stdio: ["pipe", "pipe", process.env.DEBUG ? "inherit" : "ignore"],
40
51
  env: agentEnv,
@@ -67,19 +78,23 @@ export class AcpClient {
67
78
  this.log("Creating ACP connection");
68
79
  // Create the client-side connection, providing our Client handler
69
80
  this.connection = new acp.ClientSideConnection((_agent) => this.createClientHandler(), stream);
70
- // Initialize the connection
81
+ // Initialize the connection with timeout
71
82
  this.log("Sending initialize request");
72
- const initResponse = await this.connection.initialize({
73
- protocolVersion: acp.PROTOCOL_VERSION,
74
- clientInfo: { name: "agent-sh", version: "0.1.0" },
75
- clientCapabilities: {
76
- terminal: true,
77
- fs: {
78
- readTextFile: true,
79
- writeTextFile: true,
83
+ const initTimeoutMs = 30000; // 30 seconds
84
+ const initResponse = await Promise.race([
85
+ this.connection.initialize({
86
+ protocolVersion: acp.PROTOCOL_VERSION,
87
+ clientInfo: { name: "agent-sh", version: "0.1.0" },
88
+ clientCapabilities: {
89
+ terminal: true,
90
+ fs: {
91
+ readTextFile: true,
92
+ writeTextFile: true,
93
+ },
80
94
  },
81
- },
82
- });
95
+ }),
96
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Initialize timeout after ${initTimeoutMs}ms`)), initTimeoutMs)),
97
+ ]);
83
98
  this.log("Initialize successful");
84
99
  // Store agent info for display
85
100
  if (initResponse.agentInfo) {
@@ -96,12 +111,20 @@ export class AcpClient {
96
111
  cwd,
97
112
  mcpServers: [],
98
113
  });
99
- const sessionResponse = await this.connection.newSession({
100
- cwd: sessionConfig.cwd,
101
- mcpServers: sessionConfig.mcpServers,
102
- });
114
+ const sessionTimeoutMs = 30000; // 30 seconds
115
+ const sessionResponse = await Promise.race([
116
+ this.connection.newSession({
117
+ cwd: sessionConfig.cwd,
118
+ mcpServers: sessionConfig.mcpServers,
119
+ }),
120
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`newSession timeout after ${sessionTimeoutMs}ms`)), sessionTimeoutMs)),
121
+ ]);
103
122
  this.sessionId = sessionResponse.sessionId;
104
123
  this.log(`Session created: ${this.sessionId}`);
124
+ // Parse session modes (thinking level, etc.)
125
+ this.updateModes(sessionResponse);
126
+ // Listen for mode cycle requests from input handler
127
+ this.bus.on("config:cycle", () => this.cycleMode());
105
128
  }
106
129
  /**
107
130
  * Send a user query to the agent.
@@ -115,6 +138,7 @@ export class AcpClient {
115
138
  this.bus.emit("agent:processing-start", {});
116
139
  await this.fileWatcher.snapshot();
117
140
  this.currentResponseText = "";
141
+ this.autoCancelled = false;
118
142
  let cancelled = false;
119
143
  // Emit agent query event (TUI renders echo+spinner, ContextManager records it)
120
144
  this.bus.emit("agent:query", { query });
@@ -134,7 +158,9 @@ export class AcpClient {
134
158
  this.log(`prompt resolved: stopReason=${response.stopReason}`);
135
159
  if (response.stopReason === "cancelled") {
136
160
  cancelled = true;
137
- this.bus.emit("agent:cancelled", {});
161
+ if (!this.autoCancelled) {
162
+ this.bus.emit("agent:cancelled", {});
163
+ }
138
164
  }
139
165
  }
140
166
  catch (err) {
@@ -146,7 +172,7 @@ export class AcpClient {
146
172
  finally {
147
173
  this.log("restoring shell mode");
148
174
  if (!cancelled) {
149
- this.bus.emit("agent:response-done", {
175
+ this.bus.emitTransform("agent:response-done", {
150
176
  response: this.currentResponseText,
151
177
  });
152
178
  }
@@ -160,6 +186,18 @@ export class AcpClient {
160
186
  this.promptInProgress = false;
161
187
  }
162
188
  }
189
+ /**
190
+ * Silently cancel the prompt after a shell tool completes.
191
+ * Unlike user-initiated cancel(), this doesn't show "(cancelled)" —
192
+ * the tool already ran, we just skip the unnecessary LLM follow-up.
193
+ */
194
+ autoCancel() {
195
+ if (!this.connection || !this.sessionId || !this.promptInProgress)
196
+ return;
197
+ this.log("auto-cancel: shell tool completed, skipping LLM follow-up");
198
+ this.autoCancelled = true;
199
+ this.connection.cancel({ sessionId: this.sessionId }).catch(() => { });
200
+ }
163
201
  /**
164
202
  * Cancel the current prompt and force-recover shell mode.
165
203
  */
@@ -202,6 +240,7 @@ export class AcpClient {
202
240
  this.sessionId = sessionResponse.sessionId;
203
241
  this.lastResponseText = "";
204
242
  this.currentResponseText = "";
243
+ this.updateModes(sessionResponse);
205
244
  }
206
245
  /**
207
246
  * Get the text of the last agent response (for /copy).
@@ -218,6 +257,14 @@ export class AcpClient {
218
257
  getModel() {
219
258
  return this.config.model;
220
259
  }
260
+ /**
261
+ * Get the current mode (e.g. thinking level).
262
+ */
263
+ getCurrentMode() {
264
+ if (!this.currentModeId)
265
+ return null;
266
+ return this.modes.find((m) => m.id === this.currentModeId) ?? null;
267
+ }
221
268
  /**
222
269
  * Check if agent is connected.
223
270
  */
@@ -226,6 +273,45 @@ export class AcpClient {
226
273
  // Session ID may not be set yet if we're still initializing
227
274
  return this.connection !== null && this.agentInfo !== null;
228
275
  }
276
+ /**
277
+ * Parse modes from a session response and notify listeners.
278
+ */
279
+ updateModes(response) {
280
+ const modes = response.modes;
281
+ if (!modes)
282
+ return;
283
+ if (modes.availableModes) {
284
+ this.modes = modes.availableModes.map((m) => ({
285
+ id: m.id,
286
+ name: m.name || m.id,
287
+ }));
288
+ }
289
+ if (modes.currentModeId) {
290
+ this.currentModeId = modes.currentModeId;
291
+ }
292
+ this.bus.emit("config:changed", {});
293
+ }
294
+ /**
295
+ * Cycle to the next session mode.
296
+ */
297
+ async cycleMode() {
298
+ if (!this.connection || !this.sessionId || this.modes.length === 0)
299
+ return;
300
+ const currentIdx = this.modes.findIndex((m) => m.id === this.currentModeId);
301
+ const nextIdx = (currentIdx + 1) % this.modes.length;
302
+ const nextMode = this.modes[nextIdx];
303
+ try {
304
+ await this.connection.setSessionMode({
305
+ sessionId: this.sessionId,
306
+ modeId: nextMode.id,
307
+ });
308
+ this.currentModeId = nextMode.id;
309
+ this.bus.emit("config:changed", {});
310
+ }
311
+ catch (err) {
312
+ this.log(`Failed to set mode: ${err}`);
313
+ }
314
+ }
229
315
  log(msg) {
230
316
  if (process.env.DEBUG) {
231
317
  process.stderr.write(`[agent-sh] ${msg}\n`);
@@ -237,8 +323,15 @@ export class AcpClient {
237
323
  createClientHandler() {
238
324
  return {
239
325
  // Required: handle session update notifications (streaming)
326
+ // Errors must not propagate — the ACP SDK returns them as error
327
+ // responses to the agent, which can stall the stream.
240
328
  sessionUpdate: async (params) => {
241
- this.handleSessionUpdate(params);
329
+ try {
330
+ this.handleSessionUpdate(params);
331
+ }
332
+ catch (err) {
333
+ this.log(`Error in sessionUpdate handler: ${err instanceof Error ? err.stack : err}`);
334
+ }
242
335
  },
243
336
  // Required: handle permission requests
244
337
  requestPermission: async (params) => {
@@ -280,40 +373,56 @@ export class AcpClient {
280
373
  const content = update.content;
281
374
  if (content.type === "text") {
282
375
  this.currentResponseText += content.text;
283
- this.bus.emit("agent:response-chunk", { text: content.text });
376
+ this.bus.emitTransform("agent:response-chunk", { text: content.text });
284
377
  }
285
378
  break;
286
379
  }
287
380
  case "agent_thought_chunk": {
288
381
  const thought = update.content;
289
382
  if (thought.type === "text" && thought.text) {
290
- this.bus.emit("agent:thinking-chunk", { text: thought.text });
383
+ this.bus.emitTransform("agent:thinking-chunk", { text: thought.text });
291
384
  }
292
385
  break;
293
386
  }
294
387
  case "tool_call": {
295
388
  const toolId = update.toolCallId || `tool-${this.pendingToolCounter++}`;
296
- this.pendingToolCalls.set(toolId, true);
297
- this.bus.emit("agent:tool-started", {
389
+ const payload = {
298
390
  title: update.title,
299
391
  toolCallId: toolId,
300
392
  kind: update.kind ?? undefined,
301
393
  locations: update.locations?.map((l) => ({ path: l.path, line: l.line })),
302
394
  rawInput: update.rawInput,
395
+ };
396
+ const defer = this.pendingToolCalls.size > 0;
397
+ this.pendingToolCalls.set(toolId, {
398
+ title: update.title ?? "",
399
+ deferredPayload: defer ? payload : undefined,
303
400
  });
401
+ if (!defer) {
402
+ this.bus.emit("agent:tool-started", payload);
403
+ }
304
404
  break;
305
405
  }
306
406
  case "tool_call_update": {
307
- // Stream tool output content (text from pi's internal tool results)
308
- if (update.content && Array.isArray(update.content)) {
309
- for (const block of update.content) {
310
- if (block.type === "content" && block.content?.type === "text" && block.content.text) {
311
- this.bus.emit("agent:tool-output-chunk", { chunk: block.content.text });
407
+ const toolId = update.toolCallId;
408
+ const toolInfo = toolId ? this.pendingToolCalls.get(toolId) : undefined;
409
+ const toolTitle = toolInfo?.title;
410
+ if (update.status === "completed" || update.status === "failed") {
411
+ // Emit deferred tool-started before output (parallel tools)
412
+ if (toolInfo?.deferredPayload) {
413
+ this.bus.emit("agent:tool-started", toolInfo.deferredPayload);
414
+ toolInfo.deferredPayload = undefined;
415
+ }
416
+ // Show content only on final status. Skip tools whose output the
417
+ // user already sees (user_shell → PTY) or is agent-only (shell_recall).
418
+ const skipOutput = toolTitle === "user_shell" || toolTitle === "shell_recall";
419
+ if (!skipOutput && update.content && Array.isArray(update.content)) {
420
+ for (const block of update.content) {
421
+ if (block.type === "content" && block.content?.type === "text" && block.content.text) {
422
+ this.bus.emitTransform("agent:tool-output-chunk", { chunk: block.content.text });
423
+ }
312
424
  }
313
425
  }
314
- }
315
- if (update.status === "completed" || update.status === "failed") {
316
- const toolId = update.toolCallId;
317
426
  const exitCode = update.status === "completed" ? 0 : 1;
318
427
  if (toolId && this.pendingToolCalls.has(toolId)) {
319
428
  this.pendingToolCalls.delete(toolId);
@@ -326,11 +435,24 @@ export class AcpClient {
326
435
  else if (!toolId) {
327
436
  this.bus.emit("agent:tool-completed", { exitCode, rawOutput: update.rawOutput });
328
437
  }
438
+ // Auto-cancel after shell tools complete — the command already
439
+ // ran in the user's PTY, no need for a second LLM round trip.
440
+ // The result is captured in shell context / shell_recall.
441
+ if (toolTitle === "user_shell" && update.status === "completed") {
442
+ this.autoCancel();
443
+ }
444
+ }
445
+ break;
446
+ }
447
+ case "current_mode_update": {
448
+ const modeId = update.currentModeId;
449
+ if (modeId) {
450
+ this.currentModeId = modeId;
451
+ this.bus.emit("config:changed", {});
329
452
  }
330
453
  break;
331
454
  }
332
455
  default:
333
- // Ignore other update types for now
334
456
  break;
335
457
  }
336
458
  }
@@ -5,6 +5,8 @@ export declare class ContextManager {
5
5
  private currentCwd;
6
6
  private sessionStart;
7
7
  private pendingToolCalls;
8
+ private firstPrompt;
9
+ private agentShellActive;
8
10
  constructor(bus: EventBus);
9
11
  getCwd(): string;
10
12
  /**
@@ -17,9 +19,10 @@ export declare class ContextManager {
17
19
  */
18
20
  search(query: string): string;
19
21
  /**
20
- * Return full untruncated content for specific exchange IDs.
22
+ * Return content for specific exchange IDs.
23
+ * Optional start/end restrict to a line range (1-indexed).
21
24
  */
22
- expand(ids: number[]): string;
25
+ expand(ids: number[], start?: number, end?: number): string;
23
26
  /**
24
27
  * One-line summaries of last N exchanges.
25
28
  */
@@ -37,7 +40,6 @@ export declare class ContextManager {
37
40
  private formatContext;
38
41
  private addExchange;
39
42
  private formatExchangeTruncated;
40
- private truncateForRecall;
41
43
  private formatExchangeFull;
42
44
  private exchangeOneLiner;
43
45
  private exchangeSearchText;
@@ -1,21 +1,18 @@
1
- const DEFAULT_WINDOW_SIZE = 20;
2
- const DEFAULT_BUDGET = 16384; // ~4K tokens at ~4 chars/token
3
- // Truncation thresholds (in lines)
4
- const SHELL_TRUNCATE_THRESHOLD = 30;
5
- const SHELL_HEAD_LINES = 10;
6
- const SHELL_TAIL_LINES = 10;
1
+ import { getSettings } from "./settings.js";
2
+ // Non-configurable thresholds (agent response and tool output follow shell settings)
7
3
  const AGENT_RESPONSE_TRUNCATE_THRESHOLD = 20;
8
4
  const AGENT_RESPONSE_HEAD_LINES = 15;
9
5
  const TOOL_TRUNCATE_THRESHOLD = 20;
10
6
  const TOOL_HEAD_LINES = 5;
11
7
  const TOOL_TAIL_LINES = 5;
12
- const RECALL_EXPAND_MAX_LINES = 500;
13
8
  export class ContextManager {
14
9
  exchanges = [];
15
10
  nextId = 1;
16
11
  currentCwd;
17
12
  sessionStart;
18
13
  pendingToolCalls = [];
14
+ firstPrompt = true;
15
+ agentShellActive = false; // true while user_shell command is executing
19
16
  constructor(bus) {
20
17
  this.currentCwd = process.cwd();
21
18
  this.sessionStart = Date.now();
@@ -30,11 +27,15 @@ export class ContextManager {
30
27
  exitCode: e.exitCode,
31
28
  outputLines: lines.length,
32
29
  outputBytes: e.output.length,
30
+ source: this.agentShellActive ? "agent" : "user",
33
31
  });
34
32
  });
35
33
  bus.on("shell:cwd-change", (e) => {
36
34
  this.currentCwd = e.cwd;
37
35
  });
36
+ // Track agent-initiated shell commands (user_shell tool)
37
+ bus.on("shell:agent-exec-start", () => { this.agentShellActive = true; });
38
+ bus.on("shell:agent-exec-done", () => { this.agentShellActive = false; });
38
39
  // ── Subscribe to agent events ──
39
40
  bus.on("agent:query", (e) => {
40
41
  this.pendingToolCalls = [];
@@ -85,7 +86,8 @@ export class ContextManager {
85
86
  * Build the <shell_context> block for the agent prompt.
86
87
  * Pipeline: window → truncate → format
87
88
  */
88
- getContext(budget = DEFAULT_BUDGET) {
89
+ getContext(budget) {
90
+ budget ??= getSettings().contextBudget;
89
91
  let exchanges = this.applyWindow(this.exchanges);
90
92
  exchanges = this.applyTruncation(exchanges, budget);
91
93
  return this.formatContext(exchanges);
@@ -141,9 +143,10 @@ export class ContextManager {
141
143
  return parts.join("\n");
142
144
  }
143
145
  /**
144
- * Return full untruncated content for specific exchange IDs.
146
+ * Return content for specific exchange IDs.
147
+ * Optional start/end restrict to a line range (1-indexed).
145
148
  */
146
- expand(ids) {
149
+ expand(ids, start, end) {
147
150
  const results = [];
148
151
  for (const id of ids) {
149
152
  const ex = this.exchanges.find((e) => e.id === id);
@@ -151,7 +154,25 @@ export class ContextManager {
151
154
  results.push(`#${id}: not found`);
152
155
  continue;
153
156
  }
154
- results.push(this.formatExchangeFull(ex));
157
+ const text = this.formatExchangeFull(ex);
158
+ const lines = text.split("\n");
159
+ const total = lines.length;
160
+ if (start != null || end != null) {
161
+ // Line range requested
162
+ const s = Math.max(0, (start ?? 1) - 1);
163
+ const e = end ?? total;
164
+ results.push(lines.slice(s, e).join("\n") +
165
+ `\n[showing lines ${s + 1}-${Math.min(e, total)} of ${total}]`);
166
+ }
167
+ else if (total > getSettings().recallExpandMaxLines) {
168
+ // Too large — tell the agent to narrow down
169
+ results.push(`#${ex.id}: output is ${total} lines, too large to expand fully. ` +
170
+ `Use start/end params to select a line range (e.g. start=1, end=50), ` +
171
+ `or use search with a regex to find specific content.`);
172
+ }
173
+ else {
174
+ results.push(text);
175
+ }
155
176
  }
156
177
  return results.join("\n\n");
157
178
  }
@@ -205,10 +226,12 @@ export class ContextManager {
205
226
  clear() {
206
227
  this.exchanges = [];
207
228
  this.pendingToolCalls = [];
229
+ this.firstPrompt = true;
208
230
  // Don't reset nextId — IDs should be globally unique within a session
209
231
  }
210
232
  // ── Pipeline stages ───────────────────────────────────────────
211
- applyWindow(exchanges, windowSize = DEFAULT_WINDOW_SIZE) {
233
+ applyWindow(exchanges, windowSize) {
234
+ windowSize ??= getSettings().contextWindowSize;
212
235
  return exchanges.slice(-windowSize);
213
236
  }
214
237
  applyTruncation(exchanges, budget) {
@@ -217,7 +240,8 @@ export class ContextManager {
217
240
  // Pass 1: per-type truncation
218
241
  for (const ex of result) {
219
242
  if (ex.type === "shell_command") {
220
- ex.output = truncateOutput(ex.output, SHELL_TRUNCATE_THRESHOLD, SHELL_HEAD_LINES, SHELL_TAIL_LINES, ex.id);
243
+ const s = getSettings();
244
+ ex.output = truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id);
221
245
  }
222
246
  else if (ex.type === "agent_response") {
223
247
  ex.response = truncateHead(ex.response, AGENT_RESPONSE_TRUNCATE_THRESHOLD, AGENT_RESPONSE_HEAD_LINES, ex.id);
@@ -248,9 +272,22 @@ export class ContextManager {
248
272
  const elapsed = Math.round((Date.now() - this.sessionStart) / 60000);
249
273
  const totalCount = this.exchanges.length;
250
274
  let out = "<shell_context>\n";
275
+ if (this.firstPrompt) {
276
+ out += `You are an AI assistant living inside agent-sh, a shell-first terminal.\n`;
277
+ out += `The user interacts with a real shell (PTY) and sends you queries inline. You are there to help them with their tasks.\n`;
278
+ out += `\n`;
279
+ out += `IMPORTANT tool usage rules:\n`;
280
+ out += `- user_shell runs commands in the user's live shell (PTY). The user sees output directly — no summary needed.\n`;
281
+ out += `- Your internal tools (bash, read, write, ls, etc.) run in an isolated subprocess. The user CANNOT see their output.\n`;
282
+ out += `- When the user asks to see, list, view, or display anything, ALWAYS use user_shell. NEVER use internal tools like ls/read/bash for display — the user won't see it.\n`;
283
+ 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`;
284
+ out += `- After a user_shell command, the user already saw the output. Do NOT repeat or summarize it.\n`;
285
+ out += `- You can browse or search session history with shell_recall.\n`;
286
+ out += `\n`;
287
+ this.firstPrompt = false;
288
+ }
251
289
  out += `cwd: ${this.currentCwd}\n`;
252
290
  out += `session: ${totalCount} exchanges, ${elapsed}m elapsed\n`;
253
- out += `[hint: use the shell_recall tool to retrieve truncated content — search(query) or expand(ids)]\n`;
254
291
  for (const ex of exchanges) {
255
292
  out += "\n" + this.formatExchangeTruncated(ex);
256
293
  }
@@ -269,7 +306,8 @@ export class ContextManager {
269
306
  formatExchangeTruncated(ex) {
270
307
  switch (ex.type) {
271
308
  case "shell_command": {
272
- let s = `#${ex.id} [shell] $ ${ex.command}\n`;
309
+ const label = ex.source === "agent" ? "agent → shell" : "shell";
310
+ let s = `#${ex.id} [${label} cwd:${ex.cwd}] $ ${ex.command}\n`;
273
311
  if (ex.output)
274
312
  s += indent(ex.output, " ") + "\n";
275
313
  if (ex.exitCode !== null)
@@ -299,20 +337,12 @@ export class ContextManager {
299
337
  }
300
338
  }
301
339
  }
302
- truncateForRecall(text) {
303
- const lines = text.split("\n");
304
- if (lines.length <= RECALL_EXPAND_MAX_LINES)
305
- return text;
306
- const half = RECALL_EXPAND_MAX_LINES / 2;
307
- return (lines.slice(0, half).join("\n") +
308
- `\n[... ${lines.length - RECALL_EXPAND_MAX_LINES} more lines ...]\n` +
309
- lines.slice(-half).join("\n"));
310
- }
311
340
  formatExchangeFull(ex) {
312
341
  switch (ex.type) {
313
342
  case "shell_command": {
314
- const output = this.truncateForRecall(ex.output);
315
- let s = `#${ex.id} [shell] $ ${ex.command} (${ex.outputLines} lines, ${ex.outputBytes} bytes)\n`;
343
+ const label = ex.source === "agent" ? "agent → shell" : "shell";
344
+ const output = ex.output;
345
+ let s = `#${ex.id} [${label}] $ ${ex.command} (${ex.outputLines} lines, ${ex.outputBytes} bytes)\n`;
316
346
  if (output)
317
347
  s += output + "\n";
318
348
  if (ex.exitCode !== null)
@@ -324,10 +354,9 @@ export class ContextManager {
324
354
  case "agent_response":
325
355
  return `#${ex.id} [agent]\n${ex.response}`;
326
356
  case "tool_execution": {
327
- const output = this.truncateForRecall(ex.output);
328
357
  let s = `#${ex.id} [tool] ${ex.tool} (${ex.outputLines} lines, ${ex.outputBytes} bytes)\n`;
329
- if (output)
330
- s += output + "\n";
358
+ if (ex.output)
359
+ s += ex.output + "\n";
331
360
  if (ex.exitCode !== null)
332
361
  s += `exit ${ex.exitCode}\n`;
333
362
  return s;
@@ -336,8 +365,10 @@ export class ContextManager {
336
365
  }
337
366
  exchangeOneLiner(ex) {
338
367
  switch (ex.type) {
339
- case "shell_command":
340
- return `#${ex.id} shell: ${ex.command} (${ex.outputLines} lines, exit ${ex.exitCode ?? "?"})`;
368
+ case "shell_command": {
369
+ const label = ex.source === "agent" ? "agent shell" : "shell";
370
+ return `#${ex.id} ${label} [cwd:${ex.cwd}]: ${ex.command} (${ex.outputLines} total lines, exit ${ex.exitCode ?? "?"})`;
371
+ }
341
372
  case "agent_query":
342
373
  return `#${ex.id} query: ${ex.query}`;
343
374
  case "agent_response": {
package/dist/core.js CHANGED
@@ -20,11 +20,15 @@ import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
21
  import { AcpClient } from "./acp-client.js";
22
22
  import { setPalette } from "./utils/palette.js";
23
+ import * as streamTransform from "./utils/stream-transform.js";
24
+ import * as settingsMod from "./settings.js";
25
+ import { HandlerRegistry } from "./utils/handler-registry.js";
23
26
  // Re-export types that library consumers need
24
27
  export { EventBus } from "./event-bus.js";
25
28
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
26
29
  export function createCore(config) {
27
30
  const bus = new EventBus();
31
+ const handlers = new HandlerRegistry();
28
32
  const contextManager = new ContextManager(bus);
29
33
  const client = new AcpClient({ bus, contextManager, config });
30
34
  let connected = false;
@@ -67,6 +71,12 @@ export function createCore(config) {
67
71
  getAcpClient: () => client,
68
72
  quit: opts.quit,
69
73
  setPalette,
74
+ createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
75
+ createFencedBlockTransform: (o) => streamTransform.createFencedBlockTransform(bus, o),
76
+ getExtensionSettings: settingsMod.getExtensionSettings,
77
+ define: (name, fn) => handlers.define(name, fn),
78
+ advise: (name, wrapper) => handlers.advise(name, wrapper),
79
+ call: (name, ...args) => handlers.call(name, ...args),
70
80
  };
71
81
  },
72
82
  kill() {