pi-pier 0.2.4 → 0.2.5

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
  }
package/src/core/todo.ts CHANGED
@@ -13,12 +13,11 @@ import type { PiSurface } from '../pi-surface.ts';
13
13
  import type { TodosService } from '../todos-service.ts';
14
14
  import { planTodoReadHook } from '../todo-read-hook.ts';
15
15
  import { makeProgressUpdate } from '../subagent-core.ts';
16
- import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
16
+ import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
17
17
  import { formatAge, isArchived } from '../stale-core.ts';
18
18
  import {
19
19
  TODO_EDIT_CUSTOM_TYPE,
20
20
  completionTransitions,
21
- countTodos,
22
21
  fuzzyFind,
23
22
  listsEqual,
24
23
  makeSnapshot,
@@ -26,6 +25,7 @@ import {
26
25
  revertedCompleted,
27
26
  validateTodos,
28
27
  } from '../todo-core.ts';
28
+ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
29
29
 
30
30
  export interface TodoUiSlot {
31
31
  /** Lets index lifecycle events render through the currently mounted plugin. */
@@ -52,37 +52,9 @@ const TOOL_DESCRIPTION = [
52
52
  'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
53
53
  ].join(' ');
54
54
 
55
- const TODO_MARKS: Record<TodoStatus, string> = {
56
- pending: '○',
57
- in_progress: '▶',
58
- completed: '✓',
59
- blocked: '■',
60
- abandoned: '✗',
61
- };
62
-
63
55
  /** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
64
56
  export const WIDGET_MAX_LINES = 10;
65
57
 
66
- /** Share phase rendering between the widget and /todos so both views order entries identically. */
67
- function renderGroups(items: readonly TodoItem[]): string[] {
68
- const groups = new Map<string, TodoItem[]>();
69
- for (const it of items) {
70
- const key = it.phase ?? '';
71
- const arr = groups.get(key) ?? [];
72
- arr.push(it);
73
- groups.set(key, arr);
74
- }
75
- const lines: string[] = [];
76
- for (const [phase, list] of groups) {
77
- if (phase) lines.push(` [${phase}]`);
78
- for (const it of list) {
79
- const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
80
- lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
81
- }
82
- }
83
- return lines;
84
- }
85
-
86
58
  /**
87
59
  * Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
88
60
  * tail. The window anchors on the first in-progress item, then the last open item, then recent
@@ -91,21 +63,23 @@ function renderGroups(items: readonly TodoItem[]): string[] {
91
63
  */
92
64
  export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
93
65
  if (items.length === 0) return [];
94
- const c = countTodos(items);
95
66
  // Collapse archived plans to two lines so stale work cannot monopolize the widget.
96
67
  if (opts?.archivedAgeMs != null) {
97
68
  return [
98
- `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓ · archived ${formatAge(opts.archivedAgeMs)}`,
69
+ `${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
99
70
  ' archived — /todos 全量',
100
71
  ];
101
72
  }
102
73
 
103
74
  // Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
104
75
  const renderBudget = WIDGET_MAX_LINES - 1 - 1;
105
- const [start, end] = anchorRange(items, renderBudget);
76
+ const [start, end] = anchorTodoRange(
77
+ items,
78
+ (s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
79
+ );
106
80
  const kept = items.slice(start, end);
107
81
  const hidden = items.filter((_, i) => i < start || i >= end);
108
- const lines = [`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...renderGroups(kept)];
82
+ const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
109
83
  if (hidden.length > 0) {
110
84
  const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
111
85
  lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
@@ -113,44 +87,6 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
113
87
  return lines;
114
88
  }
115
89
 
116
- /**
117
- * Expand around the active anchor within the rendered-line budget, favoring upcoming work while
118
- * guaranteeing that the anchor remains visible.
119
- */
120
- function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
121
- if (renderGroups(items).length <= renderBudget) return [0, items.length];
122
- const anchor = items.findIndex((it) => it.status === 'in_progress');
123
- const anchorIdx = anchor >= 0
124
- ? anchor
125
- : items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
126
- if (anchorIdx < 0) {
127
- // Completed plans keep the most recent tail visible within the line budget.
128
- let end = items.length;
129
- while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
130
- return [0, end];
131
- }
132
- let start = anchorIdx;
133
- let end = anchorIdx + 1;
134
- const fits = () => renderGroups(items.slice(start, end)).length <= renderBudget;
135
- let growTail = true;
136
- while ((end < items.length || start > 0) && (end - start) < items.length) {
137
- if (growTail && end < items.length) {
138
- end += 1;
139
- if (!fits()) { end -= 1; break; }
140
- } else if (start > 0) {
141
- start -= 1;
142
- if (!fits()) { start += 1; break; }
143
- } else if (end < items.length) {
144
- end += 1;
145
- if (!fits()) { end -= 1; break; }
146
- } else {
147
- break;
148
- }
149
- growTail = !growTail; // Alternate sides so both context and upcoming work remain visible.
150
- }
151
- return [start, end];
152
- }
153
-
154
90
  export default function todoPlugin(ctx: Context): void {
155
91
  const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
156
92
  const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
@@ -372,14 +308,13 @@ export default function todoPlugin(ctx: Context): void {
372
308
  return;
373
309
  }
374
310
  // /todos is the unbounded view because the widget intentionally prioritizes active context.
375
- const body = renderGroups(todos.items);
311
+ const body = renderTodoGroups(todos.items);
376
312
  if (body.length === 0) {
377
313
  ui?.notify?.('todo list is empty', 'info');
378
314
  return;
379
315
  }
380
- const c = countTodos(todos.items);
381
316
  const age = archivedAgeMs();
382
- const head = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`
317
+ const head = formatTodoSummary(todos.items)
383
318
  + (age != null ? ` · archived ${formatAge(age)}(不再注入)` : '');
384
319
  ui?.notify?.([head, ...body].join('\n'), 'info');
385
320
  },