agent-sh 0.7.0 → 0.9.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 (86) hide show
  1. package/README.md +28 -33
  2. package/dist/agent/agent-loop.d.ts +31 -8
  3. package/dist/agent/agent-loop.js +277 -66
  4. package/dist/agent/conversation-state.d.ts +41 -9
  5. package/dist/agent/conversation-state.js +340 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +176 -0
  10. package/dist/agent/system-prompt.d.ts +4 -5
  11. package/dist/agent/system-prompt.js +16 -11
  12. package/dist/agent/token-budget.d.ts +13 -0
  13. package/dist/agent/token-budget.js +50 -0
  14. package/dist/agent/tool-protocol.d.ts +83 -0
  15. package/dist/agent/tool-protocol.js +386 -0
  16. package/dist/agent/tools/user-shell.js +4 -1
  17. package/dist/agent/types.d.ts +21 -1
  18. package/dist/context-manager.d.ts +0 -1
  19. package/dist/context-manager.js +5 -110
  20. package/dist/core.d.ts +7 -7
  21. package/dist/core.js +76 -180
  22. package/dist/event-bus.d.ts +40 -0
  23. package/dist/event-bus.js +20 -1
  24. package/dist/extension-loader.d.ts +5 -0
  25. package/dist/extension-loader.js +104 -17
  26. package/dist/extensions/agent-backend.d.ts +13 -0
  27. package/dist/extensions/agent-backend.js +167 -0
  28. package/dist/extensions/command-suggest.d.ts +3 -3
  29. package/dist/extensions/command-suggest.js +4 -3
  30. package/dist/extensions/index.d.ts +19 -0
  31. package/dist/extensions/index.js +25 -0
  32. package/dist/extensions/slash-commands.d.ts +1 -1
  33. package/dist/extensions/slash-commands.js +44 -1
  34. package/dist/extensions/terminal-buffer.d.ts +1 -1
  35. package/dist/extensions/terminal-buffer.js +22 -8
  36. package/dist/extensions/tui-renderer.js +177 -122
  37. package/dist/index.js +14 -20
  38. package/dist/settings.d.ts +25 -2
  39. package/dist/settings.js +25 -4
  40. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  41. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  42. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  43. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  44. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  45. package/dist/{shell.js → shell/shell.js} +24 -6
  46. package/dist/types.d.ts +49 -32
  47. package/dist/utils/ansi.d.ts +10 -0
  48. package/dist/utils/ansi.js +27 -0
  49. package/dist/utils/compositor.d.ts +62 -0
  50. package/dist/utils/compositor.js +88 -0
  51. package/dist/utils/diff-renderer.js +92 -4
  52. package/dist/utils/floating-panel.d.ts +34 -3
  53. package/dist/utils/floating-panel.js +315 -82
  54. package/dist/utils/handler-registry.d.ts +26 -10
  55. package/dist/utils/handler-registry.js +52 -16
  56. package/dist/utils/line-editor.d.ts +32 -3
  57. package/dist/utils/line-editor.js +218 -36
  58. package/dist/utils/markdown.d.ts +1 -0
  59. package/dist/utils/markdown.js +4 -4
  60. package/dist/utils/message-utils.d.ts +35 -0
  61. package/dist/utils/message-utils.js +75 -0
  62. package/dist/utils/terminal-buffer.d.ts +9 -1
  63. package/dist/utils/terminal-buffer.js +31 -2
  64. package/dist/utils/tool-display.d.ts +1 -0
  65. package/dist/utils/tool-display.js +1 -1
  66. package/dist/utils/tool-interactive.d.ts +12 -0
  67. package/dist/utils/tool-interactive.js +53 -0
  68. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  69. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  70. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  71. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  72. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  73. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  74. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  75. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  76. package/examples/extensions/interactive-prompts.ts +82 -110
  77. package/examples/extensions/overlay-agent.ts +84 -38
  78. package/examples/extensions/peer-mesh.ts +450 -0
  79. package/examples/extensions/pi-bridge/index.ts +87 -2
  80. package/examples/extensions/questionnaire.ts +249 -0
  81. package/examples/extensions/tmux-pane.ts +307 -0
  82. package/examples/extensions/web-access.ts +327 -0
  83. package/package.json +9 -1
  84. package/dist/extensions/overlay-agent.d.ts +0 -11
  85. package/dist/extensions/overlay-agent.js +0 -43
  86. package/examples/extensions/terminal-buffer.ts +0 -184
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Questionnaire tool — the agent can ask the user one or more questions.
3
+ *
4
+ * Single question: simple option list with arrow key navigation.
5
+ * Multiple questions: tab bar navigation between questions.
6
+ *
7
+ * Usage:
8
+ * agent-sh -e ./examples/extensions/questionnaire.ts
9
+ */
10
+ import type { ExtensionContext } from "agent-sh/types";
11
+ import type { InteractiveSession, ToolExecutionContext } from "agent-sh/agent/types.js";
12
+ import { palette as p } from "agent-sh/utils/palette.js";
13
+
14
+ // ── Key matching ─────────────────────────────────────────────────
15
+
16
+ function isKey(data: string, key: string): boolean {
17
+ switch (key) {
18
+ case "up": return data === "\x1b[A" || data === "\x1bOA";
19
+ case "down": return data === "\x1b[B" || data === "\x1bOB";
20
+ case "left": return data === "\x1b[D" || data === "\x1bOD";
21
+ case "right": return data === "\x1b[C" || data === "\x1bOC";
22
+ case "enter": return data === "\r" || data === "\n";
23
+ case "escape": return data === "\x1b";
24
+ case "tab": return data === "\t";
25
+ default: return data === key;
26
+ }
27
+ }
28
+
29
+ // ── Types ────────────────────────────────────────────────────────
30
+
31
+ interface QuestionOption {
32
+ value: string;
33
+ label: string;
34
+ description?: string;
35
+ }
36
+
37
+ interface Question {
38
+ id: string;
39
+ label: string;
40
+ prompt: string;
41
+ options: QuestionOption[];
42
+ }
43
+
44
+ interface Answer {
45
+ id: string;
46
+ value: string;
47
+ label: string;
48
+ index: number;
49
+ }
50
+
51
+ interface QuestionnaireResult {
52
+ answers: Answer[];
53
+ cancelled: boolean;
54
+ }
55
+
56
+ // ── Extension ────────────────────────────────────────────────────
57
+
58
+ export default function activate({ registerTool }: ExtensionContext) {
59
+ registerTool({
60
+ name: "questionnaire",
61
+ displayName: "questionnaire",
62
+ description:
63
+ "Ask the user one or more questions with selectable options. " +
64
+ "Use for clarifying requirements, getting preferences, or confirming decisions. " +
65
+ "For single questions, shows a simple option list. " +
66
+ "For multiple questions, shows a tab-based interface.",
67
+ input_schema: {
68
+ type: "object",
69
+ properties: {
70
+ questions: {
71
+ type: "array",
72
+ description: "Questions to ask the user",
73
+ items: {
74
+ type: "object",
75
+ properties: {
76
+ id: { type: "string", description: "Unique identifier" },
77
+ label: { type: "string", description: "Short label for tab bar (defaults to Q1, Q2)" },
78
+ prompt: { type: "string", description: "The full question text" },
79
+ options: {
80
+ type: "array",
81
+ items: {
82
+ type: "object",
83
+ properties: {
84
+ value: { type: "string", description: "Value returned when selected" },
85
+ label: { type: "string", description: "Display label" },
86
+ description: { type: "string", description: "Optional description" },
87
+ },
88
+ required: ["value", "label"],
89
+ },
90
+ },
91
+ },
92
+ required: ["id", "prompt", "options"],
93
+ },
94
+ },
95
+ },
96
+ required: ["questions"],
97
+ },
98
+
99
+ async execute(args, _onChunk, ctx?: ToolExecutionContext) {
100
+ if (!ctx?.ui) {
101
+ return { content: "Error: interactive UI not available", exitCode: 1, isError: true };
102
+ }
103
+
104
+ const rawQuestions = args.questions as any[];
105
+ if (!rawQuestions?.length) {
106
+ return { content: "Error: no questions provided", exitCode: 1, isError: true };
107
+ }
108
+
109
+ const questions: Question[] = rawQuestions.map((q, i) => ({
110
+ ...q,
111
+ label: q.label || `Q${i + 1}`,
112
+ }));
113
+
114
+ const result = await ctx.ui.custom<QuestionnaireResult>(
115
+ createSession(questions),
116
+ );
117
+
118
+ if (result.cancelled) {
119
+ return { content: "User cancelled the questionnaire.", exitCode: 1, isError: false };
120
+ }
121
+
122
+ const lines = result.answers.map((a) => {
123
+ const q = questions.find((q) => q.id === a.id);
124
+ return `${q?.label ?? a.id}: ${a.index + 1}. ${a.label}`;
125
+ });
126
+
127
+ return { content: lines.join("\n"), exitCode: 0, isError: false };
128
+ },
129
+
130
+ getDisplayInfo: () => ({ kind: "execute" as const, icon: "?" }),
131
+
132
+ formatCall(args) {
133
+ const qs = (args.questions as any[]) ?? [];
134
+ const labels = qs.map((q: any) => q.label || q.id).join(", ");
135
+ return `${qs.length} question${qs.length !== 1 ? "s" : ""}${labels ? ` (${labels})` : ""}`;
136
+ },
137
+ });
138
+ }
139
+
140
+ // ── Interactive session ──────────────────────────────────────────
141
+
142
+ function createSession(questions: Question[]): InteractiveSession<QuestionnaireResult> {
143
+ const isMulti = questions.length > 1;
144
+ let tab = 0;
145
+ let optionIdx = 0;
146
+ const answers = new Map<string, Answer>();
147
+
148
+ return {
149
+ render(width) {
150
+ const w = Math.min(80, width);
151
+ const lines: string[] = [];
152
+ const q = questions[tab];
153
+
154
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
155
+
156
+ // Tab bar for multi-question
157
+ if (isMulti) {
158
+ const tabs: string[] = [];
159
+ for (let i = 0; i < questions.length; i++) {
160
+ const answered = answers.has(questions[i].id);
161
+ const active = i === tab;
162
+ const box = answered ? "■" : "□";
163
+ const label = ` ${box} ${questions[i].label} `;
164
+ tabs.push(active
165
+ ? `${p.accent}${p.bold}${label}${p.reset}`
166
+ : `${p.muted}${label}${p.reset}`);
167
+ }
168
+ lines.push(` ${tabs.join(" ")}`);
169
+ lines.push("");
170
+ }
171
+
172
+ // Question + options
173
+ if (q) {
174
+ lines.push(` ${q.prompt}`);
175
+ lines.push("");
176
+ for (let i = 0; i < q.options.length; i++) {
177
+ const opt = q.options[i];
178
+ const sel = i === optionIdx;
179
+ const prefix = sel ? `${p.accent}> ${p.reset}` : " ";
180
+ lines.push(`${prefix}${sel ? p.accent : ""}${i + 1}. ${opt.label}${sel ? p.reset : ""}`);
181
+ if (opt.description) {
182
+ lines.push(` ${p.muted}${opt.description}${p.reset}`);
183
+ }
184
+ }
185
+ }
186
+
187
+ lines.push("");
188
+ lines.push(isMulti
189
+ ? ` ${p.dim}Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel${p.reset}`
190
+ : ` ${p.dim}↑↓ navigate • Enter select • Esc cancel${p.reset}`);
191
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
192
+
193
+ return lines;
194
+ },
195
+
196
+ handleInput(data, done) {
197
+ const q = questions[tab];
198
+
199
+ if (isKey(data, "escape")) {
200
+ done({ answers: [], cancelled: true });
201
+ return;
202
+ }
203
+
204
+ // Tab navigation
205
+ if (isMulti) {
206
+ if (isKey(data, "tab") || isKey(data, "right")) {
207
+ tab = (tab + 1) % questions.length;
208
+ optionIdx = 0;
209
+ return;
210
+ }
211
+ if (isKey(data, "left")) {
212
+ tab = (tab - 1 + questions.length) % questions.length;
213
+ optionIdx = 0;
214
+ return;
215
+ }
216
+ }
217
+
218
+ if (!q) return;
219
+
220
+ if (isKey(data, "up")) {
221
+ optionIdx = Math.max(0, optionIdx - 1);
222
+ return;
223
+ }
224
+ if (isKey(data, "down")) {
225
+ optionIdx = Math.min(q.options.length - 1, optionIdx + 1);
226
+ return;
227
+ }
228
+
229
+ if (isKey(data, "enter")) {
230
+ const opt = q.options[optionIdx];
231
+ answers.set(q.id, { id: q.id, value: opt.value, label: opt.label, index: optionIdx });
232
+
233
+ if (!isMulti) {
234
+ done({ answers: Array.from(answers.values()), cancelled: false });
235
+ return;
236
+ }
237
+
238
+ // Advance to next unanswered or finish
239
+ const unanswered = questions.findIndex((q) => !answers.has(q.id));
240
+ if (unanswered === -1) {
241
+ done({ answers: Array.from(answers.values()), cancelled: false });
242
+ } else {
243
+ tab = unanswered;
244
+ optionIdx = 0;
245
+ }
246
+ }
247
+ },
248
+ };
249
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Tmux side-pane extension.
3
+ *
4
+ * Two modes:
5
+ * /split — agent output renders in the side pane, queries typed
6
+ * in the main shell (> prompt).
7
+ * /rsplit — reverse split: the side pane has its own input prompt,
8
+ * the agent can see and control the main pane via
9
+ * terminal_read / terminal_keys.
10
+ *
11
+ * Both modes use createRemoteSession() which handles compositor
12
+ * routing, shell lifecycle, and chrome suppression automatically.
13
+ *
14
+ * Usage:
15
+ * ash -e ./examples/extensions/tmux-pane.ts
16
+ *
17
+ * # Or install permanently
18
+ * cp examples/extensions/tmux-pane.ts ~/.agent-sh/extensions/
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as net from "node:net";
22
+ import * as os from "node:os";
23
+ import * as path from "node:path";
24
+ import { execSync, spawn } from "node:child_process";
25
+ import type { ExtensionContext, RenderSurface, RemoteSession } from "agent-sh/types";
26
+
27
+ // ── Helpers ─────────────────────────────────────────────────────
28
+
29
+ function inTmux(): boolean {
30
+ return !!process.env.TMUX;
31
+ }
32
+
33
+ function tmux(...args: string[]): string {
34
+ return execSync(
35
+ "tmux " + args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" "),
36
+ { encoding: "utf-8" },
37
+ ).trim();
38
+ }
39
+
40
+ function getPaneWidth(paneId: string): number {
41
+ try {
42
+ return parseInt(tmux("display-message", "-p", "-t", paneId, "#{pane_width}"), 10) || 80;
43
+ } catch {
44
+ return 80;
45
+ }
46
+ }
47
+
48
+ function paneExists(paneId: string): boolean {
49
+ try {
50
+ tmux("display-message", "-p", "-t", paneId, "#{pane_id}");
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ // ── Chat client script (runs in rsplit pane) ────────────────────
58
+
59
+ const CHAT_CLIENT_SCRIPT = `
60
+ const net = require("net");
61
+ const readline = require("readline");
62
+
63
+ const sockPath = process.argv[2];
64
+ if (!sockPath) { console.error("No socket path"); process.exit(1); }
65
+
66
+ const sock = net.createConnection(sockPath);
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
+
69
+ sock.on("data", (data) => {
70
+ readline.clearLine(process.stdout, 0);
71
+ readline.cursorTo(process.stdout, 0);
72
+ process.stdout.write(data.toString());
73
+ rl.prompt(true);
74
+ });
75
+
76
+ sock.on("end", () => process.exit(0));
77
+ sock.on("error", () => process.exit(1));
78
+
79
+ rl.setPrompt("\\x1b[36m❯\\x1b[0m ");
80
+ rl.prompt();
81
+
82
+ rl.on("line", (line) => {
83
+ const trimmed = line.trim();
84
+ if (!trimmed) { rl.prompt(); return; }
85
+ sock.write(trimmed + "\\n");
86
+ });
87
+
88
+ rl.on("close", () => { sock.end(); process.exit(0); });
89
+ `;
90
+
91
+ // ── Surface factory ─────────────────────────────────────────────
92
+
93
+ function createSurface(
94
+ paneId: string,
95
+ ttyFd: fs.WriteStream,
96
+ socketClient: () => net.Socket | undefined,
97
+ ): RenderSurface {
98
+ let cachedWidth = getPaneWidth(paneId);
99
+ let lastWidthCheck = Date.now();
100
+
101
+ return {
102
+ write(text: string): void {
103
+ // In rsplit mode, route through socket so client can manage prompt
104
+ const c = socketClient();
105
+ if (c && !c.destroyed) {
106
+ try { c.write(text); } catch {}
107
+ return;
108
+ }
109
+ // In split mode (or fallback), write directly to tty
110
+ if (ttyFd.destroyed) return;
111
+ try { ttyFd.write(text); } catch {}
112
+ },
113
+ writeLine(line: string): void {
114
+ this.write(line + "\n");
115
+ },
116
+ get columns(): number {
117
+ const now = Date.now();
118
+ if (now - lastWidthCheck > 2000) {
119
+ cachedWidth = getPaneWidth(paneId);
120
+ lastWidthCheck = now;
121
+ }
122
+ return cachedWidth;
123
+ },
124
+ };
125
+ }
126
+
127
+ // ── Pane state ──────────────────────────────────────────────────
128
+
129
+ type PaneMode = "split" | "rsplit";
130
+
131
+ interface PaneState {
132
+ mode: PaneMode;
133
+ paneId: string;
134
+ ttyFd: fs.WriteStream;
135
+ session: RemoteSession;
136
+ // rsplit-mode only
137
+ server?: net.Server;
138
+ client?: net.Socket;
139
+ sockPath?: string;
140
+ scriptPath?: string;
141
+ }
142
+
143
+ // ── Extension ───────────────────────────────────────────────────
144
+
145
+ export default function activate(ctx: ExtensionContext): void {
146
+ const { bus, registerCommand, registerInstruction, createRemoteSession } = ctx;
147
+
148
+ if (!inTmux()) return;
149
+
150
+ let state: PaneState | null = null;
151
+
152
+ registerInstruction("Tmux Interactive Session", [
153
+ "When the dynamic context includes `interactive-session: true`, the user is chatting",
154
+ "with you in a side pane next to their terminal. They may have a program running in",
155
+ "the other pane (vim, htop, a REPL, etc.). In this mode:",
156
+ "- Use terminal_read to see what's on their screen.",
157
+ "- Use terminal_keys to interact with their running program.",
158
+ "- Use user_shell only for standalone commands, not for interacting with what's on screen.",
159
+ "- Keep responses concise.",
160
+ ].join("\n"));
161
+
162
+ // ── Open / close ──────────────────────────────────────────────
163
+
164
+ function openSplit(): void {
165
+ if (state) close();
166
+
167
+ try {
168
+ const paneId = tmux(
169
+ "split-window", "-h", "-l", "45%",
170
+ "-P", "-F", "#{pane_id}", "cat",
171
+ ).trim();
172
+ execSync("sleep 0.1");
173
+
174
+ const tty = tmux("display-message", "-p", "-t", paneId, "#{pane_tty}");
175
+ const ttyFd = fs.createWriteStream(tty, { flags: "w" });
176
+ ttyFd.on("error", () => destroyStale());
177
+
178
+ const surface = createSurface(paneId, ttyFd, () => undefined);
179
+ const session = createRemoteSession({ surface });
180
+
181
+ state = { mode: "split", paneId, ttyFd, session };
182
+ surface.writeLine("\x1b[2m── agent output ──\x1b[0m\n");
183
+ bus.emit("ui:info", { message: "Split pane opened (/split to close, /rsplit for interactive)." });
184
+ } catch (e) {
185
+ bus.emit("ui:error", {
186
+ message: `Failed to open split: ${e instanceof Error ? e.message : String(e)}`,
187
+ });
188
+ }
189
+ }
190
+
191
+ function openRsplit(): void {
192
+ if (state) close();
193
+
194
+ try {
195
+ const sockPath = path.join(os.tmpdir(), `agent-sh-chat-${process.pid}.sock`);
196
+ try { fs.unlinkSync(sockPath); } catch {}
197
+
198
+ let client: net.Socket | undefined;
199
+
200
+ const server = net.createServer((conn) => {
201
+ client = conn;
202
+ if (state) state.client = conn;
203
+ conn.on("data", (data) => {
204
+ for (const line of data.toString().split("\n")) {
205
+ const trimmed = line.trim();
206
+ if (trimmed) session.submit(trimmed);
207
+ }
208
+ });
209
+ conn.on("end", () => { client = undefined; if (state) state.client = undefined; });
210
+ conn.on("error", () => { client = undefined; if (state) state.client = undefined; });
211
+ });
212
+ server.listen(sockPath);
213
+
214
+ const scriptPath = path.join(os.tmpdir(), `agent-sh-chat-${process.pid}.js`);
215
+ fs.writeFileSync(scriptPath, CHAT_CLIENT_SCRIPT);
216
+
217
+ const paneId = tmux(
218
+ "split-window", "-h", "-l", "45%",
219
+ "-P", "-F", "#{pane_id}",
220
+ "node", scriptPath, sockPath,
221
+ ).trim();
222
+ execSync("sleep 0.2");
223
+
224
+ const tty = tmux("display-message", "-p", "-t", paneId, "#{pane_tty}");
225
+ const ttyFd = fs.createWriteStream(tty, { flags: "w" });
226
+ ttyFd.on("error", () => destroyStale());
227
+
228
+ const surface = createSurface(paneId, ttyFd, () => client);
229
+ const session = createRemoteSession({
230
+ surface,
231
+ suppressQueryBox: true,
232
+ interactive: true,
233
+ });
234
+
235
+ state = { mode: "rsplit", paneId, ttyFd, session, server, client, sockPath, scriptPath };
236
+ bus.emit("ui:info", { message: "Reverse split opened (/rsplit to close, /split for output-only)." });
237
+ } catch (e) {
238
+ bus.emit("ui:error", {
239
+ message: `Failed to open rsplit: ${e instanceof Error ? e.message : String(e)}`,
240
+ });
241
+ if (state) close();
242
+ }
243
+ }
244
+
245
+ function close(): void {
246
+ if (!state) return;
247
+ const s = state;
248
+ state = null;
249
+
250
+ s.session.close();
251
+ if (s.client) { try { s.client.end(); } catch {} }
252
+ if (s.server) { try { s.server.close(); } catch {} }
253
+ try { s.ttyFd.end(); } catch {}
254
+ try { tmux("kill-pane", "-t", s.paneId); } catch {}
255
+ if (s.sockPath) { try { fs.unlinkSync(s.sockPath); } catch {} }
256
+ if (s.scriptPath) { try { fs.unlinkSync(s.scriptPath); } catch {} }
257
+ }
258
+
259
+ function destroyStale(): void {
260
+ if (!state) return;
261
+ const s = state;
262
+ state = null;
263
+
264
+ s.session.close();
265
+ if (s.client) { try { s.client.end(); } catch {} }
266
+ if (s.server) { try { s.server.close(); } catch {} }
267
+ try { s.ttyFd.end(); } catch {}
268
+ if (s.sockPath) { try { fs.unlinkSync(s.sockPath); } catch {} }
269
+ if (s.scriptPath) { try { fs.unlinkSync(s.scriptPath); } catch {} }
270
+ }
271
+
272
+ // ── Commands ──────────────────────────────────────────────────
273
+
274
+ registerCommand("split", "Toggle tmux side pane for agent output", (args) => {
275
+ const cmd = args.trim().toLowerCase();
276
+ if (cmd === "close") return close();
277
+ if (cmd === "open") return openSplit();
278
+ if (state?.mode === "split") close(); else openSplit();
279
+ });
280
+
281
+ registerCommand("rsplit", "Toggle interactive tmux side pane (reverse split)", (args) => {
282
+ const cmd = args.trim().toLowerCase();
283
+ if (cmd === "close") return close();
284
+ if (cmd === "open") return openRsplit();
285
+ if (state?.mode === "rsplit") close(); else openRsplit();
286
+ });
287
+
288
+ // ── Lifecycle events ──────────────────────────────────────────
289
+
290
+ // In split mode, redraw prompt immediately after query submit.
291
+ bus.on("agent:query", () => {
292
+ if (state?.mode !== "split") return;
293
+ setImmediate(() => bus.emit("shell:pty-write", { data: "\n" }));
294
+ });
295
+
296
+ // In rsplit mode, re-prompt the client after agent finishes.
297
+ bus.on("agent:processing-done", () => {
298
+ if (!state) return;
299
+ if (!paneExists(state.paneId)) { destroyStale(); return; }
300
+ if (state.mode === "rsplit" && state.client && !state.client.destroyed) {
301
+ state.client.write("\n");
302
+ }
303
+ state.session.surface.writeLine("");
304
+ });
305
+
306
+ process.on("exit", () => { if (state) close(); });
307
+ }