agent-sh 0.3.1 → 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 (78) hide show
  1. package/README.md +66 -96
  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 +84 -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 +111 -53
  46. package/dist/index.js +124 -120
  47. package/dist/input-handler.d.ts +17 -8
  48. package/dist/input-handler.js +152 -45
  49. package/dist/output-parser.d.ts +7 -0
  50. package/dist/output-parser.js +27 -0
  51. package/dist/settings.d.ts +53 -2
  52. package/dist/settings.js +45 -2
  53. package/dist/shell.js +36 -27
  54. package/dist/types.d.ts +46 -6
  55. package/dist/utils/box-frame.d.ts +3 -1
  56. package/dist/utils/box-frame.js +12 -5
  57. package/dist/utils/line-editor.js +4 -0
  58. package/dist/utils/llm-client.d.ts +45 -0
  59. package/dist/utils/llm-client.js +60 -0
  60. package/dist/utils/markdown.js +2 -2
  61. package/dist/utils/stream-transform.js +20 -47
  62. package/dist/utils/tool-display.js +15 -5
  63. package/examples/extensions/claude-code-bridge/README.md +35 -0
  64. package/examples/extensions/claude-code-bridge/index.ts +198 -0
  65. package/examples/extensions/claude-code-bridge/package.json +11 -0
  66. package/examples/extensions/openrouter.ts +87 -0
  67. package/examples/extensions/pi-bridge/README.md +35 -0
  68. package/examples/extensions/pi-bridge/index.ts +265 -0
  69. package/examples/extensions/pi-bridge/package.json +13 -0
  70. package/examples/extensions/subagents.ts +87 -0
  71. package/package.json +3 -5
  72. package/dist/acp-client.d.ts +0 -100
  73. package/dist/acp-client.js +0 -656
  74. package/dist/extensions/shell-exec.d.ts +0 -24
  75. package/dist/extensions/shell-exec.js +0 -188
  76. package/dist/mcp-server.d.ts +0 -13
  77. package/dist/mcp-server.js +0 -234
  78. package/examples/pi-agent-sh.ts +0 -166
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Claude Code bridge — runs Claude Code Agent SDK in-process as agent-sh's backend.
3
+ *
4
+ * Uses the official @anthropic-ai/claude-agent-sdk to spawn a Claude Code
5
+ * session with a custom user_shell MCP tool for PTY access. Claude Code
6
+ * handles its own model selection, tool execution, and permissions.
7
+ *
8
+ * Setup:
9
+ * npm install @anthropic-ai/claude-agent-sdk
10
+ *
11
+ * Usage:
12
+ * agent-sh -e examples/extensions/claude-code-bridge
13
+ *
14
+ * Requires: Claude Code CLI installed and authenticated (claude login).
15
+ */
16
+ import {
17
+ query,
18
+ tool,
19
+ createSdkMcpServer,
20
+ type Query,
21
+ } from "@anthropic-ai/claude-agent-sdk";
22
+ import { z } from "zod";
23
+ import type { ExtensionContext } from "../../src/types.js";
24
+ import type { EventBus } from "../../src/event-bus.js";
25
+
26
+ // ── user_shell MCP tool ───────────────────────────────────────────
27
+ function createUserShellTool(bus: EventBus) {
28
+ let liveCwd = process.cwd();
29
+ bus.on("shell:cwd-change", ({ cwd }) => { liveCwd = cwd; });
30
+
31
+ return tool(
32
+ "user_shell",
33
+ "Run a command in the user's live shell (visible in terminal). " +
34
+ "Use for cd, export, source, or commands the user wants to see. " +
35
+ "Set return_output=true only if you need to inspect the result.",
36
+ {
37
+ command: z.string().describe("Command to execute in user's shell"),
38
+ return_output: z.boolean().optional().describe(
39
+ "Whether to return the command output. Default false.",
40
+ ),
41
+ },
42
+ async (args) => {
43
+ const result = await bus.emitPipeAsync("shell:exec-request", {
44
+ command: args.command,
45
+ output: "",
46
+ cwd: liveCwd,
47
+ done: false,
48
+ });
49
+
50
+ const text = args.return_output
51
+ ? result.output || "(no output)"
52
+ : "Command executed.";
53
+
54
+ return { content: [{ type: "text" as const, text }] };
55
+ },
56
+ );
57
+ }
58
+
59
+ // ── Extension entry point ─────────────────────────────────────────
60
+ export default function activate(ctx: ExtensionContext): void {
61
+ const { bus } = ctx;
62
+
63
+ const shellTool = createUserShellTool(bus);
64
+ const shellServer = createSdkMcpServer({
65
+ name: "agent-sh",
66
+ version: "1.0.0",
67
+ tools: [shellTool],
68
+ });
69
+
70
+ let activeQuery: Query | null = null;
71
+ const listeners: Array<{ event: string; fn: Function }> = [];
72
+
73
+ const wireListeners = () => {
74
+ const onSubmit = async ({ query: userQuery, modeInstruction, modeLabel }: any) => {
75
+ const prompt = modeInstruction
76
+ ? `${modeInstruction}\n${userQuery}`
77
+ : userQuery;
78
+
79
+ bus.emit("agent:query", { query: userQuery, modeLabel });
80
+ bus.emit("agent:processing-start", {});
81
+
82
+ let fullResponseText = "";
83
+ let streamed = false;
84
+
85
+ try {
86
+ activeQuery = query({
87
+ prompt,
88
+ options: {
89
+ cwd: process.cwd(),
90
+ systemPrompt: {
91
+ type: "preset",
92
+ preset: "claude_code",
93
+ append:
94
+ "You are running inside agent-sh, a terminal wrapper.\n" +
95
+ "EXECUTE mode ('>'): Use your standard tools. Do NOT use user_shell.\n" +
96
+ "HELP mode ('?'): Run the command via mcp__agent-sh__user_shell. Just run it, no explanation.\n" +
97
+ "Each prompt includes a per-query mode instruction — follow it.",
98
+ },
99
+ mcpServers: { "agent-sh": shellServer },
100
+ allowedTools: [
101
+ "mcp__agent-sh__user_shell",
102
+ "Read", "Edit", "Write", "Bash", "Glob", "Grep",
103
+ ],
104
+ permissionMode: "acceptEdits",
105
+ includePartialMessages: true,
106
+ },
107
+ });
108
+
109
+ for await (const message of activeQuery) {
110
+ switch (message.type) {
111
+ case "stream_event": {
112
+ streamed = true;
113
+ const event = message.event;
114
+ if (event.type === "content_block_delta") {
115
+ const delta = event.delta as any;
116
+ if (delta.type === "text_delta" && delta.text) {
117
+ bus.emitTransform("agent:response-chunk", {
118
+ blocks: [{ type: "text" as const, text: delta.text }],
119
+ });
120
+ fullResponseText += delta.text;
121
+ } else if (delta.type === "thinking_delta" && delta.thinking) {
122
+ bus.emit("agent:thinking-chunk", { text: delta.thinking });
123
+ }
124
+ }
125
+ break;
126
+ }
127
+
128
+ case "assistant": {
129
+ const msg = message.message;
130
+ for (const block of msg.content) {
131
+ const b = block as any;
132
+ if (b.type === "text" && b.text && !streamed) {
133
+ bus.emitTransform("agent:response-chunk", {
134
+ blocks: [{ type: "text" as const, text: b.text }],
135
+ });
136
+ fullResponseText += b.text;
137
+ } else if (b.type === "tool_use") {
138
+ bus.emit("agent:tool-started", {
139
+ title: b.name,
140
+ toolCallId: b.id,
141
+ kind: b.name.includes("shell") || b.name === "Bash"
142
+ ? "execute"
143
+ : "read",
144
+ });
145
+ }
146
+ }
147
+ break;
148
+ }
149
+
150
+ case "result":
151
+ break;
152
+ }
153
+ }
154
+
155
+ bus.emitTransform("agent:response-done", {
156
+ response: fullResponseText,
157
+ });
158
+ } catch (err) {
159
+ bus.emit("agent:error", {
160
+ message: err instanceof Error ? err.message : String(err),
161
+ });
162
+ } finally {
163
+ activeQuery = null;
164
+ bus.emit("agent:processing-done", {});
165
+ }
166
+ };
167
+
168
+ const onCancel = () => { activeQuery?.interrupt(); };
169
+ const onReset = () => { /* each query() is a new session */ };
170
+
171
+ bus.on("agent:submit", onSubmit);
172
+ bus.on("agent:cancel-request", onCancel);
173
+ bus.on("agent:reset-session", onReset);
174
+ listeners.push(
175
+ { event: "agent:submit", fn: onSubmit },
176
+ { event: "agent:cancel-request", fn: onCancel },
177
+ { event: "agent:reset-session", fn: onReset },
178
+ );
179
+ };
180
+
181
+ const unwireListeners = () => {
182
+ for (const { event, fn } of listeners) bus.off(event as any, fn as any);
183
+ listeners.length = 0;
184
+ };
185
+
186
+ // ── Register as backend ───────────────────────────────────────
187
+ bus.emit("agent:register-backend", {
188
+ name: "claude-code",
189
+ start: async () => {
190
+ wireListeners();
191
+ bus.emit("agent:info", { name: "claude-code", version: "1.0" });
192
+ },
193
+ kill: () => {
194
+ activeQuery?.interrupt();
195
+ unwireListeners();
196
+ },
197
+ });
198
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "agent-sh-claude-code-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code agent backend for agent-sh",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "dependencies": {
8
+ "@anthropic-ai/claude-agent-sdk": "^0.2.0",
9
+ "zod": "^4.0.0"
10
+ }
11
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * OpenRouter provider extension.
3
+ *
4
+ * Registers OpenRouter as a provider and fetches its full model catalog
5
+ * at startup. Models appear in /model autocomplete as "model [openrouter]"
6
+ * and are available for cycling with Shift+Tab.
7
+ *
8
+ * Model capabilities (reasoning, context window) are read from the
9
+ * OpenRouter API response — no hardcoded model lists.
10
+ *
11
+ * Setup:
12
+ * export OPENROUTER_API_KEY="your-key"
13
+ *
14
+ * Usage:
15
+ * agent-sh -e ./examples/extensions/openrouter.ts
16
+ *
17
+ * # Or add to settings.json:
18
+ * { "extensions": ["./examples/extensions/openrouter.ts"] }
19
+ */
20
+ import type { ExtensionContext } from "agent-sh/types";
21
+
22
+ const BASE_URL = "https://openrouter.ai/api/v1";
23
+ const API_KEY = process.env.OPENROUTER_API_KEY ?? "";
24
+
25
+ /** Curated default models — used immediately while the full catalog loads. */
26
+ const DEFAULT_MODELS = [
27
+ "anthropic/claude-sonnet-4",
28
+ "google/gemini-2.5-pro-preview",
29
+ "openai/gpt-4.1",
30
+ "deepseek/deepseek-r1",
31
+ "meta-llama/llama-4-maverick",
32
+ ];
33
+
34
+ interface OpenRouterModel {
35
+ id: string;
36
+ name: string;
37
+ context_length?: number;
38
+ supported_parameters?: string[];
39
+ pricing?: { prompt: string; completion: string };
40
+ }
41
+
42
+ export default function activate({ bus }: ExtensionContext): void {
43
+ if (!API_KEY) {
44
+ bus.emit("ui:error", {
45
+ message: "OpenRouter extension: OPENROUTER_API_KEY not set. Skipping.",
46
+ });
47
+ return;
48
+ }
49
+
50
+ // Register provider immediately with curated defaults
51
+ bus.emit("provider:register", {
52
+ id: "openrouter",
53
+ apiKey: API_KEY,
54
+ baseURL: BASE_URL,
55
+ defaultModel: DEFAULT_MODELS[0],
56
+ models: DEFAULT_MODELS,
57
+ });
58
+
59
+ // Fetch full model catalog in background, re-register with capabilities
60
+ fetchModels().then((models) => {
61
+ if (models.length > 0) {
62
+ bus.emit("provider:register", {
63
+ id: "openrouter",
64
+ apiKey: API_KEY,
65
+ baseURL: BASE_URL,
66
+ defaultModel: DEFAULT_MODELS[0],
67
+ supportsReasoningEffort: true,
68
+ models: models.map((m) => ({
69
+ id: m.id,
70
+ reasoning: m.supported_parameters?.includes("reasoning") ?? false,
71
+ contextWindow: m.context_length,
72
+ })),
73
+ });
74
+ }
75
+ }).catch(() => {
76
+ // Silently fall back to curated defaults
77
+ });
78
+ }
79
+
80
+ async function fetchModels(): Promise<OpenRouterModel[]> {
81
+ const res = await fetch(`${BASE_URL}/models`, {
82
+ headers: { Authorization: `Bearer ${API_KEY}` },
83
+ });
84
+ if (!res.ok) return [];
85
+ const data = await res.json();
86
+ return (data.data ?? []) as OpenRouterModel[];
87
+ }
@@ -0,0 +1,35 @@
1
+ # pi-bridge
2
+
3
+ Runs [pi](https://github.com/nickarora/pi)'s full coding agent as an agent-sh backend. Uses pi's own configuration, models, tools, and extensions — agent-sh just provides the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Copy or symlink into your extensions directory
9
+ cp -r examples/extensions/pi-bridge ~/.agent-sh/extensions/pi-bridge
10
+
11
+ # Install dependencies
12
+ cd ~/.agent-sh/extensions/pi-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": "pi"
23
+ }
24
+ ```
25
+
26
+ Or switch at runtime:
27
+
28
+ ```
29
+ ? /backend pi
30
+ ```
31
+
32
+ ## Requirements
33
+
34
+ - pi must be configured separately (`~/.pi/settings.json`) with API keys and model preferences
35
+ - agent-sh does not override pi's configuration — it uses whatever pi is set up with
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Pi bridge — runs pi's full coding agent in-process as agent-sh's backend.
3
+ *
4
+ * Uses pi's own AgentSession with its full configuration: model registry,
5
+ * provider settings, extensions, session management, and tool system.
6
+ * Agent-sh provides the shell frontend and TUI rendering.
7
+ *
8
+ * In addition to pi's built-in tools, this bridge registers `user_shell`
9
+ * so pi can execute commands in agent-sh's live PTY (visible to the user,
10
+ * affects shell state like cd/export/source).
11
+ *
12
+ * Setup:
13
+ * npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent
14
+ *
15
+ * Usage:
16
+ * agent-sh -e examples/extensions/pi-bridge
17
+ */
18
+ import type { AgentEvent } from "@mariozechner/pi-agent-core";
19
+ import {
20
+ createAgentSessionServices,
21
+ createAgentSessionFromServices,
22
+ createAgentSessionRuntime,
23
+ SessionManager,
24
+ } from "@mariozechner/pi-coding-agent";
25
+ import { Type } from "@sinclair/typebox";
26
+ import type { ExtensionContext } from "../../src/types.js";
27
+ import type { EventBus } from "../../src/event-bus.js";
28
+
29
+ // ── agent-sh context injected via tool promptGuidelines + promptSnippet ──
30
+
31
+ // ── user_shell as a pi ToolDefinition ─────────────────────────────
32
+ function createUserShellToolDef(bus: EventBus) {
33
+ // Track agent-sh's live cwd so user_shell always runs in the right place
34
+ let liveCwd = process.cwd();
35
+ bus.on("shell:cwd-change", ({ cwd }) => { liveCwd = cwd; });
36
+
37
+ const schema = Type.Object({
38
+ command: Type.String({ description: "Command to execute in user's shell" }),
39
+ return_output: Type.Optional(
40
+ Type.Boolean({
41
+ description:
42
+ "Whether to return the command output. Default false — output is shown directly to the user.",
43
+ }),
44
+ ),
45
+ });
46
+
47
+ return {
48
+ name: "user_shell",
49
+ label: "user_shell",
50
+ description:
51
+ "Run a command in the user's live shell (visible in terminal). " +
52
+ "Use for cd, export, source, or commands the user wants to see. " +
53
+ "Output is shown directly to the user. Set return_output=true only " +
54
+ "if you need to inspect the result.",
55
+ promptSnippet: "Execute commands in the user's live terminal (PTY). Use in HELP mode.",
56
+ promptGuidelines: [
57
+ "You are running inside agent-sh, a terminal wrapper with two interaction modes.",
58
+ "EXECUTE mode (triggered by '>'): Use your standard tools (bash, file ops). Do NOT use user_shell.",
59
+ "HELP mode (triggered by '?'): Run the command via user_shell. Do not explain or confirm — just run it.",
60
+ "Each prompt includes a per-query mode instruction — follow it.",
61
+ "user_shell executes in the user's actual shell (their aliases, env vars, cwd). Use bash for background work.",
62
+ ],
63
+ parameters: schema,
64
+
65
+ async execute(_toolCallId, params) {
66
+ const command = params.command;
67
+ const returnOutput = params.return_output ?? false;
68
+
69
+ const result = await bus.emitPipeAsync("shell:exec-request", {
70
+ command,
71
+ output: "",
72
+ cwd: liveCwd,
73
+ done: false,
74
+ });
75
+
76
+ const text = returnOutput
77
+ ? result.output || "(no output)"
78
+ : "Command executed.";
79
+
80
+ return { content: [{ type: "text", text }], details: undefined };
81
+ },
82
+ };
83
+ }
84
+
85
+ // ── Extension entry point ─────────────────────────────────────────
86
+ export default function activate(ctx: ExtensionContext): void {
87
+ const { bus } = ctx;
88
+ const cwd = process.cwd();
89
+
90
+ const userShellTool = createUserShellToolDef(bus);
91
+
92
+ // ── Boot pi session (async — register backend synchronously first) ──
93
+ let session: any = null;
94
+ let runtime: any = null;
95
+ let booting = true;
96
+
97
+ const boot = async () => {
98
+ try {
99
+ // Pi loads its own config: ~/.pi/agent/settings.json, models, extensions
100
+ const services = await createAgentSessionServices({ cwd });
101
+ const sessionManager = SessionManager.inMemory(cwd);
102
+
103
+ // createRuntime factory — returns { session, services, ... } as expected
104
+ // by createAgentSessionRuntime
105
+ const createRuntime = async (opts: any) => {
106
+ const result = await createAgentSessionFromServices({
107
+ services,
108
+ sessionManager: opts.sessionManager ?? sessionManager,
109
+ customTools: [userShellTool],
110
+ });
111
+ return { ...result, services };
112
+ };
113
+
114
+ runtime = await createAgentSessionRuntime(createRuntime, {
115
+ cwd,
116
+ sessionManager,
117
+ });
118
+ session = runtime.session;
119
+
120
+ // Subscribe to pi events → agent-sh bus
121
+ let fullResponseText = "";
122
+
123
+ session.subscribe((event: AgentEvent) => {
124
+ switch (event.type) {
125
+ case "agent_start":
126
+ fullResponseText = "";
127
+ break;
128
+
129
+ case "message_update": {
130
+ const ame = (event as any).assistantMessageEvent;
131
+ if (ame.type === "text_delta") {
132
+ bus.emitTransform("agent:response-chunk", {
133
+ blocks: [{ type: "text" as const, text: ame.delta }],
134
+ });
135
+ fullResponseText += ame.delta;
136
+ } else if (ame.type === "thinking_delta") {
137
+ bus.emit("agent:thinking-chunk", { text: ame.delta });
138
+ }
139
+ break;
140
+ }
141
+
142
+ case "tool_execution_start":
143
+ bus.emit("agent:tool-started", {
144
+ title: (event as any).toolName,
145
+ toolCallId: (event as any).toolCallId,
146
+ kind: (event as any).toolName === "user_shell" || (event as any).toolName === "bash"
147
+ ? "execute"
148
+ : "read",
149
+ });
150
+ break;
151
+
152
+ case "tool_execution_update": {
153
+ const pr = (event as any).partialResult as
154
+ | { content?: Array<{ type: string; text?: string }> }
155
+ | undefined;
156
+ if (pr?.content) {
157
+ for (const c of pr.content) {
158
+ if (c.type === "text" && c.text) {
159
+ bus.emit("agent:tool-output-chunk", { chunk: c.text });
160
+ }
161
+ }
162
+ }
163
+ break;
164
+ }
165
+
166
+ case "tool_execution_end":
167
+ bus.emit("agent:tool-completed", {
168
+ toolCallId: (event as any).toolCallId,
169
+ exitCode: (event as any).isError ? 1 : 0,
170
+ kind: (event as any).toolName === "user_shell" || (event as any).toolName === "bash"
171
+ ? "execute"
172
+ : "read",
173
+ });
174
+ break;
175
+
176
+ case "agent_end":
177
+ bus.emitTransform("agent:response-done", {
178
+ response: fullResponseText,
179
+ });
180
+ bus.emit("agent:processing-done", {});
181
+ break;
182
+ }
183
+ });
184
+
185
+ // Report model info
186
+ const model = session.model;
187
+ bus.emit("agent:info", {
188
+ name: "pi",
189
+ version: "0.66",
190
+ model: model ? `${model.provider}/${model.id}` : undefined,
191
+ });
192
+
193
+ booting = false;
194
+ } catch (err) {
195
+ booting = false;
196
+ bus.emit("ui:error", {
197
+ message: `pi-bridge: failed to initialize — ${err instanceof Error ? err.message : String(err)}`,
198
+ });
199
+ }
200
+ };
201
+
202
+ // ── Bus listeners (wired on start, unwired on kill) ────────────
203
+ const listeners: Array<{ event: string; fn: Function }> = [];
204
+
205
+ const wireListeners = () => {
206
+ const onSubmit = async ({ query, modeInstruction, modeLabel }: any) => {
207
+ if (!session) {
208
+ bus.emit("agent:error", {
209
+ message: booting ? "pi is still starting up..." : "pi session not initialized",
210
+ });
211
+ bus.emit("agent:processing-done", {});
212
+ return;
213
+ }
214
+
215
+ const prompt = modeInstruction ? `${modeInstruction}\n${query}` : query;
216
+ bus.emit("agent:query", { query, modeLabel });
217
+ bus.emit("agent:processing-start", {});
218
+
219
+ try {
220
+ await session.prompt(prompt);
221
+ } catch (err) {
222
+ bus.emit("agent:error", {
223
+ message: err instanceof Error ? err.message : String(err),
224
+ });
225
+ bus.emit("agent:processing-done", {});
226
+ }
227
+ };
228
+
229
+ const onCancel = async () => { await session?.abort(); };
230
+ const onReset = async () => {
231
+ await runtime?.newSession();
232
+ session = runtime?.session;
233
+ };
234
+
235
+ bus.on("agent:submit", onSubmit);
236
+ bus.on("agent:cancel-request", onCancel);
237
+ bus.on("agent:reset-session", onReset);
238
+ listeners.push(
239
+ { event: "agent:submit", fn: onSubmit },
240
+ { event: "agent:cancel-request", fn: onCancel },
241
+ { event: "agent:reset-session", fn: onReset },
242
+ );
243
+ };
244
+
245
+ const unwireListeners = () => {
246
+ for (const { event, fn } of listeners) bus.off(event as any, fn as any);
247
+ listeners.length = 0;
248
+ };
249
+
250
+ // ── Register as backend ───────────────────────────────────────
251
+ bus.emit("agent:register-backend", {
252
+ name: "pi",
253
+ start: async () => {
254
+ await boot();
255
+ wireListeners();
256
+ },
257
+ kill: () => {
258
+ unwireListeners();
259
+ runtime?.dispose();
260
+ session = null;
261
+ runtime = null;
262
+ booting = true;
263
+ },
264
+ });
265
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "agent-sh-pi-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Pi coding agent backend for agent-sh",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "dependencies": {
8
+ "@mariozechner/pi-agent-core": "^0.66.0",
9
+ "@mariozechner/pi-ai": "^0.66.0",
10
+ "@mariozechner/pi-coding-agent": "^0.66.0",
11
+ "@sinclair/typebox": "^0.34.0"
12
+ }
13
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Subagent extension — lets the main agent spawn focused sub-agents.
3
+ *
4
+ * The main agent gets a `spawn_agent` tool that creates a fresh agent
5
+ * with its own context. The LLM decides how to specialize — no
6
+ * predefined categories, no registry, no config.
7
+ *
8
+ * Usage:
9
+ * agent-sh -e ./examples/extensions/subagents.ts
10
+ */
11
+ import type { ExtensionContext } from "../../src/types.js";
12
+ import { runSubagent } from "../../src/agent/subagent.js";
13
+
14
+ export default function activate(ctx: ExtensionContext): void {
15
+ const { bus, llmClient, contextManager } = ctx;
16
+ if (!llmClient) return;
17
+
18
+ const allToolNames = () => ctx.getTools().map(t => t.name);
19
+
20
+ ctx.registerTool({
21
+ name: "spawn_agent",
22
+ description:
23
+ "Spawn a subagent with its own fresh context to handle a focused task. " +
24
+ "Use this to delegate work that needs investigation or multiple tool calls, " +
25
+ "without polluting your main conversation context. " +
26
+ "The subagent runs to completion and returns its result.",
27
+ input_schema: {
28
+ type: "object",
29
+ properties: {
30
+ task: {
31
+ type: "string",
32
+ description: "Clear description of what the subagent should do",
33
+ },
34
+ tools: {
35
+ type: "array",
36
+ items: { type: "string" },
37
+ description: `Tool names the subagent can use. Available: ${allToolNames().join(", ")}`,
38
+ },
39
+ },
40
+ required: ["task"],
41
+ },
42
+
43
+ showOutput: false,
44
+
45
+ getDisplayInfo: () => ({
46
+ kind: "execute",
47
+ }),
48
+
49
+ async execute(args) {
50
+ const task = args.task as string;
51
+ const toolNames = args.tools as string[] | undefined;
52
+
53
+ const allTools = ctx.getTools();
54
+ // Filter to requested tools, or give all tools (minus spawn_agent to prevent recursion)
55
+ const tools = toolNames
56
+ ? allTools.filter(t => toolNames.includes(t.name))
57
+ : allTools.filter(t => t.name !== "spawn_agent");
58
+
59
+ const systemPrompt =
60
+ `You are a focused subagent. Complete the task and return a clear, concise result.\n` +
61
+ `Working directory: ${contextManager.getCwd()}`;
62
+
63
+ try {
64
+ const result = await runSubagent({
65
+ llmClient,
66
+ tools,
67
+ systemPrompt,
68
+ task,
69
+ bus,
70
+ maxIterations: 25,
71
+ });
72
+
73
+ return {
74
+ content: result || "(no response)",
75
+ exitCode: 0,
76
+ isError: false,
77
+ };
78
+ } catch (err) {
79
+ return {
80
+ content: `Subagent error: ${err instanceof Error ? err.message : String(err)}`,
81
+ exitCode: 1,
82
+ isError: true,
83
+ };
84
+ }
85
+ },
86
+ });
87
+ }