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/settings.js CHANGED
@@ -12,6 +12,9 @@ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
12
  const DEFAULTS = {
13
13
  extensions: [],
14
14
  historySize: 500,
15
+ providers: {},
16
+ defaultProvider: undefined,
17
+ defaultBackend: "agent-sh",
15
18
  contextWindowSize: 20,
16
19
  contextBudget: 16384,
17
20
  shellTruncateThreshold: 10,
@@ -21,7 +24,9 @@ const DEFAULTS = {
21
24
  maxCommandOutputLines: 3,
22
25
  readOutputMaxLines: 0,
23
26
  diffMaxLines: 20,
24
- enableMcp: true,
27
+ skillPaths: [],
28
+ startupBanner: true,
29
+ promptIndicator: true,
25
30
  };
26
31
  let cached = null;
27
32
  /** Load settings from disk (cached after first call). */
@@ -31,7 +36,10 @@ export function getSettings() {
31
36
  const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
32
37
  cached = JSON.parse(raw);
33
38
  }
34
- catch {
39
+ catch (err) {
40
+ if (err instanceof SyntaxError) {
41
+ console.error(`[agent-sh] Warning: invalid JSON in ${SETTINGS_PATH}: ${err.message}`);
42
+ }
35
43
  cached = {};
36
44
  }
37
45
  }
@@ -59,3 +67,38 @@ export function getExtensionSettings(namespace, defaults) {
59
67
  export function reloadSettings() {
60
68
  cached = null;
61
69
  }
70
+ /**
71
+ * Expand $ENV_VAR references in a string.
72
+ * Supports $VAR and ${VAR} syntax.
73
+ */
74
+ export function expandEnvVars(value) {
75
+ return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, plain) => {
76
+ const name = braced || plain;
77
+ return process.env[name] ?? "";
78
+ });
79
+ }
80
+ /**
81
+ * Resolve a provider config by name from settings.
82
+ * Returns null if provider not found.
83
+ */
84
+ export function resolveProvider(name) {
85
+ const settings = getSettings();
86
+ const provider = settings.providers?.[name];
87
+ if (!provider)
88
+ return null;
89
+ const models = provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []);
90
+ const defaultModel = provider.defaultModel ?? models[0];
91
+ return {
92
+ id: name,
93
+ apiKey: provider.apiKey ? expandEnvVars(provider.apiKey) : undefined,
94
+ baseURL: provider.baseURL,
95
+ defaultModel,
96
+ models: models.length ? models : (defaultModel ? [defaultModel] : []),
97
+ contextWindow: provider.contextWindow,
98
+ };
99
+ }
100
+ /** Get all configured provider names. */
101
+ export function getProviderNames() {
102
+ const settings = getSettings();
103
+ return Object.keys(settings.providers ?? {});
104
+ }
package/dist/shell.js CHANGED
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import * as pty from "node-pty";
5
5
  import { InputHandler } from "./input-handler.js";
6
6
  import { OutputParser } from "./output-parser.js";
7
+ import { getSettings } from "./settings.js";
7
8
  export class Shell {
8
9
  ptyProcess;
9
10
  bus;
@@ -40,13 +41,16 @@ export class Shell {
40
41
  let shellArgs;
41
42
  const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
42
43
  const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
44
+ const titleCmd = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
43
45
  this.isZsh = isZsh;
46
+ const settings = getSettings();
47
+ const showIndicator = settings.promptIndicator !== false;
44
48
  if (isZsh) {
45
49
  // For zsh: use ZDOTDIR to source user's real config, then append
46
50
  // our hooks via precmd_functions (additive — doesn't clobber p10k/omz).
47
51
  this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-sh-"));
48
52
  const userZdotdir = env.ZDOTDIR || env.HOME || os.homedir();
49
- fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), [
53
+ const zshrcLines = [
50
54
  `ZDOTDIR="${userZdotdir}"`,
51
55
  `[ -f "${userZdotdir}/.zshrc" ] && source "${userZdotdir}/.zshrc"`,
52
56
  "",
@@ -54,32 +58,19 @@ export class Shell {
54
58
  "__agent_sh_precmd() {",
55
59
  ` ${osc7Cmd}`,
56
60
  ` ${promptMarker}`,
61
+ ...(showIndicator ? [` ${titleCmd}`] : []),
57
62
  "}",
58
63
  "precmd_functions+=(__agent_sh_precmd)",
59
64
  "",
60
- "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)",
61
- "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering",
62
- 'if (( ${+widgets[zle-line-init]} )); then',
63
- " zle -A zle-line-init __agent_sh_orig_line_init",
64
- " __agent_sh_line_init() {",
65
- " zle __agent_sh_orig_line_init",
66
- ' printf "\\e]9998;READY\\a"',
67
- " }",
68
- "else",
69
- " __agent_sh_line_init() {",
70
- ' printf "\\e]9998;READY\\a"',
71
- " }",
72
- "fi",
73
- "zle -N zle-line-init __agent_sh_line_init",
74
- "",
75
- "# Hidden widget to trigger prompt redraw from Node.js side",
76
- "# Bound to an unused escape sequence that no real key produces",
77
- "__agent_sh_redraw() {",
78
- " zle reset-prompt",
65
+ "# Preexec hook: emit actual command text so agent-sh can track",
66
+ "# history-recalled and tab-completed commands accurately",
67
+ "__agent_sh_preexec() {",
68
+ ' printf "\\e]9997;%s\\a" "$1"',
79
69
  "}",
80
- "zle -N __agent_sh_redraw",
81
- "bindkey '\\e[9999~' __agent_sh_redraw",
82
- ].join("\n") + "\n");
70
+ "preexec_functions+=(__agent_sh_preexec)",
71
+ ];
72
+ zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ' printf "\\e]9998;READY\\a"', " }", "else", " __agent_sh_line_init() {", ' printf "\\e]9998;READY\\a"', " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
73
+ fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), zshrcLines.join("\n") + "\n");
83
74
  env.ZDOTDIR = this.tmpDir;
84
75
  shellArgs = ["--no-globalrcs"];
85
76
  }
@@ -88,15 +79,29 @@ export class Shell {
88
79
  // real bashrc then appends our hooks. No HOME override needed.
89
80
  this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-sh-"));
90
81
  const userHome = env.HOME || os.homedir();
91
- fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), [
82
+ const bashrcLines = [
92
83
  `[ -f "${userHome}/.bashrc" ] && source "${userHome}/.bashrc"`,
93
84
  "",
94
85
  "# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
95
- `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}${osc7Cmd}; ${promptMarker}"`,
86
+ `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_preexec_ran=0; ${osc7Cmd}; ${promptMarker}${showIndicator ? `; ${titleCmd}` : ""}"`,
87
+ "",
88
+ "# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
89
+ "# can track history-recalled and tab-completed commands accurately",
90
+ "__agent_sh_preexec_ran=0",
91
+ "__agent_sh_emit_preexec() {",
92
+ ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
93
+ ' [[ -n $COMP_LINE ]] && return',
94
+ " __agent_sh_preexec_ran=1",
95
+ " local this_cmd",
96
+ ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
97
+ ` printf '\\e]9997;%s\\a' "$this_cmd"`,
98
+ "}",
99
+ "trap '__agent_sh_emit_preexec' DEBUG",
96
100
  "",
97
101
  "# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
98
102
  'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
99
- ].join("\n") + "\n");
103
+ ];
104
+ fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), bashrcLines.join("\n") + "\n");
100
105
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
101
106
  }
102
107
  // Pause stdin before spawning PTY to avoid TTY contention on macOS.
@@ -259,6 +264,7 @@ export class Shell {
259
264
  this.echoSkip = true;
260
265
  this.paused = false;
261
266
  process.stdout.write("\n");
267
+ this.bus.emit("shell:agent-exec-start", {});
262
268
  const output = await new Promise((resolve, reject) => {
263
269
  const timeout = setTimeout(() => {
264
270
  this.bus.off("shell:command-done", handler);
@@ -276,6 +282,7 @@ export class Shell {
276
282
  });
277
283
  this.paused = true;
278
284
  this.echoSkip = false;
285
+ this.bus.emit("shell:agent-exec-done", {});
279
286
  return { ...payload, output: output.output, cwd: output.cwd, done: true };
280
287
  });
281
288
  }
package/dist/types.d.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  import type { EventBus } from "./event-bus.js";
2
2
  import type { ContextManager } from "./context-manager.js";
3
- import type { AcpClient } from "./acp-client.js";
3
+ import type { LlmClient } from "./utils/llm-client.js";
4
4
  import type { ColorPalette } from "./utils/palette.js";
5
5
  import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
6
+ import type { ToolDefinition } from "./agent/types.js";
6
7
  export type { ContentBlock } from "./event-bus.js";
7
8
  export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
9
+ /** A model entry in the cycling list, optionally tied to a provider. */
10
+ export interface AgentMode {
11
+ model: string;
12
+ /** Provider id — when cycling changes provider, LlmClient is reconfigured. */
13
+ provider?: string;
14
+ /** Provider-specific config for reconfiguring LlmClient on switch. */
15
+ providerConfig?: {
16
+ apiKey: string;
17
+ baseURL?: string;
18
+ };
19
+ /** Context window size in tokens (for usage display). */
20
+ contextWindow?: number;
21
+ /** Model supports reasoning/thinking tokens. */
22
+ reasoning?: boolean;
23
+ /** Provider supports the reasoning_effort parameter. */
24
+ supportsReasoningEffort?: boolean;
25
+ }
8
26
  export interface AgentShellConfig {
9
- agentCommand: string;
10
- agentArgs: string[];
11
27
  shell?: string;
12
28
  model?: string;
13
29
  extensions?: string[];
14
- /** Full shell environment (from user's rc files) for agent subprocess. */
15
- shellEnv?: Record<string, string>;
30
+ /** API key for OpenAI-compatible provider. */
31
+ apiKey?: string;
32
+ /** Base URL for OpenAI-compatible API. */
33
+ baseURL?: string;
34
+ /** Named provider to use from settings.json. */
35
+ provider?: string;
16
36
  }
17
37
  /**
18
38
  * Context passed to user/third-party extensions.
@@ -23,7 +43,8 @@ export interface AgentShellConfig {
23
43
  export interface ExtensionContext {
24
44
  bus: EventBus;
25
45
  contextManager: ContextManager;
26
- getAcpClient: () => AcpClient;
46
+ /** LLM client for fast-path features (null in ACP mode). */
47
+ llmClient: LlmClient | null;
27
48
  quit: () => void;
28
49
  /** Override color palette slots for theming. */
29
50
  setPalette: (overrides: Partial<ColorPalette>) => void;
@@ -33,6 +54,12 @@ export interface ExtensionContext {
33
54
  createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
34
55
  /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
35
56
  getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
57
+ /** Register a slash command available in any input mode. */
58
+ registerCommand: (name: string, description: string, handler: (args: string) => Promise<void> | void) => void;
59
+ /** Register a tool for the built-in agent. No-op when using bridge backends. */
60
+ registerTool: (tool: ToolDefinition) => void;
61
+ /** Get all registered tools (for subagent tool subsets). Returns [] when using bridge backends. */
62
+ getTools: () => ToolDefinition[];
36
63
  /** Register a named handler. */
37
64
  define: (name: string, fn: (...args: any[]) => any) => void;
38
65
  /** Wrap a named handler. Receives `next` (original) + args. */
@@ -6,8 +6,10 @@ export interface BoxFrameOptions {
6
6
  style?: BorderStyle;
7
7
  /** Border color (ANSI escape). Default DIM. */
8
8
  borderColor?: string;
9
- /** Title text shown in the top border. */
9
+ /** Title text shown on the left of the top border. */
10
10
  title?: string;
11
+ /** Title text shown on the right of the top border. */
12
+ titleRight?: string;
11
13
  /** Footer lines shown below a divider, inside the box. */
12
14
  footer?: string[];
13
15
  }
@@ -30,11 +30,18 @@ export function renderBoxFrame(content, opts) {
30
30
  // Content area width = total - 2 borders - 2 padding spaces
31
31
  const innerW = Math.max(1, width - 4);
32
32
  const output = [];
33
- // Top border (with optional title)
34
- if (opts.title) {
35
- const titleVis = visibleLen(opts.title);
36
- const afterDashes = Math.max(1, width - titleVis - 4);
37
- output.push(`${bc}${b.tl}${p.reset} ${opts.title} ${bc}${b.h.repeat(afterDashes)}${b.tr}${p.reset}`);
33
+ // Top border (with optional left/right titles)
34
+ if (opts.title || opts.titleRight) {
35
+ const leftPart = opts.title
36
+ ? `${p.reset} ${opts.title} ${bc}`
37
+ : "";
38
+ const leftVis = opts.title ? visibleLen(opts.title) + 2 : 0; // +2 for spaces
39
+ const rightPart = opts.titleRight
40
+ ? `${p.reset} ${opts.titleRight} ${bc}`
41
+ : "";
42
+ const rightVis = opts.titleRight ? visibleLen(opts.titleRight) + 2 : 0;
43
+ const dashCount = Math.max(1, width - 2 - leftVis - rightVis);
44
+ output.push(`${bc}${b.tl}${leftPart}${b.h.repeat(dashCount)}${rightPart}${b.tr}${p.reset}`);
38
45
  }
39
46
  else {
40
47
  output.push(`${bc}${b.tl}${b.h.repeat(width - 2)}${b.tr}${p.reset}`);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thin, stateless wrapper around the OpenAI SDK.
3
+ * No agent-sh knowledge — just a configured client.
4
+ *
5
+ * Used by both AgentLoop (full tool loop) and fast-path features
6
+ * (command suggestions, completions).
7
+ */
8
+ import OpenAI from "openai";
9
+ import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions.js";
10
+ export type { ChatCompletionMessageParam, ChatCompletionTool };
11
+ export interface LlmClientConfig {
12
+ apiKey: string;
13
+ baseURL?: string;
14
+ model: string;
15
+ }
16
+ export declare class LlmClient {
17
+ private config;
18
+ private client;
19
+ model: string;
20
+ constructor(config: LlmClientConfig);
21
+ /** Swap the underlying client config at runtime (e.g. provider switch). */
22
+ reconfigure(newConfig: LlmClientConfig): void;
23
+ /**
24
+ * Create a streaming chat completion.
25
+ * Returns an async iterable of chunks.
26
+ */
27
+ stream(opts: {
28
+ messages: ChatCompletionMessageParam[];
29
+ tools?: ChatCompletionTool[];
30
+ model?: string;
31
+ max_tokens?: number;
32
+ /** Reasoning effort level (e.g. "low", "medium", "high"). Provider-dependent. */
33
+ reasoning_effort?: string;
34
+ signal?: AbortSignal;
35
+ }): import("openai").APIPromise<import("openai/core/streaming.mjs").Stream<OpenAI.Chat.Completions.ChatCompletionChunk>>;
36
+ /**
37
+ * Single-shot completion (no streaming) — for fast-path features.
38
+ * Returns the text content of the first choice.
39
+ */
40
+ complete(opts: {
41
+ messages: ChatCompletionMessageParam[];
42
+ model?: string;
43
+ max_tokens?: number;
44
+ }): Promise<string>;
45
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Thin, stateless wrapper around the OpenAI SDK.
3
+ * No agent-sh knowledge — just a configured client.
4
+ *
5
+ * Used by both AgentLoop (full tool loop) and fast-path features
6
+ * (command suggestions, completions).
7
+ */
8
+ import OpenAI from "openai";
9
+ export class LlmClient {
10
+ config;
11
+ client;
12
+ model;
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.client = new OpenAI({
16
+ apiKey: config.apiKey,
17
+ baseURL: config.baseURL,
18
+ });
19
+ this.model = config.model;
20
+ }
21
+ /** Swap the underlying client config at runtime (e.g. provider switch). */
22
+ reconfigure(newConfig) {
23
+ this.config = newConfig;
24
+ this.client = new OpenAI({
25
+ apiKey: newConfig.apiKey,
26
+ baseURL: newConfig.baseURL,
27
+ });
28
+ this.model = newConfig.model;
29
+ }
30
+ /**
31
+ * Create a streaming chat completion.
32
+ * Returns an async iterable of chunks.
33
+ */
34
+ stream(opts) {
35
+ const body = {
36
+ model: opts.model ?? this.model,
37
+ messages: opts.messages,
38
+ tools: opts.tools?.length ? opts.tools : undefined,
39
+ max_tokens: opts.max_tokens ?? 8192,
40
+ stream: true,
41
+ stream_options: { include_usage: true },
42
+ ...(opts.reasoning_effort
43
+ ? { reasoning_effort: opts.reasoning_effort }
44
+ : {}),
45
+ };
46
+ return this.client.chat.completions.create(body, { signal: opts.signal });
47
+ }
48
+ /**
49
+ * Single-shot completion (no streaming) — for fast-path features.
50
+ * Returns the text content of the first choice.
51
+ */
52
+ async complete(opts) {
53
+ const response = await this.client.chat.completions.create({
54
+ model: opts.model ?? this.model,
55
+ messages: opts.messages,
56
+ max_tokens: opts.max_tokens ?? 1024,
57
+ });
58
+ return response.choices[0]?.message?.content ?? "";
59
+ }
60
+ }
@@ -224,9 +224,9 @@ export class MarkdownRenderer {
224
224
  const h4 = line.match(/^#{4,} (.+)/);
225
225
  if (h4)
226
226
  return `${p.bold}${h4[1]}${p.reset}`;
227
- // Horizontal rule
227
+ // Horizontal rule — subtle short separator, not full-width
228
228
  if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
229
- return `${p.muted}${"".repeat(this.contentWidth)}${p.reset}`;
229
+ return "";
230
230
  }
231
231
  // Blockquote
232
232
  const bq = line.match(/^>\s?(.*)/);
@@ -19,37 +19,23 @@
19
19
  export function createBlockTransform(bus, opts) {
20
20
  let buffer = "";
21
21
  bus.onPipe("agent:response-chunk", (e) => {
22
- // Process text from e.text and from text blocks in e.blocks
23
22
  const outBlocks = [];
24
- if (e.blocks) {
25
- for (const block of e.blocks) {
26
- if (block.type === "text") {
27
- // Run delimiter detection on text blocks
28
- buffer += block.text;
29
- const { blocks: parsed, pending } = processBuffer(buffer, opts);
30
- buffer = pending;
31
- outBlocks.push(...parsed);
32
- }
33
- else {
34
- // Pass through non-text blocks unchanged
35
- outBlocks.push(block);
36
- }
23
+ for (const block of e.blocks) {
24
+ if (block.type === "text") {
25
+ buffer += block.text;
26
+ const { blocks: parsed, pending } = processBuffer(buffer, opts);
27
+ buffer = pending;
28
+ outBlocks.push(...parsed);
29
+ }
30
+ else {
31
+ outBlocks.push(block);
37
32
  }
38
33
  }
39
- // Also process any raw text not yet in blocks
40
- if (e.text) {
41
- buffer += e.text;
42
- const { blocks: parsed, pending } = processBuffer(buffer, opts);
43
- buffer = pending;
44
- outBlocks.push(...parsed);
45
- }
46
- return { ...e, text: "", blocks: outBlocks };
34
+ return { blocks: outBlocks };
47
35
  });
48
36
  bus.onPipe("agent:response-done", (e) => {
49
37
  if (buffer) {
50
- // Unclosed pattern — flush as text
51
38
  bus.emitTransform("agent:response-chunk", {
52
- text: buffer,
53
39
  blocks: [{ type: "text", text: buffer }],
54
40
  });
55
41
  buffer = "";
@@ -66,35 +52,23 @@ export function createFencedBlockTransform(bus, opts) {
66
52
  bus.onPipe("agent:response-chunk", (e) => {
67
53
  if (flushing)
68
54
  return e; // pass through during flush to avoid re-buffering
69
- // Collect text from blocks or raw text
55
+ // Separate text blocks (to buffer) from non-text blocks (pass through)
70
56
  let incoming = "";
71
- if (e.blocks) {
72
- // Process text blocks, pass through non-text blocks
73
- const passthrough = [];
74
- for (const block of e.blocks) {
75
- if (block.type === "text") {
76
- incoming += block.text;
77
- }
78
- else {
79
- passthrough.push(block);
80
- }
57
+ const passthrough = [];
58
+ for (const block of e.blocks) {
59
+ if (block.type === "text") {
60
+ incoming += block.text;
61
+ }
62
+ else {
63
+ passthrough.push(block);
81
64
  }
82
- const { blocks, pending } = processFencedBuffer(buffer + incoming, opts, inFence, fenceMatch, fenceLines);
83
- buffer = pending.text;
84
- inFence = pending.inFence;
85
- fenceMatch = pending.fenceMatch;
86
- fenceLines = pending.fenceLines;
87
- return { ...e, text: "", blocks: [...passthrough, ...blocks] };
88
65
  }
89
- // No blocks yet work with raw text
90
- incoming = buffer + e.text;
91
- const { blocks, pending } = processFencedBuffer(incoming, opts, inFence, fenceMatch, fenceLines);
66
+ const { blocks, pending } = processFencedBuffer(buffer + incoming, opts, inFence, fenceMatch, fenceLines);
92
67
  buffer = pending.text;
93
68
  inFence = pending.inFence;
94
69
  fenceMatch = pending.fenceMatch;
95
70
  fenceLines = pending.fenceLines;
96
- const existing = e.blocks ?? [];
97
- return { ...e, text: "", blocks: [...existing, ...blocks] };
71
+ return { blocks: [...passthrough, ...blocks] };
98
72
  });
99
73
  function flushBuffer() {
100
74
  if (!buffer && !inFence)
@@ -110,7 +84,6 @@ export function createFencedBlockTransform(bus, opts) {
110
84
  if (remaining) {
111
85
  flushing = true;
112
86
  bus.emitTransform("agent:response-chunk", {
113
- text: "",
114
87
  blocks: [{ type: "text", text: remaining }],
115
88
  });
116
89
  flushing = false;
@@ -73,6 +73,15 @@ export function renderToolCall(tool, width) {
73
73
  if (typeof raw.command === "string") {
74
74
  detail = `$ ${raw.command}`;
75
75
  }
76
+ else if (typeof raw.pattern === "string") {
77
+ // grep/glob — show the search pattern
78
+ const target = typeof raw.path === "string" ? ` ${shortenPath(raw.path, cwd)}` : "";
79
+ detail = `${raw.pattern}${target}`;
80
+ }
81
+ else if (typeof raw.path === "string") {
82
+ // read_file, write_file, etc.
83
+ detail = shortenPath(raw.path, cwd);
84
+ }
76
85
  else if (typeof raw.operation === "string") {
77
86
  detail = raw.operation;
78
87
  if (raw.ids && Array.isArray(raw.ids)) {
@@ -83,20 +92,21 @@ export function renderToolCall(tool, width) {
83
92
  }
84
93
  }
85
94
  else {
86
- detail = formatRawInput(tool.rawInput, width - tool.title.length - 6);
95
+ detail = formatRawInput(tool.rawInput, width - 4);
87
96
  }
88
97
  }
89
98
  }
90
99
  }
91
- // Render as single line: title: detail
92
- const maxDetailW = Math.max(1, width - tool.title.length - 6);
100
+ // Render as single line: icon + detail (icon implies the tool type)
101
+ // Falls back to icon + title when no detail is available
102
+ const maxDetailW = Math.max(1, width - 4);
93
103
  if (detail) {
94
104
  if (detail.length > maxDetailW)
95
105
  detail = detail.slice(0, maxDetailW - 1) + "…";
96
- lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}${p.dim}: ${detail}${p.reset}`);
106
+ lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${detail}${p.reset}`);
97
107
  }
98
108
  else {
99
- lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
109
+ lines.push(`${p.warning}${icon} ${tool.title}${p.reset}`);
100
110
  }
101
111
  // Show additional file locations on separate lines (if more than one)
102
112
  if (mode === "full" && tool.locations && tool.locations.length > 1) {
@@ -0,0 +1,35 @@
1
+ # claude-code-bridge
2
+
3
+ Runs Claude Code as an agent-sh backend using the official [@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Copy or symlink into your extensions directory
9
+ cp -r examples/extensions/claude-code-bridge ~/.agent-sh/extensions/claude-code-bridge
10
+
11
+ # Install dependencies
12
+ cd ~/.agent-sh/extensions/claude-code-bridge
13
+ npm install
14
+ ```
15
+
16
+ ## Configure
17
+
18
+ Set as default backend in `~/.agent-sh/settings.json`:
19
+
20
+ ```json
21
+ {
22
+ "defaultBackend": "claude-code"
23
+ }
24
+ ```
25
+
26
+ Or switch at runtime:
27
+
28
+ ```
29
+ ? /backend claude-code
30
+ ```
31
+
32
+ ## Requirements
33
+
34
+ - `ANTHROPIC_API_KEY` must be set in your environment
35
+ - Claude Code manages its own model selection — no model configuration needed in agent-sh