agent-sh 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -576
- package/dist/acp-client.d.ts +24 -0
- package/dist/acp-client.js +168 -35
- package/dist/context-manager.d.ts +6 -4
- package/dist/context-manager.js +75 -44
- package/dist/event-bus.d.ts +29 -0
- package/dist/extension-loader.js +3 -14
- package/dist/extensions/shell-exec.d.ts +24 -0
- package/dist/extensions/shell-exec.js +188 -0
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +133 -28
- package/dist/index.js +195 -6
- package/dist/input-handler.d.ts +13 -3
- package/dist/input-handler.js +259 -127
- package/dist/mcp-server.d.ts +13 -0
- package/dist/mcp-server.js +234 -0
- package/dist/output-parser.d.ts +5 -26
- package/dist/output-parser.js +16 -78
- package/dist/settings.d.ts +33 -0
- package/dist/settings.js +43 -0
- package/dist/shell.d.ts +9 -4
- package/dist/shell.js +88 -10
- package/dist/types.d.ts +4 -0
- package/dist/utils/ansi.d.ts +4 -1
- package/dist/utils/ansi.js +60 -2
- package/dist/utils/line-editor.d.ts +59 -0
- package/dist/utils/line-editor.js +381 -0
- package/dist/utils/markdown.js +4 -4
- package/dist/utils/tool-display.d.ts +11 -0
- package/dist/utils/tool-display.js +92 -9
- package/examples/pi-agent-sh.ts +166 -0
- package/package.json +1 -1
package/dist/acp-client.d.ts
CHANGED
|
@@ -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.
|
package/dist/acp-client.js
CHANGED
|
@@ -21,9 +21,12 @@ export class AcpClient {
|
|
|
21
21
|
terminalDonePromises = new Map();
|
|
22
22
|
terminalCounter = 0;
|
|
23
23
|
fileWatcher;
|
|
24
|
-
pendingToolCalls = new Map();
|
|
24
|
+
pendingToolCalls = new Map(); // toolCallId → title
|
|
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;
|
|
@@ -32,10 +35,20 @@ export class AcpClient {
|
|
|
32
35
|
}
|
|
33
36
|
async start() {
|
|
34
37
|
this.log(`Starting agent: ${this.config.agentCommand} ${this.config.agentArgs.join(" ")}`);
|
|
35
|
-
// Spawn the agent subprocess
|
|
36
|
-
//
|
|
38
|
+
// Spawn the agent subprocess with the user's full shell environment
|
|
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
|
+
}
|
|
37
49
|
this.agentProcess = spawn(this.config.agentCommand, this.config.agentArgs, {
|
|
38
50
|
stdio: ["pipe", "pipe", process.env.DEBUG ? "inherit" : "ignore"],
|
|
51
|
+
env: agentEnv,
|
|
39
52
|
});
|
|
40
53
|
// Catch spawn errors (ENOENT, EACCES, etc.) before proceeding
|
|
41
54
|
await new Promise((resolve, reject) => {
|
|
@@ -65,19 +78,23 @@ export class AcpClient {
|
|
|
65
78
|
this.log("Creating ACP connection");
|
|
66
79
|
// Create the client-side connection, providing our Client handler
|
|
67
80
|
this.connection = new acp.ClientSideConnection((_agent) => this.createClientHandler(), stream);
|
|
68
|
-
// Initialize the connection
|
|
81
|
+
// Initialize the connection with timeout
|
|
69
82
|
this.log("Sending initialize request");
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
},
|
|
78
94
|
},
|
|
79
|
-
},
|
|
80
|
-
|
|
95
|
+
}),
|
|
96
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Initialize timeout after ${initTimeoutMs}ms`)), initTimeoutMs)),
|
|
97
|
+
]);
|
|
81
98
|
this.log("Initialize successful");
|
|
82
99
|
// Store agent info for display
|
|
83
100
|
if (initResponse.agentInfo) {
|
|
@@ -87,15 +104,27 @@ export class AcpClient {
|
|
|
87
104
|
};
|
|
88
105
|
this.log(`Agent info: ${this.agentInfo.name} v${this.agentInfo.version}`);
|
|
89
106
|
}
|
|
90
|
-
// Create a session
|
|
107
|
+
// Create a session — let extensions add MCP servers via pipe
|
|
91
108
|
const cwd = this.contextManager.getCwd();
|
|
92
109
|
this.log(`Creating new session with cwd: ${cwd}`);
|
|
93
|
-
const
|
|
110
|
+
const sessionConfig = this.bus.emitPipe("session:configure", {
|
|
94
111
|
cwd,
|
|
95
112
|
mcpServers: [],
|
|
96
113
|
});
|
|
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
|
+
]);
|
|
97
122
|
this.sessionId = sessionResponse.sessionId;
|
|
98
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());
|
|
99
128
|
}
|
|
100
129
|
/**
|
|
101
130
|
* Send a user query to the agent.
|
|
@@ -109,6 +138,7 @@ export class AcpClient {
|
|
|
109
138
|
this.bus.emit("agent:processing-start", {});
|
|
110
139
|
await this.fileWatcher.snapshot();
|
|
111
140
|
this.currentResponseText = "";
|
|
141
|
+
this.autoCancelled = false;
|
|
112
142
|
let cancelled = false;
|
|
113
143
|
// Emit agent query event (TUI renders echo+spinner, ContextManager records it)
|
|
114
144
|
this.bus.emit("agent:query", { query });
|
|
@@ -116,19 +146,25 @@ export class AcpClient {
|
|
|
116
146
|
const contextBlock = this.contextManager.getContext();
|
|
117
147
|
try {
|
|
118
148
|
this.log("sending prompt...");
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
prompt
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
const promptTimeoutMs = 300000; // 5 minutes timeout for LLM response
|
|
150
|
+
const response = await Promise.race([
|
|
151
|
+
this.connection.prompt({
|
|
152
|
+
sessionId: this.sessionId,
|
|
153
|
+
prompt: [
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: contextBlock + "\n" + query,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}),
|
|
160
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Prompt timeout after ${promptTimeoutMs}ms`)), promptTimeoutMs)),
|
|
161
|
+
]);
|
|
128
162
|
this.log(`prompt resolved: stopReason=${response.stopReason}`);
|
|
129
163
|
if (response.stopReason === "cancelled") {
|
|
130
164
|
cancelled = true;
|
|
131
|
-
this.
|
|
165
|
+
if (!this.autoCancelled) {
|
|
166
|
+
this.bus.emit("agent:cancelled", {});
|
|
167
|
+
}
|
|
132
168
|
}
|
|
133
169
|
}
|
|
134
170
|
catch (err) {
|
|
@@ -154,6 +190,18 @@ export class AcpClient {
|
|
|
154
190
|
this.promptInProgress = false;
|
|
155
191
|
}
|
|
156
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Silently cancel the prompt after a shell tool completes.
|
|
195
|
+
* Unlike user-initiated cancel(), this doesn't show "(cancelled)" —
|
|
196
|
+
* the tool already ran, we just skip the unnecessary LLM follow-up.
|
|
197
|
+
*/
|
|
198
|
+
autoCancel() {
|
|
199
|
+
if (!this.connection || !this.sessionId || !this.promptInProgress)
|
|
200
|
+
return;
|
|
201
|
+
this.log("auto-cancel: shell tool completed, skipping LLM follow-up");
|
|
202
|
+
this.autoCancelled = true;
|
|
203
|
+
this.connection.cancel({ sessionId: this.sessionId }).catch(() => { });
|
|
204
|
+
}
|
|
157
205
|
/**
|
|
158
206
|
* Cancel the current prompt and force-recover shell mode.
|
|
159
207
|
*/
|
|
@@ -185,13 +233,18 @@ export class AcpClient {
|
|
|
185
233
|
async resetSession() {
|
|
186
234
|
if (!this.connection)
|
|
187
235
|
return;
|
|
188
|
-
const
|
|
236
|
+
const sessionConfig = this.bus.emitPipe("session:configure", {
|
|
189
237
|
cwd: this.contextManager.getCwd(),
|
|
190
238
|
mcpServers: [],
|
|
191
239
|
});
|
|
240
|
+
const sessionResponse = await this.connection.newSession({
|
|
241
|
+
cwd: sessionConfig.cwd,
|
|
242
|
+
mcpServers: sessionConfig.mcpServers,
|
|
243
|
+
});
|
|
192
244
|
this.sessionId = sessionResponse.sessionId;
|
|
193
245
|
this.lastResponseText = "";
|
|
194
246
|
this.currentResponseText = "";
|
|
247
|
+
this.updateModes(sessionResponse);
|
|
195
248
|
}
|
|
196
249
|
/**
|
|
197
250
|
* Get the text of the last agent response (for /copy).
|
|
@@ -208,6 +261,14 @@ export class AcpClient {
|
|
|
208
261
|
getModel() {
|
|
209
262
|
return this.config.model;
|
|
210
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Get the current mode (e.g. thinking level).
|
|
266
|
+
*/
|
|
267
|
+
getCurrentMode() {
|
|
268
|
+
if (!this.currentModeId)
|
|
269
|
+
return null;
|
|
270
|
+
return this.modes.find((m) => m.id === this.currentModeId) ?? null;
|
|
271
|
+
}
|
|
211
272
|
/**
|
|
212
273
|
* Check if agent is connected.
|
|
213
274
|
*/
|
|
@@ -216,6 +277,45 @@ export class AcpClient {
|
|
|
216
277
|
// Session ID may not be set yet if we're still initializing
|
|
217
278
|
return this.connection !== null && this.agentInfo !== null;
|
|
218
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Parse modes from a session response and notify listeners.
|
|
282
|
+
*/
|
|
283
|
+
updateModes(response) {
|
|
284
|
+
const modes = response.modes;
|
|
285
|
+
if (!modes)
|
|
286
|
+
return;
|
|
287
|
+
if (modes.availableModes) {
|
|
288
|
+
this.modes = modes.availableModes.map((m) => ({
|
|
289
|
+
id: m.id,
|
|
290
|
+
name: m.name || m.id,
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
if (modes.currentModeId) {
|
|
294
|
+
this.currentModeId = modes.currentModeId;
|
|
295
|
+
}
|
|
296
|
+
this.bus.emit("config:changed", {});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Cycle to the next session mode.
|
|
300
|
+
*/
|
|
301
|
+
async cycleMode() {
|
|
302
|
+
if (!this.connection || !this.sessionId || this.modes.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
const currentIdx = this.modes.findIndex((m) => m.id === this.currentModeId);
|
|
305
|
+
const nextIdx = (currentIdx + 1) % this.modes.length;
|
|
306
|
+
const nextMode = this.modes[nextIdx];
|
|
307
|
+
try {
|
|
308
|
+
await this.connection.setSessionMode({
|
|
309
|
+
sessionId: this.sessionId,
|
|
310
|
+
modeId: nextMode.id,
|
|
311
|
+
});
|
|
312
|
+
this.currentModeId = nextMode.id;
|
|
313
|
+
this.bus.emit("config:changed", {});
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
this.log(`Failed to set mode: ${err}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
219
319
|
log(msg) {
|
|
220
320
|
if (process.env.DEBUG) {
|
|
221
321
|
process.stderr.write(`[agent-sh] ${msg}\n`);
|
|
@@ -282,29 +382,61 @@ export class AcpClient {
|
|
|
282
382
|
break;
|
|
283
383
|
}
|
|
284
384
|
case "tool_call": {
|
|
285
|
-
// Use toolCallId if available, otherwise generate a simple ID
|
|
286
385
|
const toolId = update.toolCallId || `tool-${this.pendingToolCounter++}`;
|
|
287
|
-
this.pendingToolCalls.set(toolId,
|
|
288
|
-
this.bus.emit("agent:tool-started", {
|
|
386
|
+
this.pendingToolCalls.set(toolId, update.title ?? "");
|
|
387
|
+
this.bus.emit("agent:tool-started", {
|
|
388
|
+
title: update.title,
|
|
389
|
+
toolCallId: toolId,
|
|
390
|
+
kind: update.kind ?? undefined,
|
|
391
|
+
locations: update.locations?.map((l) => ({ path: l.path, line: l.line })),
|
|
392
|
+
rawInput: update.rawInput,
|
|
393
|
+
});
|
|
289
394
|
break;
|
|
290
395
|
}
|
|
291
396
|
case "tool_call_update": {
|
|
292
|
-
|
|
397
|
+
const toolId = update.toolCallId;
|
|
398
|
+
const toolTitle = toolId ? this.pendingToolCalls.get(toolId) : undefined;
|
|
293
399
|
if (update.status === "completed" || update.status === "failed") {
|
|
294
|
-
|
|
400
|
+
// Show content only on final status. Skip tools whose output the
|
|
401
|
+
// user already sees (user_shell → PTY) or is agent-only (shell_recall).
|
|
402
|
+
const skipOutput = toolTitle === "user_shell" || toolTitle === "shell_recall";
|
|
403
|
+
if (!skipOutput && update.content && Array.isArray(update.content)) {
|
|
404
|
+
for (const block of update.content) {
|
|
405
|
+
if (block.type === "content" && block.content?.type === "text" && block.content.text) {
|
|
406
|
+
this.bus.emit("agent:tool-output-chunk", { chunk: block.content.text });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
295
410
|
const exitCode = update.status === "completed" ? 0 : 1;
|
|
296
411
|
if (toolId && this.pendingToolCalls.has(toolId)) {
|
|
297
412
|
this.pendingToolCalls.delete(toolId);
|
|
298
|
-
this.bus.emit("agent:tool-completed", {
|
|
413
|
+
this.bus.emit("agent:tool-completed", {
|
|
414
|
+
toolCallId: toolId,
|
|
415
|
+
exitCode,
|
|
416
|
+
rawOutput: update.rawOutput,
|
|
417
|
+
});
|
|
299
418
|
}
|
|
300
419
|
else if (!toolId) {
|
|
301
|
-
this.bus.emit("agent:tool-completed", { exitCode });
|
|
420
|
+
this.bus.emit("agent:tool-completed", { exitCode, rawOutput: update.rawOutput });
|
|
421
|
+
}
|
|
422
|
+
// Auto-cancel after shell tools complete — the command already
|
|
423
|
+
// ran in the user's PTY, no need for a second LLM round trip.
|
|
424
|
+
// The result is captured in shell context / shell_recall.
|
|
425
|
+
if (toolTitle === "user_shell" && update.status === "completed") {
|
|
426
|
+
this.autoCancel();
|
|
302
427
|
}
|
|
303
428
|
}
|
|
304
429
|
break;
|
|
305
430
|
}
|
|
431
|
+
case "current_mode_update": {
|
|
432
|
+
const modeId = update.currentModeId;
|
|
433
|
+
if (modeId) {
|
|
434
|
+
this.currentModeId = modeId;
|
|
435
|
+
this.bus.emit("config:changed", {});
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
306
439
|
default:
|
|
307
|
-
// Ignore other update types for now
|
|
308
440
|
break;
|
|
309
441
|
}
|
|
310
442
|
}
|
|
@@ -363,6 +495,7 @@ export class AcpClient {
|
|
|
363
495
|
const { session, done } = executeCommand({
|
|
364
496
|
command: fullCommand,
|
|
365
497
|
cwd,
|
|
498
|
+
env: this.config.shellEnv,
|
|
366
499
|
timeout: 60_000,
|
|
367
500
|
maxOutputBytes: 256 * 1024,
|
|
368
501
|
onOutput: (chunk) => {
|
|
@@ -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,15 +19,16 @@ export declare class ContextManager {
|
|
|
17
19
|
*/
|
|
18
20
|
search(query: string): string;
|
|
19
21
|
/**
|
|
20
|
-
* Return
|
|
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
|
*/
|
|
26
29
|
getRecentSummary(n?: number): string;
|
|
27
30
|
/**
|
|
28
|
-
* Parse and handle
|
|
31
|
+
* Parse and handle shell_recall commands.
|
|
29
32
|
*/
|
|
30
33
|
handleRecallCommand(command: string): string;
|
|
31
34
|
/**
|
|
@@ -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;
|
package/dist/context-manager.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -165,21 +186,21 @@ export class ContextManager {
|
|
|
165
186
|
return recent.map((ex) => this.exchangeOneLiner(ex)).join("\n");
|
|
166
187
|
}
|
|
167
188
|
/**
|
|
168
|
-
* Parse and handle
|
|
189
|
+
* Parse and handle shell_recall commands.
|
|
169
190
|
*/
|
|
170
191
|
handleRecallCommand(command) {
|
|
171
|
-
const args = command.replace(/^
|
|
192
|
+
const args = command.replace(/^_*shell_recall\s*/, "").trim();
|
|
172
193
|
if (!args || args === "--help") {
|
|
173
194
|
return [
|
|
174
195
|
"Usage:",
|
|
175
|
-
"
|
|
176
|
-
"
|
|
177
|
-
"
|
|
196
|
+
" shell_recall Browse recent exchanges",
|
|
197
|
+
" shell_recall --search <query> Search all exchanges",
|
|
198
|
+
" shell_recall --expand <id,...> Show full content of exchanges",
|
|
178
199
|
"",
|
|
179
200
|
"Examples:",
|
|
180
|
-
'
|
|
181
|
-
"
|
|
182
|
-
"
|
|
201
|
+
' shell_recall --search "test fail"',
|
|
202
|
+
" shell_recall --expand 41",
|
|
203
|
+
" shell_recall --expand 41,42,43",
|
|
183
204
|
].join("\n");
|
|
184
205
|
}
|
|
185
206
|
const searchMatch = args.match(/^--search\s+(?:"([^"]+)"|(\S+))/);
|
|
@@ -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
|
|
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
|
-
|
|
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);
|
|
@@ -232,13 +256,13 @@ export class ContextManager {
|
|
|
232
256
|
const ex = result[i];
|
|
233
257
|
const before = this.exchangeSize(ex);
|
|
234
258
|
if (ex.type === "shell_command") {
|
|
235
|
-
ex.output = `[output omitted, use
|
|
259
|
+
ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
|
|
236
260
|
}
|
|
237
261
|
else if (ex.type === "tool_execution") {
|
|
238
|
-
ex.output = `[output omitted, use
|
|
262
|
+
ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
|
|
239
263
|
}
|
|
240
264
|
else if (ex.type === "agent_response") {
|
|
241
|
-
ex.response = `[response omitted, use
|
|
265
|
+
ex.response = `[response omitted, use shell_recall tool to expand id ${ex.id}]`;
|
|
242
266
|
}
|
|
243
267
|
totalSize -= before - this.exchangeSize(ex);
|
|
244
268
|
}
|
|
@@ -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: run \`__shell_recall --search "query"\` or \`__shell_recall --expand ID\` to retrieve truncated content]\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
|
-
|
|
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
|
|
315
|
-
|
|
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
|
-
|
|
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": {
|
|
@@ -381,7 +412,7 @@ function truncateOutput(text, threshold, headLines, tailLines, id) {
|
|
|
381
412
|
const omitted = lines.length - headLines - tailLines;
|
|
382
413
|
return [
|
|
383
414
|
...lines.slice(0, headLines),
|
|
384
|
-
`[... ${omitted} lines truncated, use
|
|
415
|
+
`[... ${omitted} lines truncated, use shell_recall tool with expand and id ${id} to see full output ...]`,
|
|
385
416
|
...lines.slice(-tailLines),
|
|
386
417
|
].join("\n");
|
|
387
418
|
}
|
|
@@ -391,7 +422,7 @@ function truncateHead(text, threshold, headLines, id) {
|
|
|
391
422
|
return text;
|
|
392
423
|
return [
|
|
393
424
|
...lines.slice(0, headLines),
|
|
394
|
-
`[... truncated, use
|
|
425
|
+
`[... truncated, use shell_recall tool with expand and id ${id} for full response ...]`,
|
|
395
426
|
].join("\n");
|
|
396
427
|
}
|
|
397
428
|
function indent(text, prefix) {
|