agent-sh 0.4.0 → 0.6.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.
Files changed (83) hide show
  1. package/README.md +37 -115
  2. package/dist/agent/agent-loop.d.ts +86 -0
  3. package/dist/agent/agent-loop.js +704 -0
  4. package/dist/agent/conversation-state.d.ts +27 -0
  5. package/dist/agent/conversation-state.js +59 -0
  6. package/dist/agent/index.d.ts +11 -0
  7. package/dist/agent/index.js +9 -0
  8. package/dist/agent/skills.d.ts +25 -0
  9. package/dist/agent/skills.js +186 -0
  10. package/dist/agent/subagent.d.ts +37 -0
  11. package/dist/agent/subagent.js +119 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +103 -0
  14. package/dist/agent/tool-registry.d.ts +15 -0
  15. package/dist/agent/tool-registry.js +30 -0
  16. package/dist/agent/tools/bash.d.ts +7 -0
  17. package/dist/agent/tools/bash.js +71 -0
  18. package/dist/agent/tools/display.d.ts +13 -0
  19. package/dist/agent/tools/display.js +70 -0
  20. package/dist/agent/tools/edit-file.d.ts +2 -0
  21. package/dist/agent/tools/edit-file.js +148 -0
  22. package/dist/agent/tools/glob.d.ts +2 -0
  23. package/dist/agent/tools/glob.js +87 -0
  24. package/dist/agent/tools/grep.d.ts +2 -0
  25. package/dist/agent/tools/grep.js +168 -0
  26. package/dist/agent/tools/list-skills.d.ts +2 -0
  27. package/dist/agent/tools/list-skills.js +28 -0
  28. package/dist/agent/tools/ls.d.ts +2 -0
  29. package/dist/agent/tools/ls.js +72 -0
  30. package/dist/agent/tools/read-file.d.ts +10 -0
  31. package/dist/agent/tools/read-file.js +101 -0
  32. package/dist/agent/tools/user-shell.d.ts +13 -0
  33. package/dist/agent/tools/user-shell.js +84 -0
  34. package/dist/agent/tools/write-file.d.ts +2 -0
  35. package/dist/agent/tools/write-file.js +82 -0
  36. package/dist/agent/types.d.ts +78 -0
  37. package/dist/agent/types.js +1 -0
  38. package/dist/core.d.ts +22 -14
  39. package/dist/core.js +256 -36
  40. package/dist/event-bus.d.ts +98 -17
  41. package/dist/event-bus.js +10 -1
  42. package/dist/extension-loader.d.ts +1 -1
  43. package/dist/extension-loader.js +10 -1
  44. package/dist/extensions/command-suggest.d.ts +10 -0
  45. package/dist/extensions/command-suggest.js +41 -0
  46. package/dist/extensions/slash-commands.d.ts +1 -1
  47. package/dist/extensions/slash-commands.js +161 -64
  48. package/dist/extensions/tui-renderer.js +426 -126
  49. package/dist/index.js +110 -129
  50. package/dist/input-handler.js +78 -9
  51. package/dist/output-parser.d.ts +7 -0
  52. package/dist/output-parser.js +27 -0
  53. package/dist/settings.d.ts +53 -2
  54. package/dist/settings.js +46 -3
  55. package/dist/shell.js +35 -28
  56. package/dist/types.d.ts +33 -6
  57. package/dist/utils/box-frame.d.ts +3 -1
  58. package/dist/utils/box-frame.js +12 -5
  59. package/dist/utils/diff.js +10 -0
  60. package/dist/utils/llm-client.d.ts +45 -0
  61. package/dist/utils/llm-client.js +60 -0
  62. package/dist/utils/markdown.d.ts +1 -0
  63. package/dist/utils/markdown.js +25 -3
  64. package/dist/utils/stream-transform.js +20 -47
  65. package/dist/utils/tool-display.d.ts +4 -0
  66. package/dist/utils/tool-display.js +35 -8
  67. package/examples/extensions/claude-code-bridge/README.md +35 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +194 -0
  69. package/examples/extensions/claude-code-bridge/package.json +11 -0
  70. package/examples/extensions/openrouter.ts +87 -0
  71. package/examples/extensions/pi-bridge/README.md +35 -0
  72. package/examples/extensions/pi-bridge/index.ts +263 -0
  73. package/examples/extensions/pi-bridge/package.json +13 -0
  74. package/examples/extensions/secret-guard.ts +100 -0
  75. package/examples/extensions/subagents.ts +87 -0
  76. package/package.json +3 -5
  77. package/dist/acp-client.d.ts +0 -105
  78. package/dist/acp-client.js +0 -684
  79. package/dist/extensions/shell-exec.d.ts +0 -24
  80. package/dist/extensions/shell-exec.js +0 -188
  81. package/dist/mcp-server.d.ts +0 -13
  82. package/dist/mcp-server.js +0 -234
  83. package/examples/pi-agent-sh.ts +0 -166
package/dist/index.js CHANGED
@@ -8,16 +8,14 @@ import tuiRenderer from "./extensions/tui-renderer.js";
8
8
  import slashCommands from "./extensions/slash-commands.js";
9
9
  import fileAutocomplete from "./extensions/file-autocomplete.js";
10
10
  import shellRecall from "./extensions/shell-recall.js";
11
- import shellExec from "./extensions/shell-exec.js";
11
+ import commandSuggest from "./extensions/command-suggest.js";
12
12
  import { loadExtensions } from "./extension-loader.js";
13
+ import { getSettings } from "./settings.js";
14
+ import { discoverSkills } from "./agent/skills.js";
13
15
  /**
14
16
  * Capture the user's full shell environment.
15
17
  * This picks up env vars exported in .zshrc/.bashrc that the
16
18
  * Node.js process doesn't have (e.g. when launched from an IDE).
17
- *
18
- * Uses -l (login shell) to get .zprofile/.bash_profile vars, then
19
- * explicitly sources the interactive rc file (.zshrc/.bashrc) which
20
- * -l alone doesn't load (that requires -i, which blocks on TTY).
21
19
  */
22
20
  async function captureShellEnvAsync(shell) {
23
21
  return new Promise((resolve) => {
@@ -37,7 +35,7 @@ async function captureShellEnvAsync(shell) {
37
35
  });
38
36
  child.on("close", (code) => {
39
37
  if (code !== 0 || !output) {
40
- resolve({}); // Return empty to trigger fallback
38
+ resolve({});
41
39
  return;
42
40
  }
43
41
  const env = {};
@@ -49,9 +47,8 @@ async function captureShellEnvAsync(shell) {
49
47
  resolve(env);
50
48
  });
51
49
  child.on("error", () => {
52
- resolve({}); // Return empty to trigger fallback
50
+ resolve({});
53
51
  });
54
- // Safety timeout
55
52
  setTimeout(() => {
56
53
  child.kill("SIGTERM");
57
54
  resolve({});
@@ -62,14 +59,9 @@ async function captureShellEnvAsync(shell) {
62
59
  }
63
60
  });
64
61
  }
65
- /**
66
- * Merge captured shell env into base env, only adding keys that don't exist.
67
- * This preserves any runtime modifications while adding missing shell vars.
68
- */
69
62
  function mergeShellEnv(baseEnv, shellEnv) {
70
63
  const merged = { ...baseEnv };
71
64
  for (const [key, value] of Object.entries(shellEnv)) {
72
- // Only add if key doesn't exist or is empty in base env
73
65
  if (!(key in merged) || !merged[key]) {
74
66
  merged[key] = value;
75
67
  }
@@ -77,113 +69,95 @@ function mergeShellEnv(baseEnv, shellEnv) {
77
69
  return merged;
78
70
  }
79
71
  function parseArgs(argv) {
80
- // Priority: CLI args > Environment variables > Config file > Defaults
81
- const defaultAgent = process.env.AGENT_SH_AGENT || "pi-acp";
82
- let agentCommand = defaultAgent;
83
- let agentArgs = [];
84
72
  let model;
85
73
  let extensions;
74
+ let provider;
86
75
  const shell = process.env.SHELL || "/bin/bash";
76
+ let apiKey = process.env.OPENAI_API_KEY;
77
+ let baseURL = process.env.OPENAI_BASE_URL;
87
78
  for (let i = 0; i < argv.length; i++) {
88
79
  const arg = argv[i];
89
- if (arg === "--agent" && argv[i + 1]) {
90
- agentCommand = argv[++i];
80
+ if (arg === "--model" && argv[i + 1]) {
81
+ model = argv[++i];
91
82
  }
92
- else if (arg === "--agent-args" && argv[i + 1]) {
93
- const argsString = argv[++i];
94
- agentArgs = argsString.split(" ");
95
- // Extract model from agent args if provided
96
- const modelArgIndex = agentArgs.findIndex(a => a === "--model" || a === "-m");
97
- if (modelArgIndex !== -1 && agentArgs[modelArgIndex + 1]) {
98
- model = agentArgs[modelArgIndex + 1];
99
- }
83
+ else if (arg === "--api-key" && argv[i + 1]) {
84
+ apiKey = argv[++i];
85
+ }
86
+ else if (arg === "--base-url" && argv[i + 1]) {
87
+ baseURL = argv[++i];
88
+ }
89
+ else if (arg === "--provider" && argv[i + 1]) {
90
+ provider = argv[++i];
100
91
  }
101
92
  else if (arg === "--shell" && argv[i + 1]) {
102
- return { agentCommand, agentArgs, shell: argv[++i], model, extensions };
93
+ return { shell: argv[++i], model, extensions, apiKey, baseURL, provider };
103
94
  }
104
95
  else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
105
96
  const exts = argv[++i].split(",").map(s => s.trim());
106
97
  extensions = extensions ? [...extensions, ...exts] : exts;
107
98
  }
108
99
  else if (arg === "--help" || arg === "-h") {
109
- console.log(`agent-sh — a shell-first terminal with ACP agent access
100
+ console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
110
101
 
111
102
  Usage: agent-sh [options]
112
103
 
113
- Quick Start:
114
- npm start Start with default agent (pi-acp)
115
- npm run pi Start with pi-acp agent
116
- npm run claude Start with Claude agent
104
+ Provider Profiles:
105
+ --provider <name> Use a provider from ~/.agent-sh/settings.json
106
+ --model <name> Override default model
107
+
108
+ Direct LLM API:
109
+ --api-key <key> API key for OpenAI-compatible provider (or set OPENAI_API_KEY)
110
+ --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
117
111
 
118
- Options:
119
- --agent <cmd> Agent command to launch (default: $AGENT_SH_AGENT or "pi-acp")
120
- --agent-args <args> Arguments for the agent (space-separated, quoted)
112
+ General Options:
121
113
  --shell <path> Shell to use (default: $SHELL or /bin/bash)
122
114
  -e, --extensions Extensions to load (comma-separated, repeatable)
123
115
  -h, --help Show this help
124
116
 
125
- Extensions:
126
- Extensions are loaded from (in order):
127
- 1. -e flags: npm packages or file paths
128
- 2. settings: ~/.agent-sh/settings.json → "extensions": [...]
129
- 3. directory: ~/.agent-sh/extensions/ (files or dirs with index.ts)
130
-
131
117
  Environment Variables:
132
- AGENT_SH_AGENT Default agent to use (e.g., "pi-acp", "claude")
118
+ OPENAI_API_KEY API key for LLM provider
119
+ OPENAI_BASE_URL Base URL override (e.g., http://localhost:11434/v1 for Ollama)
133
120
 
134
121
  Examples:
135
- npm start --agent pi-acp
136
- npm start -- -e my-extension-package
137
- npm start -- -e ./local-ext.ts -e another-package
122
+ # Use a configured provider
123
+ agent-sh --provider openai
124
+
125
+ # Direct API access
126
+ agent-sh --api-key "$KEY" --model gpt-4o
127
+
128
+ # Local model via Ollama
129
+ agent-sh --base-url http://localhost:11434/v1 --model llama3
138
130
 
139
131
  Inside the shell:
140
132
  Type normally Commands run in your real shell
141
- > <query> Send query to the AI agent
133
+ > <query> Ask the AI agent (it decides how to help)
142
134
  > /help Show available slash commands
143
135
  Ctrl-C Cancel agent response (or signal shell as usual)
144
136
  `);
145
137
  process.exit(0);
146
138
  }
147
139
  }
148
- return { agentCommand, agentArgs, shell, model, extensions };
149
- }
150
- function formatAgentInfo(agentInfo, model, thoughtLevel) {
151
- const name = agentInfo.name.replace(/-acp$/, "").replace(/-/g, " ");
152
- let infoStr = `${p.dim}${name}${p.reset}`;
153
- if (model) {
154
- const cleanModel = model
155
- .replace(/^openai\//i, "")
156
- .replace(/^anthropic\//i, "")
157
- .replace(/^google\//i, "");
158
- infoStr += ` ${p.dim}(${cleanModel})${p.reset}`;
159
- }
160
- if (thoughtLevel) {
161
- // Clean up verbose mode names like "Thinking: medium" → "medium"
162
- const label = thoughtLevel.replace(/^Thinking:\s*/i, "");
163
- infoStr += ` ${p.dim}[${label}]${p.reset}`;
164
- }
165
- return infoStr;
140
+ return { shell, model, extensions, apiKey, baseURL, provider };
166
141
  }
167
142
  async function main() {
168
- // Set up signal handlers before any terminal operations.
169
- // Ignore SIGTTOU to prevent suspension when modifying terminal settings.
143
+ if (process.env.AGENT_SH) {
144
+ console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
145
+ process.exit(1);
146
+ }
170
147
  process.on("SIGTTOU", () => { });
171
- // Also ignore SIGTTIN which can occur when reading from terminal while backgrounded.
172
148
  process.on("SIGTTIN", () => { });
173
149
  const config = parseArgs(process.argv.slice(2));
174
- // Capture user's full shell environment (from .zshrc/.bashrc etc.)
175
- // This must complete before spawning the agent so it sees all env vars.
150
+ // Capture user's full shell environment
176
151
  const baseEnv = {};
177
152
  for (const [k, v] of Object.entries(process.env)) {
178
153
  if (v !== undefined)
179
154
  baseEnv[k] = v;
180
155
  }
181
- config.shellEnv = baseEnv;
182
156
  const shellPath = config.shell || process.env.SHELL || "/bin/bash";
183
157
  try {
184
158
  const shellEnv = await captureShellEnvAsync(shellPath);
185
159
  if (Object.keys(shellEnv).length > 0) {
186
- config.shellEnv = mergeShellEnv(config.shellEnv, shellEnv);
160
+ Object.assign(baseEnv, mergeShellEnv(baseEnv, shellEnv));
187
161
  if (process.env.DEBUG) {
188
162
  console.error('[agent-sh] Shell environment captured');
189
163
  }
@@ -194,7 +168,10 @@ async function main() {
194
168
  }
195
169
  // ── Core (frontend-agnostic) ──────────────────────────────────
196
170
  const core = createCore(config);
197
- const { bus, client } = core;
171
+ const { bus } = core;
172
+ // Track agent info from bus events (populated by extension backends)
173
+ let agentInfo = null;
174
+ bus.on("agent:info", (info) => { agentInfo = info; });
198
175
  // ── Interactive frontend ──────────────────────────────────────
199
176
  if (process.env.DEBUG) {
200
177
  console.error('[agent-sh] Setting up interactive frontend...');
@@ -213,8 +190,6 @@ async function main() {
213
190
  if (process.env.DEBUG) {
214
191
  console.error('[agent-sh] Creating Shell...');
215
192
  }
216
- // Small delay on macOS to ensure we're fully in the foreground process group
217
- // before spawning the PTY. This prevents SIGTTOU suspension.
218
193
  await new Promise(resolve => setTimeout(resolve, 100));
219
194
  const shell = new Shell({
220
195
  bus,
@@ -223,13 +198,11 @@ async function main() {
223
198
  shell: config.shell || process.env.SHELL || "/bin/bash",
224
199
  cwd: process.cwd(),
225
200
  onShowAgentInfo: () => {
226
- if (client.isConnected()) {
227
- const agentInfo = client.getAgentInfo();
228
- const model = client.getModel();
229
- if (agentInfo) {
230
- const mode = client.getCurrentMode();
231
- return { info: formatAgentInfo(agentInfo, model, mode?.name ?? null) };
232
- }
201
+ if (agentInfo) {
202
+ return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
203
+ }
204
+ if (core.llmClient) {
205
+ return { info: `${p.dim}agent-sh (${core.llmClient.model})${p.reset}` };
233
206
  }
234
207
  return { info: "" };
235
208
  },
@@ -237,28 +210,17 @@ async function main() {
237
210
  if (process.env.DEBUG) {
238
211
  console.error('[agent-sh] Shell created');
239
212
  }
240
- // ── Input modes ──────────────────────────────────────────────
213
+ // ── Input mode ───────────────────────────────────────────────
241
214
  bus.emit("input-mode:register", {
242
- id: "query",
243
- trigger: "?",
244
- label: "query",
245
- promptIcon: "❯",
246
- indicator: "❓",
247
- onSubmit(query, b) {
248
- b.emit("agent:submit", { query, modeLabel: "Query", modeInstruction: "[mode: query]" });
249
- },
250
- returnToSelf: true,
251
- });
252
- bus.emit("input-mode:register", {
253
- id: "execute",
215
+ id: "agent",
254
216
  trigger: ">",
255
- label: "execute",
256
- promptIcon: "",
217
+ label: "agent",
218
+ promptIcon: "",
257
219
  indicator: "●",
258
220
  onSubmit(query, b) {
259
- b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
221
+ b.emit("agent:submit", { query });
260
222
  },
261
- returnToSelf: false,
223
+ returnToSelf: true,
262
224
  });
263
225
  // ── Extensions ────────────────────────────────────────────────
264
226
  if (process.env.DEBUG) {
@@ -269,22 +231,15 @@ async function main() {
269
231
  slashCommands(extCtx);
270
232
  fileAutocomplete(extCtx);
271
233
  shellRecall(extCtx);
272
- // Shell-exec: start the Unix domain socket bridge so agent extensions
273
- // and MCP servers can route tool calls to the PTY via the EventBus.
274
- const tmpDir = shell.getTmpDir();
275
- if (tmpDir) {
276
- if (process.env.DEBUG) {
277
- console.error('[agent-sh] Starting shell-exec socket server...');
278
- }
279
- shellExec(extCtx, { socketPath: `${tmpDir}/shell.sock` });
280
- }
281
- // Load extensions with timeout to prevent blocking startup
234
+ commandSuggest(extCtx);
235
+ // Load user extensions (may register alternative agent backends)
282
236
  if (process.env.DEBUG) {
283
237
  console.error('[agent-sh] Loading extensions...');
284
238
  }
285
- const loadExtensionsTimeoutMs = 10000; // 10 seconds
239
+ const loadExtensionsTimeoutMs = 10000;
240
+ let loadedExtensions = [];
286
241
  await Promise.race([
287
- loadExtensions(extCtx, config.extensions),
242
+ loadExtensions(extCtx, config.extensions).then((names) => { loadedExtensions = names; }),
288
243
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Extension loading timeout after ${loadExtensionsTimeoutMs}ms`)), loadExtensionsTimeoutMs)),
289
244
  ]).catch((err) => {
290
245
  console.error(`Warning: ${err.message}`);
@@ -292,21 +247,54 @@ async function main() {
292
247
  if (process.env.DEBUG) {
293
248
  console.error('[agent-sh] Extensions loaded');
294
249
  }
295
- // ── Agent connection (async — don't block shell startup) ──────
296
- const agentStartTimeoutMs = 35000; // 35 seconds (slightly longer than internal timeouts)
297
- Promise.race([
298
- core.start(),
299
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Agent connection timeout`)), agentStartTimeoutMs)),
300
- ]).catch((err) => {
301
- console.error(`Failed to connect to ${config.agentCommand}:`, err);
302
- });
250
+ // ── Discover skills ───────────────────────────────────────────
251
+ const skills = discoverSkills(process.cwd());
252
+ // ── Activate agent backend ────────────────────────────────────
253
+ // Extensions had their chance to register via agent:register-backend.
254
+ // If none did, the built-in AgentLoop gets wired to bus events.
255
+ core.activateBackend();
256
+ // ── Startup banner ───────────────────────────────────────────
257
+ const settings = getSettings();
258
+ if (settings.startupBanner !== false) {
259
+ const termW = process.stdout.columns || 80;
260
+ const bannerW = Math.min(termW, 60);
261
+ const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
262
+ const info = agentInfo;
263
+ const backendName = info?.name ?? "agent-sh";
264
+ const model = info?.model ?? core.llmClient?.model;
265
+ const provider = info?.provider;
266
+ const modelValue = model
267
+ ? provider ? `${model} [${provider}]` : model
268
+ : null;
269
+ let sections = "";
270
+ sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
271
+ if (modelValue) {
272
+ sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
273
+ }
274
+ if (loadedExtensions.length > 0) {
275
+ sections += `\n\n ${p.muted}Extensions:${p.reset}`;
276
+ for (const name of loadedExtensions) {
277
+ sections += `\n ${p.dim}${name}${p.reset}`;
278
+ }
279
+ }
280
+ if (skills.length > 0) {
281
+ sections += `\n\n ${p.muted}Skills:${p.reset}`;
282
+ for (const s of skills) {
283
+ sections += `\n ${p.dim}${s.name}${p.reset}`;
284
+ }
285
+ }
286
+ const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
287
+ const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
288
+ process.stdout.write("\n" + borderLine + "\n" +
289
+ " " + productName +
290
+ sections + "\n" +
291
+ "\n " + hint + "\n" +
292
+ borderLine + "\n\n");
293
+ }
303
294
  // ── Terminal lifecycle ────────────────────────────────────────
304
295
  process.on("SIGTERM", cleanup);
305
296
  process.on("SIGHUP", cleanup);
306
- // Handle terminal stop/resume signals properly
307
297
  process.on("SIGTSTP", () => {
308
- // Handle Ctrl+Z - suspend the entire process group
309
- // Restore terminal state before suspending
310
298
  if (process.stdin.isTTY) {
311
299
  try {
312
300
  process.stdin.setRawMode(false);
@@ -315,14 +303,11 @@ async function main() {
315
303
  // Ignore
316
304
  }
317
305
  }
318
- // Re-send SIGSTOP to actually suspend
319
306
  process.kill(process.pid, "SIGSTOP");
320
307
  });
321
308
  process.on("SIGCONT", () => {
322
- // Re-acquire terminal when brought back to foreground
323
309
  if (process.stdin.isTTY) {
324
310
  try {
325
- // Ensure we reacquire controlling terminal
326
311
  process.stdin.setRawMode(true);
327
312
  }
328
313
  catch {
@@ -340,17 +325,14 @@ async function main() {
340
325
  }
341
326
  process.exit(e.exitCode);
342
327
  });
343
- // Set up stdin - resume after all event listeners are in place
344
328
  if (process.env.DEBUG) {
345
329
  console.error('[agent-sh] Resuming stdin...');
346
330
  }
347
331
  process.stdin.resume();
348
- // Set raw mode after resume to avoid SIGTTOU issues
349
332
  if (process.stdin.isTTY) {
350
333
  if (process.env.DEBUG) {
351
334
  console.error('[agent-sh] Setting raw mode...');
352
335
  }
353
- // Use setImmediate to ensure we're in the next tick
354
336
  setImmediate(() => {
355
337
  try {
356
338
  process.stdin.setRawMode(true);
@@ -362,7 +344,6 @@ async function main() {
362
344
  if (process.env.DEBUG) {
363
345
  console.error(`[agent-sh] Failed to set raw mode: ${err}`);
364
346
  }
365
- // May fail if process is in background; SIGTTOU handler prevents suspension
366
347
  }
367
348
  });
368
349
  }
@@ -187,8 +187,44 @@ export class InputHandler {
187
187
  this.lineBuffer = "";
188
188
  this.ctx.writeToPty(ch);
189
189
  }
190
+ else if (ch === "\x1b") {
191
+ // Escape sequence — forward the entire sequence to the PTY but
192
+ // don't let it corrupt lineBuffer. Skip CSI (ESC [ ... final)
193
+ // and SS3 (ESC O <char>) sequences; anything else: just ESC.
194
+ let seq = ch;
195
+ if (i + 1 < data.length) {
196
+ const next = data[i + 1];
197
+ if (next === "[") {
198
+ // CSI: ESC [ (params) (intermediates) final_byte
199
+ seq += next;
200
+ i++;
201
+ while (i + 1 < data.length && data[i + 1].charCodeAt(0) < 0x40) {
202
+ i++;
203
+ seq += data[i];
204
+ }
205
+ if (i + 1 < data.length) {
206
+ i++;
207
+ seq += data[i];
208
+ } // final byte
209
+ }
210
+ else if (next === "O") {
211
+ // SS3: ESC O <char>
212
+ seq += next;
213
+ i++;
214
+ if (i + 1 < data.length) {
215
+ i++;
216
+ seq += data[i];
217
+ }
218
+ }
219
+ else {
220
+ // ESC + single char (alt-key, etc.)
221
+ seq += next;
222
+ i++;
223
+ }
224
+ }
225
+ this.ctx.writeToPty(seq);
226
+ }
190
227
  else if (ch.charCodeAt(0) < 32 && ch !== "\t") {
191
- this.lineBuffer = "";
192
228
  this.ctx.writeToPty(ch);
193
229
  }
194
230
  else {
@@ -253,8 +289,20 @@ export class InputHandler {
253
289
  this.updateAutocomplete();
254
290
  }
255
291
  updateAutocomplete() {
292
+ const buf = this.editor.buffer;
293
+ let command = null;
294
+ let commandArgs = null;
295
+ if (buf.startsWith("/")) {
296
+ const spaceIdx = buf.indexOf(" ");
297
+ if (spaceIdx !== -1) {
298
+ command = buf.slice(0, spaceIdx);
299
+ commandArgs = buf.slice(spaceIdx + 1);
300
+ }
301
+ }
256
302
  const { items } = this.bus.emitPipe("autocomplete:request", {
257
- buffer: this.editor.buffer,
303
+ buffer: buf,
304
+ command,
305
+ commandArgs,
258
306
  items: [],
259
307
  });
260
308
  if (items.length > 0) {
@@ -289,10 +337,15 @@ export class InputHandler {
289
337
  if (this.autocompleteLines > 0) {
290
338
  process.stdout.write(`\x1b[${this.autocompleteLines}A`);
291
339
  }
340
+ // Reposition cursor: must match the layout in writeModePromptLine()
292
341
  const agentInfo = this.onShowAgentInfo();
293
- const infoLength = visibleLen(agentInfo.info);
342
+ const indicator = this.activeMode?.indicator ?? "●";
343
+ const infoPrefix = agentInfo.info
344
+ ? `${agentInfo.info} ${indicator} `
345
+ : `${indicator} `;
294
346
  const icon = this.activeMode?.promptIcon ?? "❯";
295
- const col = infoLength + visibleLen(icon) + 1 + this.editor.cursor;
347
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1;
348
+ const col = promptVisLen + this.editor.cursor;
296
349
  process.stdout.write(`\r\x1b[${col}C`);
297
350
  }
298
351
  applyAutocomplete() {
@@ -359,16 +412,30 @@ export class InputHandler {
359
412
  processModeActions(actions) {
360
413
  for (const act of actions) {
361
414
  switch (act.action) {
362
- case "changed":
415
+ case "changed": {
416
+ // If the buffer is exactly a trigger char for a different mode, switch to it
417
+ const switchMode = this.modes.get(this.editor.buffer);
418
+ if (this.editor.buffer.length === 1 && switchMode && switchMode !== this.activeMode) {
419
+ this.dismissAutocomplete();
420
+ this.clearPromptArea();
421
+ this.activeMode = switchMode;
422
+ this.editor.clear();
423
+ this.writeModePromptLine(false);
424
+ break;
425
+ }
363
426
  this.historyIndex = -1;
364
427
  this.autocompleteIndex = 0;
365
428
  this.renderModeInput();
366
429
  break;
430
+ }
367
431
  case "submit": {
368
432
  if (this.autocompleteActive) {
369
433
  this.applyAutocomplete();
370
434
  }
371
- const query = act.buffer.trim();
435
+ // Use editor.buffer (not act.buffer) so autocomplete selections
436
+ // take effect — act.buffer is a stale snapshot from before
437
+ // applyAutocomplete() updated the buffer.
438
+ const query = this.editor.buffer.trim();
372
439
  if (query) {
373
440
  // Add to history (avoid consecutive duplicates)
374
441
  if (this.history.length === 0 || this.history[this.history.length - 1] !== query) {
@@ -390,7 +457,7 @@ export class InputHandler {
390
457
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
391
458
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
392
459
  this.bus.emit("command:execute", { name, args });
393
- this.ctx.redrawPrompt();
460
+ this.ctx.freshPrompt();
394
461
  }
395
462
  else if (query) {
396
463
  this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
@@ -442,7 +509,8 @@ export class InputHandler {
442
509
  }
443
510
  this.editor.buffer = this.history[this.historyIndex];
444
511
  this.editor.cursor = this.editor.buffer.length;
445
- this.renderModeInput();
512
+ this.clearAutocompleteLines();
513
+ this.writeModePromptLine();
446
514
  }
447
515
  break;
448
516
  case "arrow-down":
@@ -465,7 +533,8 @@ export class InputHandler {
465
533
  this.editor.buffer = this.savedBuffer;
466
534
  }
467
535
  this.editor.cursor = this.editor.buffer.length;
468
- this.renderModeInput();
536
+ this.clearAutocompleteLines();
537
+ this.writeModePromptLine();
469
538
  }
470
539
  break;
471
540
  }
@@ -19,6 +19,13 @@ export declare class OutputParser {
19
19
  isPromptReady(): boolean;
20
20
  isForegroundBusy(): boolean;
21
21
  getCwd(): string;
22
+ /**
23
+ * Detect preexec marker (OSC 9997) emitted by the shell's preexec hook.
24
+ * This carries the actual command text from the shell — more reliable than
25
+ * the InputHandler's lineBuffer which can't track history recall or tab
26
+ * completion. Returns data with the OSC stripped out.
27
+ */
28
+ private handlePreexec;
22
29
  private parseOSC7;
23
30
  /**
24
31
  * Detect our custom prompt marker (OSC 9999) in the PTY stream.
@@ -17,6 +17,7 @@ export class OutputParser {
17
17
  /** Process a chunk of PTY output data. */
18
18
  processData(data) {
19
19
  this.parseOSC7(data);
20
+ data = this.handlePreexec(data);
20
21
  this.parsePromptMarker(data);
21
22
  this.parsePromptEnd(data);
22
23
  }
@@ -41,6 +42,32 @@ export class OutputParser {
41
42
  return this.cwd;
42
43
  }
43
44
  // ── Parsing ─────────────────────────────────────────────────
45
+ /**
46
+ * Detect preexec marker (OSC 9997) emitted by the shell's preexec hook.
47
+ * This carries the actual command text from the shell — more reliable than
48
+ * the InputHandler's lineBuffer which can't track history recall or tab
49
+ * completion. Returns data with the OSC stripped out.
50
+ */
51
+ handlePreexec(data) {
52
+ const marker = "\x1b]9997;";
53
+ const idx = data.indexOf(marker);
54
+ if (idx === -1)
55
+ return data;
56
+ const endIdx = data.indexOf("\x07", idx + marker.length);
57
+ if (endIdx === -1)
58
+ return data; // incomplete OSC, wait for next chunk
59
+ const command = data.slice(idx + marker.length, endIdx);
60
+ // Authoritative command from the shell — override any lineBuffer guess
61
+ this.lastCommand = command;
62
+ this.currentOutputCapture = ""; // discard echoed text accumulated before preexec
63
+ if (!this.foregroundBusy) {
64
+ this.foregroundBusy = true;
65
+ this.bus.emit("shell:foreground-busy", { busy: true });
66
+ }
67
+ this.bus.emit("shell:command-start", { command, cwd: this.cwd });
68
+ // Return only data after the OSC — everything before was the echo
69
+ return data.slice(endIdx + 1);
70
+ }
44
71
  parseOSC7(data) {
45
72
  const match = data.match(/\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)/);
46
73
  if (match?.[1]) {