pi-pier 0.2.6 → 0.3.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 (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -10,9 +10,15 @@ import { Context } from '@deepseek-ai/cordis';
10
10
  import { Type } from 'typebox';
11
11
  import type { PiSurface } from '../pi-surface.ts';
12
12
  import type { HerdrClientLike } from '../herdr-client.ts';
13
+ import { herdrUnavailableHint } from '../herdr-client.ts';
14
+ import { toolError } from '../tool-error.ts'
15
+
16
+ /** Raw tool arguments: every field is validated inside the action handlers. */
17
+ type ToolParams = Record<string, unknown> | undefined;;
13
18
  import {
14
19
  READINESS_TIMEOUT_MS,
15
20
  READ_MAX_CHARS,
21
+ TERM_REMINDER_CUSTOM_TYPE,
16
22
  TERMINALS_CUSTOM_TYPE,
17
23
  activeTerminalPaneIds,
18
24
  classifyReadiness,
@@ -21,15 +27,20 @@ import {
21
27
  detectFullscreenTUI,
22
28
  foldTerminalsRegistry,
23
29
  makeTerminalsRegistry,
30
+ planIdleTerminalReminder,
31
+ planShellInit,
24
32
  promptStrategyFor,
25
33
  registerTerminal,
26
34
  stripAnsi,
27
35
  summarizeSessions,
36
+ terminalReminderGraceMs,
28
37
  validateSendText,
29
38
  validateSignal,
30
39
  type ReadCursor,
31
40
  type TerminalEntry,
32
41
  } from '../terminal-core.ts';
42
+ import { swallow } from '../swallow.ts';
43
+ import { pierOption } from '../pier-options.ts';
33
44
 
34
45
  export interface TerminalStateSlot {
35
46
  /** Lets index GC preserve panes that host active terminals. */
@@ -60,7 +71,8 @@ export default function terminalPlugin(ctx: Context): void {
60
71
  function persistTerminals(): void {
61
72
  try {
62
73
  pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
63
- } catch {
74
+ } catch (err) {
75
+ swallow('terminal.persist-registry', err);
64
76
  /* Persistence is best-effort because terminal operation must not depend on session logging. */
65
77
  }
66
78
  }
@@ -95,53 +107,166 @@ export default function terminalPlugin(ctx: Context): void {
95
107
  rebuildTerminals(eventCtx);
96
108
  });
97
109
 
98
- const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
99
- const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
110
+ // Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
111
+ // terminals are persistent by design, so a master that exits without closing them leaks live
112
+ // zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
113
+ // switching branches cannot kill shells that the registry replays on the next session_start.
114
+ scoped.on('session_shutdown', async () => {
115
+ idleReminderTimer && clearTimeout(idleReminderTimer);
116
+ idleReminderTimer = null;
117
+ const open = terminals.filter((t) => t.status === 'open');
118
+ if (open.length === 0) return;
119
+ await Promise.allSettled(open.map((t) => client.closePane(t.paneId)));
120
+ const now = Date.now();
121
+ terminals = terminals.map((t) =>
122
+ t.status === 'open' ? { ...t, status: 'closed' as const, closedAt: now } : t,
123
+ );
124
+ persistTerminals();
125
+ });
126
+
127
+ // Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
128
+ // a one-shot background compile left a dead split in the main tab forever). Same delivery
129
+ // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
130
+ const piSend = surface.raw as {
131
+ sendMessage?: (
132
+ message: { customType: string; content: string; display?: boolean },
133
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
134
+ ) => Promise<void>;
135
+ };
136
+ let idleReminders = 0;
137
+ let idleReminderTimer: NodeJS.Timeout | null = null;
138
+ scoped.on('agent_start', () => {
139
+ if (idleReminderTimer !== null) {
140
+ clearTimeout(idleReminderTimer);
141
+ idleReminderTimer = null;
142
+ }
143
+ });
144
+ scoped.on('agent_settled', async () => {
145
+ if (idleReminderTimer !== null) {
146
+ clearTimeout(idleReminderTimer);
147
+ idleReminderTimer = null;
148
+ }
149
+ const plan = planIdleTerminalReminder({
150
+ open: terminals.filter((t) => t.status === 'open'),
151
+ now: Date.now(),
152
+ reminders: idleReminders,
153
+ });
154
+ if (!plan.due || plan.content == null) return;
155
+ // 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
156
+ // notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
157
+ // agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
158
+ const now = Date.now();
159
+ const ids = new Set(plan.ids);
160
+ terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
161
+ persistTerminals();
162
+ idleReminders += 1;
163
+ const content = plan.content;
164
+ idleReminderTimer = setTimeout(() => {
165
+ idleReminderTimer = null;
166
+ void (async () => {
167
+ const send = piSend.sendMessage;
168
+ if (typeof send !== 'function') return;
169
+ try {
170
+ await send(
171
+ { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
172
+ { deliverAs: 'followUp' },
173
+ );
174
+ } catch {
175
+ /* Delivery failure is non-fatal; the cap already bounds retries. */
176
+ }
177
+ })();
178
+ }, terminalReminderGraceMs());
179
+ idleReminderTimer.unref?.();
180
+ });
181
+
182
+ const TERM_READ_MAX = Number(pierOption('PIER_TERM_READ_MAX') ?? READ_MAX_CHARS) || READ_MAX_CHARS;
183
+ /** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
184
+ const WAIT_DEFAULT_MS = 120_000;
185
+ const WAIT_MAX_MS = 600_000;
186
+ /** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
187
+ const WAIT_TAIL_CHARS = 2_000;
188
+
189
+ /** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
190
+ const fail = (msg: string): never => toolError(msg);
100
191
 
101
192
  scoped.registerTool({
102
193
  name: 'terminal',
103
194
  label: 'Terminal',
104
195
  description: [
105
196
  '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).',
197
+ 'Operations: open (create session), send (type commands), wait (block until output matches a pattern), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
107
198
  'The shell keeps cwd, environment variables, and background processes across calls.',
108
199
  'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
200
+ 'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
201
+ 'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
202
+ 'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
109
203
  ].join(' '),
204
+ promptSnippet: 'terminal: a persistent shell in its own pane — use for dev servers, REPLs, and multi-step shell work that must keep state between calls.',
205
+ promptGuidelines: [
206
+ 'Prefer bash for one-shot commands: it is cheaper and returns output directly. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or when you need to wait for output that appears later.',
207
+ 'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
208
+ 'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
209
+ 'wait is for output you expect (a sentinel, a startup line); it returns the recent tail on timeout so you can decide instead of re-polling.',
210
+ 'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
211
+ ],
110
212
  parameters: Type.Object({
111
213
  action: Type.Union([
112
214
  Type.Literal('open'),
113
215
  Type.Literal('send'),
216
+ Type.Literal('wait'),
114
217
  Type.Literal('read'),
115
218
  Type.Literal('signal'),
116
219
  Type.Literal('close'),
117
220
  Type.Literal('list'),
118
221
  ], { description: 'Terminal operation to perform' }),
119
222
  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' })),
223
+ terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
121
224
  text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
225
+ wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Verify the shell is back at a prompt before sending; refuses with the detected readiness instead of queueing text behind a running command' })),
226
+ pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
227
+ regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
228
+ timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
122
229
  pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
123
230
  max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
124
231
  key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
125
232
  }),
126
- async execute(_tc, params, _sig, _upd, ctx) {
233
+ async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
127
234
  const action = typeof params?.action === 'string' ? params.action : '';
128
235
  switch (action) {
129
236
  case 'open': return executeTerminalOpen(params, ctx);
130
237
  case 'send': return executeTerminalSend(params);
238
+ case 'wait': return executeTerminalWait(params);
131
239
  case 'read': return executeTerminalRead(params);
132
240
  case 'signal': return executeTerminalSignal(params);
133
241
  case 'close': return executeTerminalClose(params);
134
242
  case 'list': return executeTerminalList();
135
243
  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)');
244
+ return fail(action
245
+ ? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
246
+ : 'action is required (open, send, wait, read, signal, close, list)');
139
247
  }
140
248
  },
141
249
  });
142
250
 
143
- async function executeTerminalOpen(params, ctx) {
144
- if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
251
+ /** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
252
+ async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
253
+ const prompt = promptStrategyFor();
254
+ try {
255
+ const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
256
+ const isMatched = waitRes && typeof waitRes === 'object' && 'matched' in waitRes
257
+ ? (waitRes as { matched: boolean }).matched
258
+ : Boolean(waitRes);
259
+ if (isMatched) return 'prompt';
260
+ const read = await client.readPane(paneId, { stripAnsi: false });
261
+ return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
262
+ } catch {
263
+ /* Readiness is advisory, so probe failures still leave a usable terminal. */
264
+ return 'busy';
265
+ }
266
+ }
267
+
268
+ async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
269
+ if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
145
270
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
146
271
  : (ctx as { cwd?: string }).cwd ?? process.cwd();
147
272
  const r = registerTerminal(terminals, {
@@ -150,27 +275,28 @@ export default function terminalPlugin(ctx: Context): void {
150
275
  cwd,
151
276
  createdAt: Date.now(),
152
277
  });
153
- if (!r.ok) return errText(r.error);
278
+ if (!r.ok) return fail(r.error);
154
279
  let paneId: string;
155
280
  try {
156
281
  paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
157
282
  } catch (e) {
158
- return errText(`failed to split terminal pane: ${(e as Error).message}`);
283
+ const hint = herdrUnavailableHint(e);
284
+ return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
159
285
  }
160
286
  r.entry.paneId = paneId;
161
287
  terminals = r.entries;
162
288
  persistTerminals();
289
+ const readiness = await probeReadiness(paneId);
163
290
  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 });
291
+ const init = planShellInit({ strategy: prompt, readiness });
292
+ if (init.shouldInit && init.command) {
293
+ try {
294
+ await client.sendPaneText(paneId, init.command);
295
+ r.entry.initialized = true;
296
+ persistTerminals();
297
+ } catch {
298
+ /* Shell init is best-effort: failure to set +H does not block terminal usage */
171
299
  }
172
- } catch {
173
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
174
300
  }
175
301
  const text = [
176
302
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
@@ -180,51 +306,140 @@ export default function terminalPlugin(ctx: Context): void {
180
306
  return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
181
307
  }
182
308
 
183
- async function executeTerminalSend(params) {
309
+ async function executeTerminalSend(params: ToolParams) {
184
310
  const entry = findOpenTerminal(params?.terminal_id);
185
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
311
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
186
312
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
187
- if (!v.ok) return errText(v.error);
313
+ if (!v.ok) return fail(v.error);
314
+ if (!entry.initialized) {
315
+ const prompt = promptStrategyFor();
316
+ const init = planShellInit({ strategy: prompt });
317
+ if (init.shouldInit && init.command) {
318
+ try {
319
+ await client.sendPaneText(entry.paneId, init.command);
320
+ } catch {
321
+ /* Shell init is best-effort */
322
+ }
323
+ }
324
+ entry.initialized = true;
325
+ persistTerminals();
326
+ }
327
+ if (params?.wait_prompt === true) {
328
+ // Orchestration convention: read before writing. Text typed while a previous command still
329
+ // owns the foreground gets queued and fires later, which reads as "the shell ignored me".
330
+ // Refuse with the observed readiness instead of guessing.
331
+ const readiness = await probeReadiness(entry.paneId);
332
+ if (readiness !== 'prompt') {
333
+ return fail(
334
+ `shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
335
+ + 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
336
+ );
337
+ }
338
+ }
188
339
  try {
189
340
  await client.sendPaneText(entry.paneId, v.text);
190
341
  } catch (e) {
191
- return errText(`send failed (pane may be closed): ${(e as Error).message}`);
342
+ // B5: a dead herdr socket would otherwise read as "pane may be closed", which sends the
343
+ // model hunting for a pane that is fine. Prefer the actionable transport sentence.
344
+ return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
192
345
  }
193
346
  touchTerminal(entry);
194
347
  return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
195
348
  }
196
349
 
197
- async function executeTerminalRead(params) {
350
+ async function executeTerminalWait(params: ToolParams) {
351
+ const entry = findOpenTerminal(params?.terminal_id);
352
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
353
+ const raw = typeof params?.pattern === 'string' ? params.pattern : '';
354
+ if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
355
+ const useRegex = params?.regex === true;
356
+ if (useRegex) {
357
+ try {
358
+ new RegExp(raw);
359
+ } catch (e) {
360
+ return fail(`invalid regex: ${(e as Error).message}`);
361
+ }
362
+ }
363
+ const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
364
+ const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
365
+ const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
366
+ let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
367
+ try {
368
+ const res = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
369
+ if (typeof res === 'object' && res !== null && 'matched' in res) {
370
+ waitResult = res as { matched: boolean; reason?: 'timeout' | 'unavailable' };
371
+ } else if (res === true) {
372
+ waitResult = { matched: true };
373
+ } else if (res === false) {
374
+ waitResult = { matched: false, reason: 'timeout' };
375
+ } else {
376
+ waitResult = { matched: false, reason: 'unavailable' };
377
+ }
378
+ } catch (e) {
379
+ return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
380
+ }
381
+ if (!waitResult.matched) {
382
+ // wait-for-text convention: on timeout, hand back the recent tail so the model can decide
383
+ // the next move instead of re-polling blind.
384
+ let tail = '';
385
+ try {
386
+ const read = await client.readPane(entry.paneId, { stripAnsi: false });
387
+ tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
388
+ } catch {
389
+ /* The pane is gone; the timeout text alone still tells the model it is stuck. */
390
+ }
391
+ const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
392
+ return {
393
+ content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
394
+ details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
395
+ };
396
+ }
397
+ return {
398
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
399
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
400
+ };
401
+ }
402
+
403
+ async function executeTerminalRead(params: ToolParams) {
198
404
  let paneId: string | null = null;
199
405
  let entry: TerminalEntry | null = null;
200
406
  if (typeof params?.terminal_id === 'string') {
201
407
  entry = findOpenTerminal(params.terminal_id);
202
- if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
408
+ if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
203
409
  paneId = entry.paneId;
204
410
  } else if (typeof params?.pane_id === 'string') {
205
411
  // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
412
+ // A1: the guard result is carried out of the try block — a thrown tool error must not be eaten
413
+ // by the lookup catch below (it would be reported as a bogus "pane lookup failed").
414
+ let allowed = false;
415
+ let lookupError: unknown = null;
206
416
  try {
207
417
  const panes = await client.listPanes();
208
418
  const target = panes.find((p) => p.paneId === params.pane_id);
209
419
  const ownTab = target?.tabId && target.tabId === env?.tabId;
210
420
  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;
421
+ allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
422
+ if (allowed) paneId = params.pane_id;
215
423
  } catch (e) {
216
- return errText(`pane lookup failed: ${(e as Error).message}`);
424
+ lookupError = e;
425
+ }
426
+ if (lookupError !== null) {
427
+ return fail(herdrUnavailableHint(lookupError) ?? `pane lookup failed: ${(lookupError as Error).message}`);
428
+ }
429
+ if (!allowed) {
430
+ return fail('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
217
431
  }
218
432
  } else {
219
- return errText('provide terminal_id (or pane_id for a direct own-tab read)');
433
+ return fail('provide terminal_id (or pane_id for a direct own-tab read)');
220
434
  }
221
435
  const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
222
436
  ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
437
+ if (paneId === null) return fail('no readable pane resolved (pass terminal_id, or pane_id for an own-tab pane)');
223
438
  let read: { text: string; revision: number; truncated: boolean };
224
439
  try {
225
440
  read = await client.readPane(paneId, { stripAnsi: false });
226
441
  } catch (e) {
227
- return errText(`read failed (pane may be closed): ${(e as Error).message}`);
442
+ return fail(herdrUnavailableHint(e) ?? `read failed (pane may be closed): ${(e as Error).message}`);
228
443
  }
229
444
  // T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
230
445
  const tui = detectFullscreenTUI(read.text);
@@ -241,7 +456,7 @@ export default function terminalPlugin(ctx: Context): void {
241
456
  }
242
457
  const prev: ReadCursor | null = entry
243
458
  ? (entry.readRevision != null
244
- ? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
459
+ ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
245
460
  : null)
246
461
  : null;
247
462
  const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
@@ -263,24 +478,24 @@ export default function terminalPlugin(ctx: Context): void {
263
478
  };
264
479
  }
265
480
 
266
- async function executeTerminalSignal(params) {
481
+ async function executeTerminalSignal(params: ToolParams) {
267
482
  const entry = findOpenTerminal(params?.terminal_id);
268
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
483
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
269
484
  const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
270
- if (!v.ok) return errText(v.error);
485
+ if (!v.ok) return fail(v.error);
271
486
  try {
272
487
  await client.sendPaneKeys(entry.paneId, [v.key]);
273
488
  } catch (e) {
274
- return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
489
+ return fail(herdrUnavailableHint(e) ?? `signal failed (pane may be closed): ${(e as Error).message}`);
275
490
  }
276
491
  touchTerminal(entry);
277
492
  return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
278
493
  }
279
494
 
280
- async function executeTerminalClose(params) {
495
+ async function executeTerminalClose(params: ToolParams) {
281
496
  const id = params?.terminal_id;
282
497
  const entry = terminals.find((t) => t.terminalId === id);
283
- if (!entry) return errText(`unknown terminal "${String(id)}" (see action list)`);
498
+ if (!entry) return fail(`unknown terminal "${String(id)}" (see action list)`);
284
499
  if (entry.status === 'open') {
285
500
  try {
286
501
  await client.closePane(entry.paneId);
@@ -294,12 +509,18 @@ export default function terminalPlugin(ctx: Context): void {
294
509
  }
295
510
 
296
511
  async function executeTerminalList() {
297
- let livePaneIds: string[] = [];
512
+ let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
298
513
  try {
299
- livePaneIds = (await client.listPanes()).map((p) => p.paneId);
514
+ livePanes = await client.listPanes();
300
515
  } catch {
301
516
  /* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
302
517
  }
518
+ const livePaneIds = livePanes.map((p) => p.paneId);
519
+ const titleByPaneId = new Map<string, string>();
520
+ for (const p of livePanes) {
521
+ const cleanTitle = p.terminalTitleStripped ?? p.terminalTitle;
522
+ if (cleanTitle) titleByPaneId.set(p.paneId, cleanTitle);
523
+ }
303
524
  const s = summarizeSessions(terminals, livePaneIds);
304
525
  // T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
305
526
  if (s.stalePaneIds.length > 0) {
@@ -313,9 +534,11 @@ export default function terminalPlugin(ctx: Context): void {
313
534
  if (s.terminals.length === 0) {
314
535
  return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
315
536
  }
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
- );
537
+ const lines = s.terminals.map((t) => {
538
+ const cleanTitle = titleByPaneId.get(t.paneId);
539
+ const titleTag = cleanTitle && cleanTitle !== t.label ? ` title="${cleanTitle}"` : '';
540
+ return `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}${titleTag}`;
541
+ });
319
542
  const note = s.stalePaneIds.length > 0
320
543
  ? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
321
544
  : '';