agent-sh 0.4.0 → 0.5.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 (76) hide show
  1. package/README.md +66 -113
  2. package/dist/agent/agent-loop.d.ts +85 -0
  3. package/dist/agent/agent-loop.js +611 -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 +117 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +98 -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 +62 -0
  18. package/dist/agent/tools/edit-file.d.ts +2 -0
  19. package/dist/agent/tools/edit-file.js +95 -0
  20. package/dist/agent/tools/glob.d.ts +2 -0
  21. package/dist/agent/tools/glob.js +55 -0
  22. package/dist/agent/tools/grep.d.ts +2 -0
  23. package/dist/agent/tools/grep.js +77 -0
  24. package/dist/agent/tools/list-skills.d.ts +2 -0
  25. package/dist/agent/tools/list-skills.js +28 -0
  26. package/dist/agent/tools/ls.d.ts +2 -0
  27. package/dist/agent/tools/ls.js +43 -0
  28. package/dist/agent/tools/read-file.d.ts +2 -0
  29. package/dist/agent/tools/read-file.js +55 -0
  30. package/dist/agent/tools/user-shell.d.ts +13 -0
  31. package/dist/agent/tools/user-shell.js +57 -0
  32. package/dist/agent/tools/write-file.d.ts +2 -0
  33. package/dist/agent/tools/write-file.js +74 -0
  34. package/dist/agent/types.d.ts +44 -0
  35. package/dist/agent/types.js +1 -0
  36. package/dist/core.d.ts +24 -14
  37. package/dist/core.js +260 -36
  38. package/dist/event-bus.d.ts +80 -14
  39. package/dist/event-bus.js +10 -1
  40. package/dist/extension-loader.js +12 -1
  41. package/dist/extensions/command-suggest.d.ts +10 -0
  42. package/dist/extensions/command-suggest.js +41 -0
  43. package/dist/extensions/slash-commands.d.ts +1 -1
  44. package/dist/extensions/slash-commands.js +161 -64
  45. package/dist/extensions/tui-renderer.js +90 -48
  46. package/dist/index.js +98 -122
  47. package/dist/input-handler.js +74 -7
  48. package/dist/output-parser.d.ts +7 -0
  49. package/dist/output-parser.js +27 -0
  50. package/dist/settings.d.ts +53 -2
  51. package/dist/settings.js +45 -2
  52. package/dist/shell.js +33 -26
  53. package/dist/types.d.ts +33 -6
  54. package/dist/utils/box-frame.d.ts +3 -1
  55. package/dist/utils/box-frame.js +12 -5
  56. package/dist/utils/llm-client.d.ts +45 -0
  57. package/dist/utils/llm-client.js +60 -0
  58. package/dist/utils/markdown.js +2 -2
  59. package/dist/utils/stream-transform.js +20 -47
  60. package/dist/utils/tool-display.js +15 -5
  61. package/examples/extensions/claude-code-bridge/README.md +35 -0
  62. package/examples/extensions/claude-code-bridge/index.ts +198 -0
  63. package/examples/extensions/claude-code-bridge/package.json +11 -0
  64. package/examples/extensions/openrouter.ts +87 -0
  65. package/examples/extensions/pi-bridge/README.md +35 -0
  66. package/examples/extensions/pi-bridge/index.ts +265 -0
  67. package/examples/extensions/pi-bridge/package.json +13 -0
  68. package/examples/extensions/subagents.ts +87 -0
  69. package/package.json +3 -5
  70. package/dist/acp-client.d.ts +0 -105
  71. package/dist/acp-client.js +0 -684
  72. package/dist/extensions/shell-exec.d.ts +0 -24
  73. package/dist/extensions/shell-exec.js +0 -188
  74. package/dist/mcp-server.d.ts +0 -13
  75. package/dist/mcp-server.js +0 -234
  76. package/examples/pi-agent-sh.ts +0 -166
package/dist/index.js CHANGED
@@ -8,16 +8,13 @@ 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";
13
14
  /**
14
15
  * Capture the user's full shell environment.
15
16
  * This picks up env vars exported in .zshrc/.bashrc that the
16
17
  * 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
18
  */
22
19
  async function captureShellEnvAsync(shell) {
23
20
  return new Promise((resolve) => {
@@ -37,7 +34,7 @@ async function captureShellEnvAsync(shell) {
37
34
  });
38
35
  child.on("close", (code) => {
39
36
  if (code !== 0 || !output) {
40
- resolve({}); // Return empty to trigger fallback
37
+ resolve({});
41
38
  return;
42
39
  }
43
40
  const env = {};
@@ -49,9 +46,8 @@ async function captureShellEnvAsync(shell) {
49
46
  resolve(env);
50
47
  });
51
48
  child.on("error", () => {
52
- resolve({}); // Return empty to trigger fallback
49
+ resolve({});
53
50
  });
54
- // Safety timeout
55
51
  setTimeout(() => {
56
52
  child.kill("SIGTERM");
57
53
  resolve({});
@@ -62,14 +58,9 @@ async function captureShellEnvAsync(shell) {
62
58
  }
63
59
  });
64
60
  }
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
61
  function mergeShellEnv(baseEnv, shellEnv) {
70
62
  const merged = { ...baseEnv };
71
63
  for (const [key, value] of Object.entries(shellEnv)) {
72
- // Only add if key doesn't exist or is empty in base env
73
64
  if (!(key in merged) || !merged[key]) {
74
65
  merged[key] = value;
75
66
  }
@@ -77,113 +68,96 @@ function mergeShellEnv(baseEnv, shellEnv) {
77
68
  return merged;
78
69
  }
79
70
  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
71
  let model;
85
72
  let extensions;
73
+ let provider;
86
74
  const shell = process.env.SHELL || "/bin/bash";
75
+ let apiKey = process.env.OPENAI_API_KEY;
76
+ let baseURL = process.env.OPENAI_BASE_URL;
87
77
  for (let i = 0; i < argv.length; i++) {
88
78
  const arg = argv[i];
89
- if (arg === "--agent" && argv[i + 1]) {
90
- agentCommand = argv[++i];
79
+ if (arg === "--model" && argv[i + 1]) {
80
+ model = argv[++i];
91
81
  }
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
- }
82
+ else if (arg === "--api-key" && argv[i + 1]) {
83
+ apiKey = argv[++i];
84
+ }
85
+ else if (arg === "--base-url" && argv[i + 1]) {
86
+ baseURL = argv[++i];
87
+ }
88
+ else if (arg === "--provider" && argv[i + 1]) {
89
+ provider = argv[++i];
100
90
  }
101
91
  else if (arg === "--shell" && argv[i + 1]) {
102
- return { agentCommand, agentArgs, shell: argv[++i], model, extensions };
92
+ return { shell: argv[++i], model, extensions, apiKey, baseURL, provider };
103
93
  }
104
94
  else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
105
95
  const exts = argv[++i].split(",").map(s => s.trim());
106
96
  extensions = extensions ? [...extensions, ...exts] : exts;
107
97
  }
108
98
  else if (arg === "--help" || arg === "-h") {
109
- console.log(`agent-sh — a shell-first terminal with ACP agent access
99
+ console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
110
100
 
111
101
  Usage: agent-sh [options]
112
102
 
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
103
+ Provider Profiles:
104
+ --provider <name> Use a provider from ~/.agent-sh/settings.json
105
+ --model <name> Override default model
106
+
107
+ Direct LLM API:
108
+ --api-key <key> API key for OpenAI-compatible provider (or set OPENAI_API_KEY)
109
+ --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
117
110
 
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)
111
+ General Options:
121
112
  --shell <path> Shell to use (default: $SHELL or /bin/bash)
122
113
  -e, --extensions Extensions to load (comma-separated, repeatable)
123
114
  -h, --help Show this help
124
115
 
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
116
  Environment Variables:
132
- AGENT_SH_AGENT Default agent to use (e.g., "pi-acp", "claude")
117
+ OPENAI_API_KEY API key for LLM provider
118
+ OPENAI_BASE_URL Base URL override (e.g., http://localhost:11434/v1 for Ollama)
133
119
 
134
120
  Examples:
135
- npm start --agent pi-acp
136
- npm start -- -e my-extension-package
137
- npm start -- -e ./local-ext.ts -e another-package
121
+ # Use a configured provider
122
+ agent-sh --provider openai
123
+
124
+ # Direct API access
125
+ agent-sh --api-key "$KEY" --model gpt-4o
126
+
127
+ # Local model via Ollama
128
+ agent-sh --base-url http://localhost:11434/v1 --model llama3
138
129
 
139
130
  Inside the shell:
140
131
  Type normally Commands run in your real shell
141
- > <query> Send query to the AI agent
132
+ > <query> Ask the AI agent a question (execute mode)
133
+ ? <command> Have the agent run a command in your shell (help mode)
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
  },
@@ -239,24 +212,35 @@ async function main() {
239
212
  }
240
213
  // ── Input modes ──────────────────────────────────────────────
241
214
  bus.emit("input-mode:register", {
242
- id: "query",
243
- trigger: "?",
244
- label: "query",
215
+ id: "execute",
216
+ trigger: ">",
217
+ label: "execute",
245
218
  promptIcon: "❯",
246
- indicator: "",
219
+ indicator: "",
247
220
  onSubmit(query, b) {
248
- b.emit("agent:submit", { query, modeLabel: "Query", modeInstruction: "[mode: query]" });
221
+ b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
249
222
  },
250
223
  returnToSelf: true,
251
224
  });
252
225
  bus.emit("input-mode:register", {
253
- id: "execute",
254
- trigger: ">",
255
- label: "execute",
256
- promptIcon: "",
257
- indicator: "",
226
+ id: "help",
227
+ trigger: "?",
228
+ label: "help",
229
+ promptIcon: "",
230
+ indicator: "",
258
231
  onSubmit(query, b) {
259
- b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
232
+ const onToolDone = (e) => {
233
+ if (e.kind === "execute") {
234
+ b.emit("agent:cancel-request", { silent: true });
235
+ }
236
+ };
237
+ const cleanup = () => {
238
+ b.off("agent:tool-completed", onToolDone);
239
+ b.off("agent:processing-done", cleanup);
240
+ };
241
+ b.on("agent:tool-completed", onToolDone);
242
+ b.on("agent:processing-done", cleanup);
243
+ b.emit("agent:submit", { query, modeLabel: "Help", modeInstruction: "[mode: help]" });
260
244
  },
261
245
  returnToSelf: false,
262
246
  });
@@ -269,20 +253,12 @@ async function main() {
269
253
  slashCommands(extCtx);
270
254
  fileAutocomplete(extCtx);
271
255
  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
256
+ commandSuggest(extCtx);
257
+ // Load user extensions (may register alternative agent backends)
282
258
  if (process.env.DEBUG) {
283
259
  console.error('[agent-sh] Loading extensions...');
284
260
  }
285
- const loadExtensionsTimeoutMs = 10000; // 10 seconds
261
+ const loadExtensionsTimeoutMs = 10000;
286
262
  await Promise.race([
287
263
  loadExtensions(extCtx, config.extensions),
288
264
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Extension loading timeout after ${loadExtensionsTimeoutMs}ms`)), loadExtensionsTimeoutMs)),
@@ -292,21 +268,28 @@ async function main() {
292
268
  if (process.env.DEBUG) {
293
269
  console.error('[agent-sh] Extensions loaded');
294
270
  }
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
- });
271
+ // ── Activate agent backend ────────────────────────────────────
272
+ // Extensions had their chance to register via agent:register-backend.
273
+ // If none did, the built-in AgentLoop gets wired to bus events.
274
+ core.activateBackend();
275
+ // ── Startup banner ───────────────────────────────────────────
276
+ const settings = getSettings();
277
+ if (settings.startupBanner !== false) {
278
+ const title = core.llmClient
279
+ ? `${p.accent}${p.bold}agent-sh${p.reset}${p.dim} · ${core.llmClient.model}${p.reset}`
280
+ : `${p.accent}${p.bold}agent-sh${p.reset}`;
281
+ const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}?${p.muted} to run in shell · ${p.warning}/help${p.muted} for commands${p.reset}`;
282
+ const termW = process.stdout.columns || 80;
283
+ const borderLine = `${p.muted}${"─".repeat(Math.min(termW, 60))}${p.reset}`;
284
+ process.stdout.write("\n" + borderLine + "\n" +
285
+ " " + title + "\n" +
286
+ " " + hint + "\n" +
287
+ borderLine + "\n\n");
288
+ }
303
289
  // ── Terminal lifecycle ────────────────────────────────────────
304
290
  process.on("SIGTERM", cleanup);
305
291
  process.on("SIGHUP", cleanup);
306
- // Handle terminal stop/resume signals properly
307
292
  process.on("SIGTSTP", () => {
308
- // Handle Ctrl+Z - suspend the entire process group
309
- // Restore terminal state before suspending
310
293
  if (process.stdin.isTTY) {
311
294
  try {
312
295
  process.stdin.setRawMode(false);
@@ -315,14 +298,11 @@ async function main() {
315
298
  // Ignore
316
299
  }
317
300
  }
318
- // Re-send SIGSTOP to actually suspend
319
301
  process.kill(process.pid, "SIGSTOP");
320
302
  });
321
303
  process.on("SIGCONT", () => {
322
- // Re-acquire terminal when brought back to foreground
323
304
  if (process.stdin.isTTY) {
324
305
  try {
325
- // Ensure we reacquire controlling terminal
326
306
  process.stdin.setRawMode(true);
327
307
  }
328
308
  catch {
@@ -340,17 +320,14 @@ async function main() {
340
320
  }
341
321
  process.exit(e.exitCode);
342
322
  });
343
- // Set up stdin - resume after all event listeners are in place
344
323
  if (process.env.DEBUG) {
345
324
  console.error('[agent-sh] Resuming stdin...');
346
325
  }
347
326
  process.stdin.resume();
348
- // Set raw mode after resume to avoid SIGTTOU issues
349
327
  if (process.stdin.isTTY) {
350
328
  if (process.env.DEBUG) {
351
329
  console.error('[agent-sh] Setting raw mode...');
352
330
  }
353
- // Use setImmediate to ensure we're in the next tick
354
331
  setImmediate(() => {
355
332
  try {
356
333
  process.stdin.setRawMode(true);
@@ -362,7 +339,6 @@ async function main() {
362
339
  if (process.env.DEBUG) {
363
340
  console.error(`[agent-sh] Failed to set raw mode: ${err}`);
364
341
  }
365
- // May fail if process is in background; SIGTTOU handler prevents suspension
366
342
  }
367
343
  });
368
344
  }
@@ -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;
@@ -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]) {
@@ -1,9 +1,28 @@
1
1
  export declare const CONFIG_DIR: string;
2
+ /** Provider profile — a named LLM configuration. */
3
+ export interface ProviderConfig {
4
+ /** API key (supports $ENV_VAR syntax for runtime expansion). */
5
+ apiKey?: string;
6
+ /** Base URL for OpenAI-compatible API. */
7
+ baseURL?: string;
8
+ /** Default model to use. Falls back to first entry in models list. */
9
+ defaultModel?: string;
10
+ /** Models available for cycling. */
11
+ models?: string[];
12
+ /** Context window size in tokens (e.g. 128000). Used for usage display. */
13
+ contextWindow?: number;
14
+ }
2
15
  export interface Settings {
3
16
  /** Extensions to load (npm packages or file paths). */
4
17
  extensions?: string[];
5
18
  /** Max agent query history entries to keep. */
6
19
  historySize?: number;
20
+ /** Named provider configurations. */
21
+ providers?: Record<string, ProviderConfig>;
22
+ /** Which provider to use by default. */
23
+ defaultProvider?: string;
24
+ /** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
25
+ defaultBackend?: string;
7
26
  /** Recent exchanges included in agent context window. */
8
27
  contextWindowSize?: number;
9
28
  /** Context budget in bytes (~4 chars per token). */
@@ -22,8 +41,12 @@ export interface Settings {
22
41
  readOutputMaxLines?: number;
23
42
  /** Max diff lines shown before "ctrl+o to expand". */
24
43
  diffMaxLines?: number;
25
- /** Register MCP server for bridge tools (shell_cwd, user_shell, shell_recall). Default true. */
26
- enableMcp?: boolean;
44
+ /** Additional directories to scan for skills (supports ~ expansion). */
45
+ skillPaths?: string[];
46
+ /** Show a startup banner when agent-sh launches. */
47
+ startupBanner?: boolean;
48
+ /** Show a subtle agent-sh indicator in the shell prompt. */
49
+ promptIndicator?: boolean;
27
50
  }
28
51
  declare const DEFAULTS: Required<Settings>;
29
52
  /** Load settings from disk (cached after first call). */
@@ -41,4 +64,32 @@ export declare function getSettings(): Settings & typeof DEFAULTS;
41
64
  export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
42
65
  /** Reset cached settings (for testing or after external edit). */
43
66
  export declare function reloadSettings(): void;
67
+ /**
68
+ * Expand $ENV_VAR references in a string.
69
+ * Supports $VAR and ${VAR} syntax.
70
+ */
71
+ export declare function expandEnvVars(value: string): string;
72
+ /** Resolved provider ready for use (env vars expanded, defaults applied). */
73
+ export interface ResolvedProvider {
74
+ id: string;
75
+ apiKey?: string;
76
+ baseURL?: string;
77
+ defaultModel?: string;
78
+ models: string[];
79
+ contextWindow?: number;
80
+ /** Provider supports the reasoning_effort parameter. Default: true. */
81
+ supportsReasoningEffort?: boolean;
82
+ /** Per-model capabilities, keyed by model id. */
83
+ modelCapabilities?: Map<string, {
84
+ reasoning?: boolean;
85
+ contextWindow?: number;
86
+ }>;
87
+ }
88
+ /**
89
+ * Resolve a provider config by name from settings.
90
+ * Returns null if provider not found.
91
+ */
92
+ export declare function resolveProvider(name: string): ResolvedProvider | null;
93
+ /** Get all configured provider names. */
94
+ export declare function getProviderNames(): string[];
44
95
  export {};