pi-pier 0.2.4 → 0.2.6

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.
@@ -99,258 +99,226 @@ export default function terminalPlugin(ctx: Context): void {
99
99
  const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
100
100
 
101
101
  scoped.registerTool({
102
- name: 'terminal_open',
103
- label: 'Terminal Open',
102
+ name: 'terminal',
103
+ label: 'Terminal',
104
104
  description: [
105
- 'Open a persistent interactive terminal (a resident shell in its own herdr pane, next to this session) and return its terminal_id.',
106
- 'The shell keeps its cwd, environment variables, and background processes across terminal_send/terminal_read calls — use it for dev servers, REPLs, or multi-step shell work instead of one-shot bash calls.',
107
- '`cwd` (optional): working directory for the shell (defaults to this session cwd).',
108
- 'Readiness: waits for the shell prompt (up to 30s); on timeout the terminal still opens — check with terminal_read before sending.',
109
- 'A human can also type into the pane directly; terminal_close closes it.',
105
+ 'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
106
+ 'Operations: open (create session), send (type commands), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
107
+ 'The shell keeps cwd, environment variables, and background processes across calls.',
108
+ 'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
110
109
  ].join(' '),
111
110
  parameters: Type.Object({
112
- cwd: Type.Optional(Type.String({ description: 'Working directory for the shell' })),
111
+ action: Type.Union([
112
+ Type.Literal('open'),
113
+ Type.Literal('send'),
114
+ Type.Literal('read'),
115
+ Type.Literal('signal'),
116
+ Type.Literal('close'),
117
+ Type.Literal('list'),
118
+ ], { description: 'Terminal operation to perform' }),
119
+ cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
120
+ terminal_id: Type.Optional(Type.String({ description: '[send|read|signal|close] Terminal id returned by open action' })),
121
+ text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
122
+ pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
123
+ max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
124
+ key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
113
125
  }),
114
126
  async execute(_tc, params, _sig, _upd, ctx) {
115
- if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
116
- const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
117
- : (ctx as { cwd?: string }).cwd ?? process.cwd();
118
- const r = registerTerminal(terminals, {
119
- paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
120
- tabId: env.tabId,
121
- cwd,
122
- createdAt: Date.now(),
123
- });
124
- if (!r.ok) return errText(r.error);
125
- let paneId: string;
126
- try {
127
- paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
128
- } catch (e) {
129
- return errText(`failed to split terminal pane: ${(e as Error).message}`);
130
- }
131
- r.entry.paneId = paneId;
132
- terminals = r.entries;
133
- persistTerminals();
134
- const prompt = promptStrategyFor();
135
- let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
136
- try {
137
- const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
138
- if (matched) readiness = 'prompt';
139
- else {
140
- const read = await client.readPane(paneId, { stripAnsi: false });
141
- readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
142
- }
143
- } catch {
144
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
127
+ const action = typeof params?.action === 'string' ? params.action : '';
128
+ switch (action) {
129
+ case 'open': return executeTerminalOpen(params, ctx);
130
+ case 'send': return executeTerminalSend(params);
131
+ case 'read': return executeTerminalRead(params);
132
+ case 'signal': return executeTerminalSignal(params);
133
+ case 'close': return executeTerminalClose(params);
134
+ case 'list': return executeTerminalList();
135
+ default:
136
+ return errText(action
137
+ ? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
138
+ : 'action is required (open, send, read, signal, close, list)');
145
139
  }
146
- const text = [
147
- `terminal ${r.entry.terminalId} open (pane ${paneId})`,
148
- `cwd: ${cwd}`,
149
- `readiness: ${readiness}${readiness !== 'prompt' ? ' (prompt not detected yet — check with terminal_read before sending)' : ''}`,
150
- ].join('\n');
151
- return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
152
140
  },
153
141
  });
154
142
 
155
- scoped.registerTool({
156
- name: 'terminal_send',
157
- label: 'Terminal Send',
158
- description: [
159
- 'Send a text command (plain text only, no ANSI escapes) to a persistent terminal opened with terminal_open; Enter is appended automatically.',
160
- 'Returns after the keystrokes are delivered — poll terminal_read for output (long-running commands keep producing output across reads).',
161
- 'For control keys use terminal_signal instead. Interactive/fullscreen programs (vim, less, top) are outside this seam.',
162
- ].join(' '),
163
- parameters: Type.Object({
164
- terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
165
- text: Type.String({ description: 'Command text to type and run' }),
166
- }),
167
- async execute(_tc, params) {
168
- const entry = findOpenTerminal(params?.terminal_id);
169
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see terminal_list)`);
170
- const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
171
- if (!v.ok) return errText(v.error);
172
- try {
173
- await client.sendPaneText(entry.paneId, v.text);
174
- } catch (e) {
175
- return errText(`send failed (pane may be closed): ${(e as Error).message}`);
143
+ async function executeTerminalOpen(params, ctx) {
144
+ if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
145
+ const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
146
+ : (ctx as { cwd?: string }).cwd ?? process.cwd();
147
+ const r = registerTerminal(terminals, {
148
+ paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
149
+ tabId: env.tabId,
150
+ cwd,
151
+ createdAt: Date.now(),
152
+ });
153
+ if (!r.ok) return errText(r.error);
154
+ let paneId: string;
155
+ try {
156
+ paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
157
+ } catch (e) {
158
+ return errText(`failed to split terminal pane: ${(e as Error).message}`);
159
+ }
160
+ r.entry.paneId = paneId;
161
+ terminals = r.entries;
162
+ persistTerminals();
163
+ const prompt = promptStrategyFor();
164
+ let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
165
+ try {
166
+ const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
167
+ if (matched) readiness = 'prompt';
168
+ else {
169
+ const read = await client.readPane(paneId, { stripAnsi: false });
170
+ readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
176
171
  }
177
- touchTerminal(entry);
178
- return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
179
- },
180
- });
172
+ } catch {
173
+ /* Readiness is advisory, so probe failures still leave a usable terminal. */
174
+ }
175
+ const text = [
176
+ `terminal ${r.entry.terminalId} open (pane ${paneId})`,
177
+ `cwd: ${cwd}`,
178
+ `readiness: ${readiness}${readiness !== 'prompt' ? ' (prompt not detected yet — check with action read before sending)' : ''}`,
179
+ ].join('\n');
180
+ return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
181
+ }
181
182
 
182
- scoped.registerTool({
183
- name: 'terminal_read',
184
- label: 'Terminal Read',
185
- description: [
186
- 'Read new output from a persistent terminal since your last read (cursor-based increment; the first read returns the recent buffer).',
187
- '`terminal_id`: your terminal from terminal_open. Alternatively `pane_id`: a pane in this session\'s own tab (narrow scope by design).',
188
- 'Output is ANSI-stripped and bounded; if the buffer scrolled past your last read you get a reset marker plus the current tail.',
189
- 'Detecting a fullscreen TUI program (vim/less/top) returns an error with suggestions instead of garbage.',
190
- ].join(' '),
191
- parameters: Type.Object({
192
- terminal_id: Type.Optional(Type.String({ description: 'Terminal id from terminal_open' })),
193
- pane_id: Type.Optional(Type.String({ description: 'Direct pane read (own tab panes only)' })),
194
- max_chars: Type.Optional(Type.Number({ description: 'Output cap for this read' })),
195
- }),
196
- async execute(_tc, params) {
197
- let paneId: string | null = null;
198
- let entry: TerminalEntry | null = null;
199
- if (typeof params?.terminal_id === 'string') {
200
- entry = findOpenTerminal(params.terminal_id);
201
- if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see terminal_list)`);
202
- paneId = entry.paneId;
203
- } else if (typeof params?.pane_id === 'string') {
204
- // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
205
- try {
206
- const panes = await client.listPanes();
207
- const target = panes.find((p) => p.paneId === params.pane_id);
208
- const ownTab = target?.tabId && target.tabId === env?.tabId;
209
- const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
210
- if (!target || (!ownTab && !ownTerminal)) {
211
- return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
212
- }
213
- paneId = params.pane_id;
214
- } catch (e) {
215
- return errText(`pane lookup failed: ${(e as Error).message}`);
216
- }
217
- } else {
218
- return errText('provide terminal_id (or pane_id for a direct own-tab read)');
219
- }
220
- const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
221
- ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
222
- let read: { text: string; revision: number; truncated: boolean };
223
- try {
224
- read = await client.readPane(paneId, { stripAnsi: false });
225
- } catch (e) {
226
- return errText(`read failed (pane may be closed): ${(e as Error).message}`);
227
- }
228
- // T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
229
- const tui = detectFullscreenTUI(read.text);
230
- if (tui.detected) {
231
- const head = stripAnsi(read.text).slice(0, 200);
232
- const text = [
233
- 'FULLSCREEN_TUI_DETECTED: this terminal is running a fullscreen program (e.g. vim/less/top); its screen output is not readable here.',
234
- 'Suggestions:',
235
- '1. Run such programs in a native herdr pane where the human can interact directly.',
236
- '2. Use non-interactive alternatives (cat/grep instead of less; edit/write tools instead of vim).',
237
- `Raw head (first 200 chars): ${head}`,
238
- ].join('\n');
239
- return { content: [{ type: 'text', text }], details: { error: 'FULLSCREEN_TUI_DETECTED' } };
240
- }
241
- const prev: ReadCursor | null = entry
242
- ? (entry.readRevision != null
243
- ? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
244
- : null)
245
- : null;
246
- const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
247
- if (entry) {
248
- entry.readRevision = inc.cursor.revision;
249
- entry.readLen = inc.cursor.len;
250
- entry.readTail = inc.cursor.tail;
251
- entry.readEoTail = inc.cursor.eoTail;
252
- touchTerminal(entry);
253
- }
254
- if (inc.mode === 'none') {
255
- return { content: [{ type: 'text', text: 'no new output' }], details: { mode: 'none', revision: read.revision } };
256
- }
257
- const output = stripAnsi(inc.text);
258
- const text = output.length ? output : '(empty)';
259
- return {
260
- content: [{ type: 'text', text }],
261
- details: { mode: inc.mode, revision: read.revision, hard_capped: inc.hardCapped, source_revision_truncated: read.truncated },
262
- };
263
- },
264
- });
183
+ async function executeTerminalSend(params) {
184
+ const entry = findOpenTerminal(params?.terminal_id);
185
+ if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
186
+ const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
187
+ if (!v.ok) return errText(v.error);
188
+ try {
189
+ await client.sendPaneText(entry.paneId, v.text);
190
+ } catch (e) {
191
+ return errText(`send failed (pane may be closed): ${(e as Error).message}`);
192
+ }
193
+ touchTerminal(entry);
194
+ return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
195
+ }
265
196
 
266
- scoped.registerTool({
267
- name: 'terminal_signal',
268
- label: 'Terminal Signal',
269
- description: [
270
- 'Send a control key to a persistent terminal: ctrl+c (interrupt), ctrl+d (EOF), ctrl+z (suspend), esc, enter.',
271
- 'Use ctrl+c to stop a running command; anything beyond this whitelist (complex key sequences) is outside the terminal seam.',
272
- ].join(' '),
273
- parameters: Type.Object({
274
- terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
275
- key: Type.String({ description: 'One of: ctrl+c, ctrl+d, ctrl+z, esc, enter' }),
276
- }),
277
- async execute(_tc, params) {
278
- const entry = findOpenTerminal(params?.terminal_id);
279
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see terminal_list)`);
280
- const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
281
- if (!v.ok) return errText(v.error);
197
+ async function executeTerminalRead(params) {
198
+ let paneId: string | null = null;
199
+ let entry: TerminalEntry | null = null;
200
+ if (typeof params?.terminal_id === 'string') {
201
+ entry = findOpenTerminal(params.terminal_id);
202
+ if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
203
+ paneId = entry.paneId;
204
+ } else if (typeof params?.pane_id === 'string') {
205
+ // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
282
206
  try {
283
- await client.sendPaneKeys(entry.paneId, [v.key]);
207
+ const panes = await client.listPanes();
208
+ const target = panes.find((p) => p.paneId === params.pane_id);
209
+ const ownTab = target?.tabId && target.tabId === env?.tabId;
210
+ const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
211
+ if (!target || (!ownTab && !ownTerminal)) {
212
+ return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
213
+ }
214
+ paneId = params.pane_id;
284
215
  } catch (e) {
285
- return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
216
+ return errText(`pane lookup failed: ${(e as Error).message}`);
286
217
  }
218
+ } else {
219
+ return errText('provide terminal_id (or pane_id for a direct own-tab read)');
220
+ }
221
+ const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
222
+ ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
223
+ let read: { text: string; revision: number; truncated: boolean };
224
+ try {
225
+ read = await client.readPane(paneId, { stripAnsi: false });
226
+ } catch (e) {
227
+ return errText(`read failed (pane may be closed): ${(e as Error).message}`);
228
+ }
229
+ // T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
230
+ const tui = detectFullscreenTUI(read.text);
231
+ if (tui.detected) {
232
+ const head = stripAnsi(read.text).slice(0, 200);
233
+ const text = [
234
+ 'FULLSCREEN_TUI_DETECTED: this terminal is running a fullscreen program (e.g. vim/less/top); its screen output is not readable here.',
235
+ 'Suggestions:',
236
+ '1. Run such programs in a native herdr pane where the human can interact directly.',
237
+ '2. Use non-interactive alternatives (cat/grep instead of less; edit/write tools instead of vim).',
238
+ `Raw head (first 200 chars): ${head}`,
239
+ ].join('\n');
240
+ return { content: [{ type: 'text', text }], details: { error: 'FULLSCREEN_TUI_DETECTED' } };
241
+ }
242
+ const prev: ReadCursor | null = entry
243
+ ? (entry.readRevision != null
244
+ ? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
245
+ : null)
246
+ : null;
247
+ const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
248
+ if (entry) {
249
+ entry.readRevision = inc.cursor.revision;
250
+ entry.readLen = inc.cursor.len;
251
+ entry.readTail = inc.cursor.tail;
252
+ entry.readEoTail = inc.cursor.eoTail;
287
253
  touchTerminal(entry);
288
- return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
289
- },
290
- });
254
+ }
255
+ if (inc.mode === 'none') {
256
+ return { content: [{ type: 'text', text: 'no new output' }], details: { mode: 'none', revision: read.revision } };
257
+ }
258
+ const output = stripAnsi(inc.text);
259
+ const text = output.length ? output : '(empty)';
260
+ return {
261
+ content: [{ type: 'text', text }],
262
+ details: { mode: inc.mode, revision: read.revision, hard_capped: inc.hardCapped, source_revision_truncated: read.truncated },
263
+ };
264
+ }
291
265
 
292
- scoped.registerTool({
293
- name: 'terminal_close',
294
- label: 'Terminal Close',
295
- description: [
296
- 'Close a persistent terminal (kills its shell process tree and removes the pane).',
297
- 'Terminal state does not survive close — persist anything you need to files first.',
298
- ].join(' '),
299
- parameters: Type.Object({
300
- terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
301
- }),
302
- async execute(_tc, params) {
303
- const id = params?.terminal_id;
304
- const entry = terminals.find((t) => t.terminalId === id);
305
- if (!entry) return errText(`unknown terminal "${String(id)}" (see terminal_list)`);
306
- if (entry.status === 'open') {
307
- try {
308
- await client.closePane(entry.paneId);
309
- } catch {
310
- /* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
311
- }
312
- }
313
- terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
314
- persistTerminals();
315
- return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
316
- },
317
- });
266
+ async function executeTerminalSignal(params) {
267
+ const entry = findOpenTerminal(params?.terminal_id);
268
+ if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
269
+ const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
270
+ if (!v.ok) return errText(v.error);
271
+ try {
272
+ await client.sendPaneKeys(entry.paneId, [v.key]);
273
+ } catch (e) {
274
+ return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
275
+ }
276
+ touchTerminal(entry);
277
+ return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
278
+ }
318
279
 
319
- scoped.registerTool({
320
- name: 'terminal_list',
321
- label: 'Terminal List',
322
- description: [
323
- 'List this session\'s persistent terminals with open/closed and pane-liveness status.',
324
- 'Entries whose pane is gone (human closed it, restart) are marked closed — terminal sessions do not survive pane closure or restarts; persist state to files instead of relying on terminal reuse.',
325
- ].join(' '),
326
- parameters: Type.Object({}),
327
- async execute() {
328
- let livePaneIds: string[] = [];
280
+ async function executeTerminalClose(params) {
281
+ const id = params?.terminal_id;
282
+ const entry = terminals.find((t) => t.terminalId === id);
283
+ if (!entry) return errText(`unknown terminal "${String(id)}" (see action list)`);
284
+ if (entry.status === 'open') {
329
285
  try {
330
- livePaneIds = (await client.listPanes()).map((p) => p.paneId);
286
+ await client.closePane(entry.paneId);
331
287
  } catch {
332
- /* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
333
- }
334
- const s = summarizeSessions(terminals, livePaneIds);
335
- // T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
336
- if (s.stalePaneIds.length > 0) {
337
- terminals = terminals.map((t) =>
338
- s.stalePaneIds.includes(t.paneId)
339
- ? { ...t, status: 'closed' as const, closedAt: t.closedAt ?? Date.now() }
340
- : t,
341
- );
342
- persistTerminals();
288
+ /* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
343
289
  }
344
- if (s.terminals.length === 0) {
345
- return { content: [{ type: 'text', text: 'no terminals (open one with terminal_open)' }], details: { terminals: [] } };
346
- }
347
- const lines = s.terminals.map((t) =>
348
- `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
290
+ }
291
+ terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
292
+ persistTerminals();
293
+ return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
294
+ }
295
+
296
+ async function executeTerminalList() {
297
+ let livePaneIds: string[] = [];
298
+ try {
299
+ livePaneIds = (await client.listPanes()).map((p) => p.paneId);
300
+ } catch {
301
+ /* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
302
+ }
303
+ const s = summarizeSessions(terminals, livePaneIds);
304
+ // T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
305
+ if (s.stalePaneIds.length > 0) {
306
+ terminals = terminals.map((t) =>
307
+ s.stalePaneIds.includes(t.paneId)
308
+ ? { ...t, status: 'closed' as const, closedAt: t.closedAt ?? Date.now() }
309
+ : t,
349
310
  );
350
- const note = s.stalePaneIds.length > 0
351
- ? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
352
- : '';
353
- return { content: [{ type: 'text', text: lines.join('\n') + note }], details: { terminals: s.terminals } };
354
- },
355
- });
311
+ persistTerminals();
312
+ }
313
+ if (s.terminals.length === 0) {
314
+ return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
315
+ }
316
+ const lines = s.terminals.map((t) =>
317
+ `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
318
+ );
319
+ const note = s.stalePaneIds.length > 0
320
+ ? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
321
+ : '';
322
+ return { content: [{ type: 'text', text: lines.join('\n') + note }], details: { terminals: s.terminals } };
323
+ }
356
324
  }