pi-pier 0.2.7 → 0.3.1

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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * M14 terminal loader entry for the D78 mount tree and D81 responsibility split.
3
- *
4
- * Keeping this as a Cordis loader plugin makes the terminal surface hot-swappable. Services
5
- * provide the D79 tombstone-aware pi surface, herdr client/environment, and the state slot
6
- * used by index GC. Avoiding an index.ts import preserves an acyclic dependency graph, while
7
- * terminal-core remains independently testable pure logic.
3
+ * A Cordis loader plugin so the terminal surface stays hot-swappable: services provide the tombstone-aware
4
+ * pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
5
+ * pure, independently testable logic.
8
6
  */
9
7
  import { Context } from '@deepseek-ai/cordis';
10
8
  import { Type } from 'typebox';
11
9
  import type { PiSurface } from '../pi-surface.ts';
12
10
  import type { HerdrClientLike } from '../herdr-client.ts';
11
+ import { herdrUnavailableHint } from '../herdr-client.ts';
12
+ import { toolError } from '../tool-error.ts'
13
13
  import {
14
14
  READINESS_TIMEOUT_MS,
15
15
  READ_MAX_CHARS,
@@ -19,20 +19,33 @@ import {
19
19
  classifyReadiness,
20
20
  closeTerminal,
21
21
  computeIncrement,
22
+ classifyWaitMatch,
22
23
  detectFullscreenTUI,
23
24
  foldTerminalsRegistry,
24
25
  makeTerminalsRegistry,
25
26
  planIdleTerminalReminder,
27
+ planShellInit,
26
28
  promptStrategyFor,
27
29
  registerTerminal,
30
+ sentinelEchoHazard,
28
31
  stripAnsi,
29
32
  summarizeSessions,
30
33
  terminalReminderGraceMs,
31
34
  validateSendText,
32
35
  validateSignal,
33
36
  type ReadCursor,
37
+ type ReadinessTier,
34
38
  type TerminalEntry,
35
39
  } from '../terminal-core.ts';
40
+ import { swallow } from '../swallow.ts';
41
+ import { pierOption } from '../pier-options.ts';
42
+
43
+ type ToolParams = Record<string, unknown> | undefined;
44
+
45
+ /** Terminal tool actions; the tool schema union and the handler map are both keyed by this list. */
46
+ const TERMINAL_ACTIONS = ['open', 'send', 'wait', 'read', 'signal', 'close', 'list'] as const;
47
+ type TerminalAction = (typeof TERMINAL_ACTIONS)[number];
48
+ type ActionHandler = (params: ToolParams, ctx: unknown) => Promise<unknown>;
36
49
 
37
50
  export interface TerminalStateSlot {
38
51
  /** Lets index GC preserve panes that host active terminals. */
@@ -54,16 +67,19 @@ export default function terminalPlugin(ctx: Context): void {
54
67
  // Keying the surface by this file lets HMR tombstone and replace exactly this registration.
55
68
  const scoped = surface.forModule(import.meta.url);
56
69
 
57
- /* ── M14 resident terminal tools (D71 / T1–T6) ────────────────────
58
- * Dedicated herdr panes preserve shell state without reusing the pi TUI pane, and active
59
- * terminal panes remain exempt from index GC. Workers omit this master-side entry. */
70
+ /* M14 resident terminal tools: dedicated herdr panes preserve shell state without reusing the pi TUI pane,
71
+ * and active terminal panes remain exempt from index GC. Workers omit this master-side entry. */
60
72
 
61
73
  let terminals: TerminalEntry[] = [];
62
74
 
75
+ /** Last text sent per terminal (in-memory): lets `wait` refuse patterns that match their own echo. */
76
+ const lastSentText = new Map<string, string>();
77
+
63
78
  function persistTerminals(): void {
64
79
  try {
65
80
  pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
66
- } catch {
81
+ } catch (err) {
82
+ swallow('terminal.persist-registry', err);
67
83
  /* Persistence is best-effort because terminal operation must not depend on session logging. */
68
84
  }
69
85
  }
@@ -88,7 +104,6 @@ export default function terminalPlugin(ctx: Context): void {
88
104
  persistTerminals();
89
105
  }
90
106
 
91
- // Expose active panes so index GC does not collect resident terminal shells.
92
107
  state.activePaneIds = () => activeTerminalPaneIds(terminals);
93
108
 
94
109
  scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
@@ -99,9 +114,8 @@ export default function terminalPlugin(ctx: Context): void {
99
114
  });
100
115
 
101
116
  // Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
102
- // terminals are persistent by design, so a master that exits without closing them leaks live
103
- // zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
104
- // switching branches cannot kill shells that the registry replays on the next session_start.
117
+ // a master that exits without closing them leaks live zsh panes. Session SWITCH never fires this
118
+ // event, so switching branches cannot kill shells that session_start replays from the registry.
105
119
  scoped.on('session_shutdown', async () => {
106
120
  idleReminderTimer && clearTimeout(idleReminderTimer);
107
121
  idleReminderTimer = null;
@@ -115,14 +129,13 @@ export default function terminalPlugin(ctx: Context): void {
115
129
  persistTerminals();
116
130
  });
117
131
 
118
- // Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
119
- // a one-shot background compile left a dead split in the main tab forever). Same delivery
132
+ // Turn-end nudge: remind the model to close terminals idle past the threshold. Same delivery
120
133
  // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
121
134
  const piSend = surface.raw as {
122
135
  sendMessage?: (
123
136
  message: { customType: string; content: string; display?: boolean },
124
137
  opts?: { deliverAs?: string; triggerTurn?: boolean },
125
- ) => Promise<void>;
138
+ ) => void;
126
139
  };
127
140
  let idleReminders = 0;
128
141
  let idleReminderTimer: NodeJS.Timeout | null = null;
@@ -143,9 +156,8 @@ export default function terminalPlugin(ctx: Context): void {
143
156
  reminders: idleReminders,
144
157
  });
145
158
  if (!plan.due || plan.content == null) return;
146
- // 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
147
- // notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
148
- // agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
159
+ // Goodbye-loop guard: mark the covered terminals nudged NOW (persisted), and deliver the notice as a
160
+ // queued followUp WITHOUT triggerTurn — a reminder must never wake the agent, or the loop never ends.
149
161
  const now = Date.now();
150
162
  const ids = new Set(plan.ids);
151
163
  terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
@@ -154,30 +166,40 @@ export default function terminalPlugin(ctx: Context): void {
154
166
  const content = plan.content;
155
167
  idleReminderTimer = setTimeout(() => {
156
168
  idleReminderTimer = null;
157
- void (async () => {
158
- const send = piSend.sendMessage;
159
- if (typeof send !== 'function') return;
160
- try {
161
- await send(
162
- { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
163
- { deliverAs: 'followUp' },
164
- );
165
- } catch {
166
- /* Delivery failure is non-fatal; the cap already bounds retries. */
167
- }
168
- })();
169
+ const send = piSend.sendMessage;
170
+ if (typeof send !== 'function') return;
171
+ try {
172
+ // Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
173
+ send(
174
+ { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
175
+ { deliverAs: 'followUp' },
176
+ );
177
+ } catch {
178
+ }
169
179
  }, terminalReminderGraceMs());
170
180
  idleReminderTimer.unref?.();
171
181
  });
172
182
 
173
- const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
183
+ const TERM_READ_MAX = Number(pierOption('PIER_TERM_READ_MAX') ?? READ_MAX_CHARS) || READ_MAX_CHARS;
174
184
  /** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
175
185
  const WAIT_DEFAULT_MS = 120_000;
176
186
  const WAIT_MAX_MS = 600_000;
177
187
  /** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
178
188
  const WAIT_TAIL_CHARS = 2_000;
179
189
 
180
- const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
190
+ /** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
191
+ const fail = (msg: string): never => toolError(msg);
192
+
193
+ // Action routing: each handler validates its own params, and `open` additionally needs the tool ctx.
194
+ const handlers: Record<TerminalAction, ActionHandler> = {
195
+ open: executeTerminalOpen,
196
+ send: executeTerminalSend,
197
+ wait: executeTerminalWait,
198
+ read: executeTerminalRead,
199
+ signal: executeTerminalSignal,
200
+ close: executeTerminalClose,
201
+ list: executeTerminalList,
202
+ };
181
203
 
182
204
  scoped.registerTool({
183
205
  name: 'terminal',
@@ -186,85 +208,97 @@ export default function terminalPlugin(ctx: Context): void {
186
208
  'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
187
209
  '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).',
188
210
  'The shell keeps cwd, environment variables, and background processes across calls.',
189
- 'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
190
- '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.',
191
- '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.',
192
- '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.',
211
+ 'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with regex "^TERM_DONE_[0-9]" — the shell echoes the typed command, so a literal wait on "TERM_DONE_" would match its own echo instantly; keep the sentinel distinguishable from its echo. Redirect verbose output to a log file and read the file, because output between two reads is lost.',
193
212
  ].join(' '),
213
+ 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.',
214
+ promptGuidelines: [
215
+ '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.',
216
+ 'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
217
+ 'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
218
+ '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.',
219
+ 'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
220
+ ],
194
221
  parameters: Type.Object({
195
- action: Type.Union([
196
- Type.Literal('open'),
197
- Type.Literal('send'),
198
- Type.Literal('wait'),
199
- Type.Literal('read'),
200
- Type.Literal('signal'),
201
- Type.Literal('close'),
202
- Type.Literal('list'),
203
- ], { description: 'Terminal operation to perform' }),
222
+ action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
204
223
  cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
205
- terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
224
+ terminal_id: Type.Optional(Type.String({ description: '[open] optional requested id for the new terminal (defaults to auto-assigned term-N); [send|wait|read|signal|close] terminal id returned by open' })),
206
225
  text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
207
226
  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' })),
208
- pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
227
+ pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output (must not occur in the command text you sent — echoed input matches too)' })),
209
228
  regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
210
229
  timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
211
230
  pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
212
231
  max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
213
232
  key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
214
233
  }),
215
- async execute(_tc, params, _sig, _upd, ctx) {
234
+ async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
216
235
  const action = typeof params?.action === 'string' ? params.action : '';
217
- switch (action) {
218
- case 'open': return executeTerminalOpen(params, ctx);
219
- case 'send': return executeTerminalSend(params);
220
- case 'wait': return executeTerminalWait(params);
221
- case 'read': return executeTerminalRead(params);
222
- case 'signal': return executeTerminalSignal(params);
223
- case 'close': return executeTerminalClose(params);
224
- case 'list': return executeTerminalList();
225
- default:
226
- return errText(action
227
- ? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
228
- : 'action is required (open, send, wait, read, signal, close, list)');
236
+ const handler: ActionHandler | undefined = handlers[action as TerminalAction];
237
+ if (!handler) {
238
+ const valid = TERMINAL_ACTIONS.join(', ');
239
+ return fail(action
240
+ ? `unknown action "${action}" (valid: ${valid})`
241
+ : `action is required (${valid})`);
229
242
  }
243
+ return handler(params, ctx);
230
244
  },
231
245
  });
232
246
 
233
247
  /** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
234
- async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
248
+ async function probeReadiness(paneId: string): Promise<ReadinessTier> {
235
249
  const prompt = promptStrategyFor();
236
250
  try {
237
- const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
238
- if (matched) return 'prompt';
251
+ const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
252
+ if (waitRes.matched) return 'prompt';
239
253
  const read = await client.readPane(paneId, { stripAnsi: false });
240
254
  return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
241
255
  } catch {
242
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
243
256
  return 'busy';
244
257
  }
245
258
  }
246
259
 
247
- async function executeTerminalOpen(params, ctx) {
248
- if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
260
+ /** Best-effort `set +H` right after a shell becomes usable (history expansion would wedge it);
261
+ * returns whether the command was injected, so a shell not yet at its prompt retries later. */
262
+ async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
263
+ const init = planShellInit({ strategy: promptStrategyFor(), readiness });
264
+ if (!init.shouldInit || !init.command) return false;
265
+ try {
266
+ await client.sendPaneText(entry.paneId, init.command);
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
274
+ if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
249
275
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
250
276
  : (ctx as { cwd?: string }).cwd ?? process.cwd();
251
277
  const r = registerTerminal(terminals, {
252
278
  paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
253
279
  tabId: env.tabId,
254
280
  cwd,
281
+ // Adopt the caller-requested id so `open {terminal_id}` + `send {terminal_id}` agree;
282
+ // silently ignoring it made the follow-up send fail on an unknown terminal.
283
+ terminalId: typeof params?.terminal_id === 'string' ? params.terminal_id : null,
255
284
  createdAt: Date.now(),
256
285
  });
257
- if (!r.ok) return errText(r.error);
286
+ if (!r.ok) return fail(r.error);
258
287
  let paneId: string;
259
288
  try {
260
289
  paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
261
290
  } catch (e) {
262
- return errText(`failed to split terminal pane: ${(e as Error).message}`);
291
+ const hint = herdrUnavailableHint(e);
292
+ return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
263
293
  }
264
294
  r.entry.paneId = paneId;
265
295
  terminals = r.entries;
266
296
  persistTerminals();
267
297
  const readiness = await probeReadiness(paneId);
298
+ if (await injectShellInit(r.entry, readiness)) {
299
+ r.entry.initialized = true;
300
+ persistTerminals();
301
+ }
268
302
  const text = [
269
303
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
270
304
  `cwd: ${cwd}`,
@@ -273,18 +307,22 @@ export default function terminalPlugin(ctx: Context): void {
273
307
  return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
274
308
  }
275
309
 
276
- async function executeTerminalSend(params) {
310
+ async function executeTerminalSend(params: ToolParams) {
277
311
  const entry = findOpenTerminal(params?.terminal_id);
278
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
312
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
279
313
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
280
- if (!v.ok) return errText(v.error);
314
+ if (!v.ok) return fail(v.error);
315
+ if (!entry.initialized) {
316
+ await injectShellInit(entry);
317
+ entry.initialized = true;
318
+ persistTerminals();
319
+ }
281
320
  if (params?.wait_prompt === true) {
282
- // Orchestration convention: read before writing. Text typed while a previous command still
283
- // owns the foreground gets queued and fires later, which reads as "the shell ignored me".
284
- // Refuse with the observed readiness instead of guessing.
321
+ // Orchestration convention: read before writing. Text typed while a previous command still owns the
322
+ // foreground gets queued and fires later, which reads as "the shell ignored me"; refuse with the observed readiness.
285
323
  const readiness = await probeReadiness(entry.paneId);
286
324
  if (readiness !== 'prompt') {
287
- return errText(
325
+ return fail(
288
326
  `shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
289
327
  + 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
290
328
  );
@@ -293,79 +331,146 @@ export default function terminalPlugin(ctx: Context): void {
293
331
  try {
294
332
  await client.sendPaneText(entry.paneId, v.text);
295
333
  } catch (e) {
296
- return errText(`send failed (pane may be closed): ${(e as Error).message}`);
334
+ // B5: a dead herdr socket would otherwise read as "pane may be closed", which sends the
335
+ // model hunting for a pane that is fine. Prefer the actionable transport sentence.
336
+ return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
297
337
  }
338
+ // In-memory only (never persisted): the wait echo-hazard check needs the text this pane last
339
+ // received, because the shell echoes typed commands back into the pane.
340
+ lastSentText.set(entry.terminalId, v.text);
298
341
  touchTerminal(entry);
299
342
  return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
300
343
  }
301
344
 
302
- async function executeTerminalWait(params) {
345
+ async function executeTerminalWait(params: ToolParams) {
303
346
  const entry = findOpenTerminal(params?.terminal_id);
304
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
347
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
305
348
  const raw = typeof params?.pattern === 'string' ? params.pattern : '';
306
- if (!raw) return errText('pattern is required (literal text, or a regular expression with regex: true)');
349
+ if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
307
350
  const useRegex = params?.regex === true;
308
351
  if (useRegex) {
309
352
  try {
310
353
  new RegExp(raw);
311
354
  } catch (e) {
312
- return errText(`invalid regex: ${(e as Error).message}`);
355
+ return fail(`invalid regex: ${(e as Error).message}`);
313
356
  }
314
357
  }
358
+ // The shell echoes typed commands into the pane, so a pattern contained in the last sent text
359
+ // matches its own echo and "succeeds" instantly, before the job finishes (observed: a
360
+ // minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
361
+ if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
362
+ const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
363
+ return fail(
364
+ `pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
365
+ + 'so the wait would match its own echo and return before the job finishes. '
366
+ + 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
367
+ + 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
368
+ + 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
369
+ + '(the echo contains `DONE_$?`, which that regex cannot match).',
370
+ );
371
+ }
315
372
  const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
316
373
  const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
374
+ const started = Date.now();
317
375
  const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
318
- let matched: boolean | null;
376
+ let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
319
377
  try {
320
- matched = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
378
+ waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
321
379
  } catch (e) {
322
- return errText(`wait failed (pane may be closed): ${(e as Error).message}`);
380
+ return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
323
381
  }
324
- if (matched !== true) {
325
- // wait-for-text convention: on timeout, hand back the recent tail so the model can decide
326
- // the next move instead of re-polling blind.
382
+ if (!waitResult.matched) {
383
+ // wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
327
384
  let tail = '';
328
385
  try {
329
386
  const read = await client.readPane(entry.paneId, { stripAnsi: false });
330
387
  tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
331
388
  } catch {
332
- /* The pane is gone; the timeout text alone still tells the model it is stuck. */
333
389
  }
334
- const reason = matched === false ? 'no match' : 'wait unavailable';
390
+ const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
335
391
  return {
336
392
  content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
337
- details: { terminal_id: entry.terminalId, matched: false, pattern: raw },
393
+ details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
338
394
  };
339
395
  }
340
- return {
341
- content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
342
- details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
396
+ // herdr matches the whole pane, including text a prior read already returned, and a second
397
+ // waitForOutput on that same text returns immediately. After a stale hit, poll the unread
398
+ // increment until a new occurrence appears or the original budget is gone — do not re-enter
399
+ // waitForOutput (that busy-loops on the old match).
400
+ const deadline = started + timeoutMs;
401
+ const cursor: ReadCursor | null = entry.readRevision != null
402
+ ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
403
+ : null;
404
+ const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
405
+ let sawStale = false;
406
+ const staleFailure = {
407
+ content: [{ type: 'text' as const, text: `pattern "${shown}" matched only in already-read output of ${entry.terminalId}; no new output contains it. This is not a new event — do not treat the job as finished. Wait for a marker that has not appeared yet, or use action read.` }],
408
+ details: { terminal_id: entry.terminalId, matched: false, stale: true, pattern: raw },
343
409
  };
410
+ while (true) {
411
+ let paneText = '';
412
+ try {
413
+ const read = await client.readPane(entry.paneId, { stripAnsi: false });
414
+ paneText = stripAnsi(read.text);
415
+ } catch {
416
+ if (sawStale) return staleFailure;
417
+ return {
418
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
419
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
420
+ };
421
+ }
422
+ const classified = classifyWaitMatch(cursor, paneText, raw, useRegex);
423
+ if (classified.kind === 'fresh') {
424
+ return {
425
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — new output contains the pattern:\n${classified.excerpt}` }],
426
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
427
+ };
428
+ }
429
+ if (classified.kind !== 'stale') {
430
+ return {
431
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
432
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
433
+ };
434
+ }
435
+ sawStale = true;
436
+ const left = deadline - Date.now();
437
+ if (left <= 0) return staleFailure;
438
+ const { promise, resolve } = Promise.withResolvers<void>();
439
+ setTimeout(resolve, Math.min(400, left));
440
+ await promise;
441
+ }
344
442
  }
345
443
 
346
- async function executeTerminalRead(params) {
347
- let paneId: string | null = null;
444
+ /** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
445
+ * panes, so one task can never read another's pane. Returns the error text instead of throwing. */
446
+ async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
447
+ let panes;
448
+ try {
449
+ panes = await client.listPanes();
450
+ } catch (e) {
451
+ return { ok: false, error: herdrUnavailableHint(e) ?? `pane lookup failed: ${(e as Error).message}` };
452
+ }
453
+ const target = panes.find((p) => p.paneId === paneId);
454
+ const ownTab = Boolean(target?.tabId) && target?.tabId === env?.tabId;
455
+ if (!target || !(ownTab || activeTerminalPaneIds(terminals).has(paneId))) {
456
+ return { ok: false, error: 'pane_id read is limited to panes in this session\'s own tab or self-created terminal panes' };
457
+ }
458
+ return { ok: true, paneId };
459
+ }
460
+
461
+ async function executeTerminalRead(params: ToolParams) {
462
+ let paneId: string;
348
463
  let entry: TerminalEntry | null = null;
349
464
  if (typeof params?.terminal_id === 'string') {
350
465
  entry = findOpenTerminal(params.terminal_id);
351
- if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
466
+ if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
352
467
  paneId = entry.paneId;
353
468
  } else if (typeof params?.pane_id === 'string') {
354
- // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
355
- try {
356
- const panes = await client.listPanes();
357
- const target = panes.find((p) => p.paneId === params.pane_id);
358
- const ownTab = target?.tabId && target.tabId === env?.tabId;
359
- const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
360
- if (!target || (!ownTab && !ownTerminal)) {
361
- return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
362
- }
363
- paneId = params.pane_id;
364
- } catch (e) {
365
- return errText(`pane lookup failed: ${(e as Error).message}`);
366
- }
469
+ const resolved = await resolveDirectPaneId(params.pane_id);
470
+ if (!resolved.ok) return fail(resolved.error);
471
+ paneId = resolved.paneId;
367
472
  } else {
368
- return errText('provide terminal_id (or pane_id for a direct own-tab read)');
473
+ return fail('provide terminal_id (or pane_id for a direct own-tab read)');
369
474
  }
370
475
  const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
371
476
  ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
@@ -373,7 +478,7 @@ export default function terminalPlugin(ctx: Context): void {
373
478
  try {
374
479
  read = await client.readPane(paneId, { stripAnsi: false });
375
480
  } catch (e) {
376
- return errText(`read failed (pane may be closed): ${(e as Error).message}`);
481
+ return fail(herdrUnavailableHint(e) ?? `read failed (pane may be closed): ${(e as Error).message}`);
377
482
  }
378
483
  // T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
379
484
  const tui = detectFullscreenTUI(read.text);
@@ -390,7 +495,7 @@ export default function terminalPlugin(ctx: Context): void {
390
495
  }
391
496
  const prev: ReadCursor | null = entry
392
497
  ? (entry.readRevision != null
393
- ? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
498
+ ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
394
499
  : null)
395
500
  : null;
396
501
  const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
@@ -412,24 +517,24 @@ export default function terminalPlugin(ctx: Context): void {
412
517
  };
413
518
  }
414
519
 
415
- async function executeTerminalSignal(params) {
520
+ async function executeTerminalSignal(params: ToolParams) {
416
521
  const entry = findOpenTerminal(params?.terminal_id);
417
- if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
522
+ if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
418
523
  const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
419
- if (!v.ok) return errText(v.error);
524
+ if (!v.ok) return fail(v.error);
420
525
  try {
421
526
  await client.sendPaneKeys(entry.paneId, [v.key]);
422
527
  } catch (e) {
423
- return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
528
+ return fail(herdrUnavailableHint(e) ?? `signal failed (pane may be closed): ${(e as Error).message}`);
424
529
  }
425
530
  touchTerminal(entry);
426
531
  return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
427
532
  }
428
533
 
429
- async function executeTerminalClose(params) {
534
+ async function executeTerminalClose(params: ToolParams) {
430
535
  const id = params?.terminal_id;
431
536
  const entry = terminals.find((t) => t.terminalId === id);
432
- if (!entry) return errText(`unknown terminal "${String(id)}" (see action list)`);
537
+ if (!entry) return fail(`unknown terminal "${String(id)}" (see action list)`);
433
538
  if (entry.status === 'open') {
434
539
  try {
435
540
  await client.closePane(entry.paneId);
@@ -438,17 +543,24 @@ export default function terminalPlugin(ctx: Context): void {
438
543
  }
439
544
  }
440
545
  terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
546
+ lastSentText.delete(entry.terminalId);
441
547
  persistTerminals();
442
548
  return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
443
549
  }
444
550
 
445
551
  async function executeTerminalList() {
446
- let livePaneIds: string[] = [];
552
+ let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
447
553
  try {
448
- livePaneIds = (await client.listPanes()).map((p) => p.paneId);
554
+ livePanes = await client.listPanes();
449
555
  } catch {
450
556
  /* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
451
557
  }
558
+ const livePaneIds = livePanes.map((p) => p.paneId);
559
+ const titleByPaneId = new Map<string, string>();
560
+ for (const p of livePanes) {
561
+ const cleanTitle = p.terminalTitleStripped ?? p.terminalTitle;
562
+ if (cleanTitle) titleByPaneId.set(p.paneId, cleanTitle);
563
+ }
452
564
  const s = summarizeSessions(terminals, livePaneIds);
453
565
  // T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
454
566
  if (s.stalePaneIds.length > 0) {
@@ -462,9 +574,11 @@ export default function terminalPlugin(ctx: Context): void {
462
574
  if (s.terminals.length === 0) {
463
575
  return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
464
576
  }
465
- const lines = s.terminals.map((t) =>
466
- `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
467
- );
577
+ const lines = s.terminals.map((t) => {
578
+ const cleanTitle = titleByPaneId.get(t.paneId);
579
+ const titleTag = cleanTitle && cleanTitle !== t.label ? ` title="${cleanTitle}"` : '';
580
+ return `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}${titleTag}`;
581
+ });
468
582
  const note = s.stalePaneIds.length > 0
469
583
  ? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
470
584
  : '';