pi-pier 0.3.1 → 0.4.2

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 (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
@@ -4,9 +4,12 @@
4
4
  * pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
5
5
  * pure, independently testable logic.
6
6
  */
7
+ import { open } from 'node:fs/promises';
8
+ import { homedir } from 'node:os';
9
+ import { resolve as resolvePath } from 'node:path';
7
10
  import { Context } from '@deepseek-ai/cordis';
8
11
  import { Type } from 'typebox';
9
- import type { PiSurface } from '../pi-surface.ts';
12
+ import { cordisGet } from '../cordis-services.ts';
10
13
  import type { HerdrClientLike } from '../herdr-client.ts';
11
14
  import { herdrUnavailableHint } from '../herdr-client.ts';
12
15
  import { toolError } from '../tool-error.ts'
@@ -18,14 +21,20 @@ import {
18
21
  activeTerminalPaneIds,
19
22
  classifyReadiness,
20
23
  closeTerminal,
24
+ compilePattern,
21
25
  computeIncrement,
22
26
  classifyWaitMatch,
27
+ detectContinuationPrompt,
23
28
  detectFullscreenTUI,
29
+ echoSafeWaitSuggestion,
30
+ excerptAroundMatch,
24
31
  foldTerminalsRegistry,
25
32
  makeTerminalsRegistry,
26
- planIdleTerminalReminder,
33
+ planTerminalReminder,
27
34
  planShellInit,
28
35
  promptStrategyFor,
36
+ regexLiteralPrefix,
37
+ redirectedSentinel,
29
38
  registerTerminal,
30
39
  sentinelEchoHazard,
31
40
  stripAnsi,
@@ -45,22 +54,46 @@ type ToolParams = Record<string, unknown> | undefined;
45
54
  /** Terminal tool actions; the tool schema union and the handler map are both keyed by this list. */
46
55
  const TERMINAL_ACTIONS = ['open', 'send', 'wait', 'read', 'signal', 'close', 'list'] as const;
47
56
  type TerminalAction = (typeof TERMINAL_ACTIONS)[number];
48
- type ActionHandler = (params: ToolParams, ctx: unknown) => Promise<unknown>;
57
+ type ActionHandler = (params: ToolParams, ctx: unknown, signal?: AbortSignal) => Promise<unknown>;
58
+
59
+ /** File waits poll the log in-process; one tick per second keeps a 10-minute wait to ~600 reads. */
60
+ const FILE_WAIT_POLL_MS = 1000;
61
+ /** Only the log's tail is matched: a sentinel is the newest line, and a multi-GB log must not be read whole. */
62
+ const FILE_WAIT_TAIL_BYTES = 1024 * 1024;
63
+
64
+ /** The last `maxBytes` of a file as UTF-8, or null when it does not exist (yet). */
65
+ async function readFileTail(path: string, maxBytes: number): Promise<string | null> {
66
+ let handle;
67
+ try {
68
+ handle = await open(path, 'r');
69
+ } catch {
70
+ return null;
71
+ }
72
+ try {
73
+ const { size } = await handle.stat();
74
+ const length = Math.min(size, maxBytes);
75
+ const buffer = Buffer.alloc(length);
76
+ await handle.read(buffer, 0, length, size - length);
77
+ return buffer.toString('utf8');
78
+ } finally {
79
+ await handle.close();
80
+ }
81
+ }
49
82
 
50
83
  export interface TerminalStateSlot {
51
84
  /** Lets index GC preserve panes that host active terminals. */
52
85
  activePaneIds: () => Set<string>;
53
86
  }
54
87
 
55
- interface TerminalDeps {
88
+ export interface TerminalDeps {
56
89
  client: HerdrClientLike;
57
90
  env: { paneId: string; tabId: string } | null;
58
91
  state: TerminalStateSlot;
59
92
  }
60
93
 
61
94
  export default function terminalPlugin(ctx: Context): void {
62
- const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
63
- const { client, env, state } = ctx.get('pi-herdr.terminal-deps') as TerminalDeps;
95
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
96
+ const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
64
97
  const pi = surface.raw as {
65
98
  appendEntry?: (customType: string, data: unknown) => void;
66
99
  };
@@ -129,17 +162,20 @@ export default function terminalPlugin(ctx: Context): void {
129
162
  persistTerminals();
130
163
  });
131
164
 
132
- // Turn-end nudge: remind the model to close terminals idle past the threshold. Same delivery
133
- // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
165
+ // Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
166
+ // threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
167
+ // capped per terminal.
134
168
  const piSend = surface.raw as {
135
169
  sendMessage?: (
136
170
  message: { customType: string; content: string; display?: boolean },
137
171
  opts?: { deliverAs?: string; triggerTurn?: boolean },
138
172
  ) => void;
139
173
  };
140
- let idleReminders = 0;
174
+ /** Start of the current agent run; terminals touched since then count as "used this turn". */
175
+ let turnStartedAt: number | null = null;
141
176
  let idleReminderTimer: NodeJS.Timeout | null = null;
142
177
  scoped.on('agent_start', () => {
178
+ turnStartedAt = Date.now();
143
179
  if (idleReminderTimer !== null) {
144
180
  clearTimeout(idleReminderTimer);
145
181
  idleReminderTimer = null;
@@ -150,24 +186,39 @@ export default function terminalPlugin(ctx: Context): void {
150
186
  clearTimeout(idleReminderTimer);
151
187
  idleReminderTimer = null;
152
188
  }
153
- const plan = planIdleTerminalReminder({
154
- open: terminals.filter((t) => t.status === 'open'),
189
+ const open = await Promise.all(
190
+ terminals
191
+ .filter((t) => t.status === 'open')
192
+ .map(async (t) => {
193
+ // A pane running a foreground job (dev server, REPL, load test) is in use even when the
194
+ // last tool interaction is old: nudge only after it is back at a shell prompt.
195
+ const info = await client.processInfo(t.paneId).catch(() => null);
196
+ const busy = info?.shellPid != null
197
+ && info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
198
+ return { ...t, busy };
199
+ }),
200
+ );
201
+ const plan = planTerminalReminder({
202
+ open,
155
203
  now: Date.now(),
156
- reminders: idleReminders,
204
+ turnStartedAt,
157
205
  });
206
+ turnStartedAt = null;
158
207
  if (!plan.due || plan.content == null) return;
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.
161
- const now = Date.now();
162
- const ids = new Set(plan.ids);
163
- terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
164
- persistTerminals();
165
- idleReminders += 1;
208
+ // Deliver the notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the
209
+ // agent. The per-terminal count is bumped only when the timer fires: agent_start cancels the
210
+ // grace timer, and a cancelled reminder must not consume one of the two allowed nudges.
166
211
  const content = plan.content;
167
212
  idleReminderTimer = setTimeout(() => {
168
213
  idleReminderTimer = null;
169
214
  const send = piSend.sendMessage;
170
215
  if (typeof send !== 'function') return;
216
+ const now = Date.now();
217
+ const ids = new Set(plan.ids);
218
+ terminals = terminals.map((t) => (ids.has(t.terminalId) && t.status === 'open'
219
+ ? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
220
+ : t));
221
+ persistTerminals();
171
222
  try {
172
223
  // Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
173
224
  send(
@@ -205,31 +256,29 @@ export default function terminalPlugin(ctx: Context): void {
205
256
  name: 'terminal',
206
257
  label: 'Terminal',
207
258
  description: [
208
- 'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
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).',
210
- 'The shell keeps cwd, environment variables, and background processes across calls.',
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.',
259
+ 'Persistent interactive shells in dedicated herdr panes; cwd, environment and background processes survive across calls.',
260
+ 'Actions: open, send (type a command), wait (block until output matches), read, signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close, list.',
261
+ 'Long job: send "cmd; echo TERM_DONE_$?" and wait with regex "^TERM_DONE_[0-9]" — the shell echoes typed commands, so a literal "TERM_DONE_" would match its own echo. When output and sentinel go to a log ("cmd > run.log 2>&1; echo DONE_$? >> run.log"), wait with file: "run.log" instead of sleeping in bash.',
212
262
  ].join(' '),
213
263
  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
264
  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.',
265
+ 'Prefer bash for one-shot commands. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or output appears later; open once and reuse the terminal_id.',
266
+ 'wait returns the recent tail on timeout, so decide from it instead of re-polling.',
267
+ 'Close the terminal when the work is done — it occupies a pane and is never auto-reclaimed.',
220
268
  ],
221
269
  parameters: Type.Object({
222
- action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
223
- cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
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' })),
225
- text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
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' })),
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)' })),
228
- regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
229
- timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
230
- pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
231
- max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
232
- key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
270
+ action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name))),
271
+ cwd: Type.Optional(Type.String({ description: '[open] Shell working directory (default: session cwd)' })),
272
+ terminal_id: Type.Optional(Type.String({ description: '[open] Optional id for the new terminal (default term-N); [others] id returned by open' })),
273
+ text: Type.Optional(Type.String({ description: '[send] Command text (Enter appended; multi-line runs line by line)' })),
274
+ wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Refuse instead of queueing when the shell is not at a prompt' })),
275
+ pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regex to wait for (must not occur in the sent command text)' })),
276
+ file: Type.Optional(Type.String({ description: '[wait] Watch this log file instead of the pane (relative to the terminal cwd; may not exist yet)' })),
277
+ regex: Type.Optional(Type.Boolean({ description: '[wait] pattern is a regex (default literal)' })),
278
+ timeout_ms: Type.Optional(Type.Number({ description: '[wait] Default 120000, max 600000' })),
279
+ pane_id: Type.Optional(Type.String({ description: '[read] Read another pane in your own tab' })),
280
+ max_chars: Type.Optional(Type.Number({ description: `[read] Output cap (default ${READ_MAX_CHARS})` })),
281
+ key: Type.Optional(Type.String({ description: '[signal] ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
233
282
  }),
234
283
  async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
235
284
  const action = typeof params?.action === 'string' ? params.action : '';
@@ -240,7 +289,7 @@ export default function terminalPlugin(ctx: Context): void {
240
289
  ? `unknown action "${action}" (valid: ${valid})`
241
290
  : `action is required (${valid})`);
242
291
  }
243
- return handler(params, ctx);
292
+ return handler(params, ctx, _sig);
244
293
  },
245
294
  });
246
295
 
@@ -257,8 +306,9 @@ export default function terminalPlugin(ctx: Context): void {
257
306
  }
258
307
  }
259
308
 
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. */
309
+ /** Best-effort history-expansion init right after a shell becomes usable (zsh needs
310
+ * `setopt NO_BANG_HIST`, bash `set +H`); returns whether the command was injected, so a shell not
311
+ * yet at its prompt retries later. */
262
312
  async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
263
313
  const init = planShellInit({ strategy: promptStrategyFor(), readiness });
264
314
  if (!init.shouldInit || !init.command) return false;
@@ -284,12 +334,28 @@ export default function terminalPlugin(ctx: Context): void {
284
334
  createdAt: Date.now(),
285
335
  });
286
336
  if (!r.ok) return fail(r.error);
337
+ // Stack new terminals below the most recent one instead of halving the pi master pane again:
338
+ // every right-split of the master shrinks both (45 → 22 → 11 columns), which breaks output
339
+ // wrapping and multi-word waits. The first terminal still splits the master to the right.
340
+ const anchor = [...terminals].reverse().find((t) => t.status === 'open') ?? null;
341
+ const targetPaneId = anchor?.paneId ?? env.paneId;
342
+ const direction = anchor ? 'down' : 'right';
287
343
  let paneId: string;
288
344
  try {
289
- paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
345
+ paneId = await client.splitPane({ direction, cwd, focus: false, targetPaneId });
290
346
  } catch (e) {
291
- const hint = herdrUnavailableHint(e);
292
- return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
347
+ if (!anchor) {
348
+ const hint = herdrUnavailableHint(e);
349
+ return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
350
+ }
351
+ // A terminal restored from an earlier session can point at a pane herdr no longer has: fall
352
+ // back to the master instead of failing to open at all.
353
+ try {
354
+ paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
355
+ } catch (fallbackError) {
356
+ const hint = herdrUnavailableHint(fallbackError);
357
+ return fail(hint ?? `failed to split terminal pane: ${(fallbackError as Error).message}`);
358
+ }
293
359
  }
294
360
  r.entry.paneId = paneId;
295
361
  terminals = r.entries;
@@ -313,9 +379,10 @@ export default function terminalPlugin(ctx: Context): void {
313
379
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
314
380
  if (!v.ok) return fail(v.error);
315
381
  if (!entry.initialized) {
316
- await injectShellInit(entry);
317
- entry.initialized = true;
318
- persistTerminals();
382
+ if (await injectShellInit(entry)) {
383
+ entry.initialized = true;
384
+ persistTerminals();
385
+ }
319
386
  }
320
387
  if (params?.wait_prompt === true) {
321
388
  // Orchestration convention: read before writing. Text typed while a previous command still owns the
@@ -339,34 +406,68 @@ export default function terminalPlugin(ctx: Context): void {
339
406
  // received, because the shell echoes typed commands back into the pane.
340
407
  lastSentText.set(entry.terminalId, v.text);
341
408
  touchTerminal(entry);
342
- return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
409
+ // An incomplete command leaves the shell at a secondary prompt (`dquote>`, `heredoc>`, …):
410
+ // every later send is swallowed by it, so surface the wedge instead of reporting success.
411
+ let pending: string | null = null;
412
+ try {
413
+ const snapshot = await client.readPane(entry.paneId, { stripAnsi: false });
414
+ pending = detectContinuationPrompt(stripAnsi(snapshot.text));
415
+ } catch {
416
+ /* A pane read is advisory here: a transport failure must not turn a successful send into an error. */
417
+ }
418
+ if (pending) {
419
+ return fail(
420
+ `shell is waiting for more input (${pending}) — the sent command left an incomplete quote, heredoc, or block, `
421
+ + 'and later text is swallowed by that continuation prompt. Send the missing terminator, or interrupt with '
422
+ + 'terminal(action: "signal", key: "ctrl+c"), then re-send a complete command.',
423
+ );
424
+ }
425
+ const hint = redirectedSentinel(v.text);
426
+ const fileHint = hint
427
+ ? `\nnote: the sentinel ${hint.prefix}$? is written to ${hint.file}, not the pane — wait for it with `
428
+ + `terminal(action: "wait", terminal_id: "${entry.terminalId}", file: "${hint.file}", pattern: "${echoSafeWaitSuggestion(hint.prefix) ?? hint.prefix}", regex: true) instead of sleeping.`
429
+ : '';
430
+ return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)${fileHint}` }], details: { terminal_id: entry.terminalId } };
343
431
  }
344
432
 
345
- async function executeTerminalWait(params: ToolParams) {
433
+ async function executeTerminalWait(params: ToolParams, _ctx?: unknown, signal?: AbortSignal) {
346
434
  const entry = findOpenTerminal(params?.terminal_id);
347
435
  if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
436
+ // Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
437
+ touchTerminal(entry);
348
438
  const raw = typeof params?.pattern === 'string' ? params.pattern : '';
349
439
  if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
350
440
  const useRegex = params?.regex === true;
351
441
  if (useRegex) {
352
442
  try {
353
- new RegExp(raw);
443
+ compilePattern(raw);
354
444
  } catch (e) {
355
445
  return fail(`invalid regex: ${(e as Error).message}`);
356
446
  }
357
447
  }
448
+ if (typeof params?.file === 'string' && params.file.trim()) {
449
+ const file = params.file.trim();
450
+ const path = file.startsWith('~/') ? resolvePath(homedir(), file.slice(2)) : resolvePath(entry.cwd, file);
451
+ return waitForFile(entry, path, raw, useRegex, params, signal);
452
+ }
358
453
  // The shell echoes typed commands into the pane, so a pattern contained in the last sent text
359
454
  // matches its own echo and "succeeds" instantly, before the job finishes (observed: a
360
455
  // minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
361
456
  if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
362
457
  const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
458
+ const literal = useRegex ? regexLiteralPrefix(raw) : raw;
459
+ const suggestion = echoSafeWaitSuggestion(literal);
460
+ const advice = suggestion
461
+ ? `For future sends, use a sentinel distinguishable from its echo: send \`cmd; echo ${literal}$?\` and wait with regex "${suggestion}" `
462
+ + '(the echoed command shows `$?`, which that regex cannot match).'
463
+ : 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
464
+ + '(the echo contains `DONE_$?`, which that regex cannot match).';
363
465
  return fail(
364
466
  `pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
365
467
  + 'so the wait would match its own echo and return before the job finishes. '
366
468
  + 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
367
469
  + '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).',
470
+ + advice,
370
471
  );
371
472
  }
372
473
  const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
@@ -387,7 +488,10 @@ export default function terminalPlugin(ctx: Context): void {
387
488
  tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
388
489
  } catch {
389
490
  }
390
- const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
491
+ // A wait that consumed its budget is a timeout even when the failing RPC reports itself as
492
+ // unavailable: the distinction tells the model whether to re-check the command or the server.
493
+ const consumedBudget = Date.now() - started >= timeoutMs * 0.95;
494
+ const reason = waitResult.reason === 'timeout' || consumedBudget ? 'timeout' : 'wait unavailable';
391
495
  return {
392
496
  content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
393
497
  details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
@@ -399,7 +503,13 @@ export default function terminalPlugin(ctx: Context): void {
399
503
  // waitForOutput (that busy-loops on the old match).
400
504
  const deadline = started + timeoutMs;
401
505
  const cursor: ReadCursor | null = entry.readRevision != null
402
- ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
506
+ ? {
507
+ revision: entry.readRevision,
508
+ len: entry.readLen ?? 0,
509
+ tail: entry.readTail ?? '',
510
+ textLen: entry.readTextLen ?? 0,
511
+ textHash: entry.readTextHash ?? 0,
512
+ }
403
513
  : null;
404
514
  const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
405
515
  let sawStale = false;
@@ -441,6 +551,53 @@ export default function terminalPlugin(ctx: Context): void {
441
551
  }
442
552
  }
443
553
 
554
+ /** `wait` on a log file: the sentinel of a redirected job never reaches the pane. A match already in
555
+ * the file when the wait starts returns at once, labelled, so a leftover from an earlier run is visible. */
556
+ async function waitForFile(
557
+ entry: TerminalEntry,
558
+ path: string,
559
+ pattern: string,
560
+ useRegex: boolean,
561
+ params: ToolParams,
562
+ signal: AbortSignal | undefined,
563
+ ) {
564
+ const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
565
+ const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
566
+ const started = Date.now();
567
+ const matches = (text: string) => (useRegex ? compilePattern(pattern).test(text) : text.includes(pattern));
568
+ let text: string | null = null;
569
+ let wake: (() => void) | null = null;
570
+ const onAbort = () => wake?.();
571
+ signal?.addEventListener('abort', onAbort, { once: true });
572
+ try {
573
+ for (let tick = 0; ; tick += 1) {
574
+ text = await readFileTail(path, FILE_WAIT_TAIL_BYTES);
575
+ if (text !== null && matches(text)) {
576
+ const when = tick === 0 ? 'already present when the wait started (truncate the log per run if this is an old one)' : `appeared after ${Math.round((Date.now() - started) / 1000)}s`;
577
+ return {
578
+ content: [{ type: 'text', text: `matched in ${path} — ${when}:\n${excerptAroundMatch(text, pattern, useRegex)}` }],
579
+ details: { terminal_id: entry.terminalId, matched: true, pattern, file: path, alreadyPresent: tick === 0 },
580
+ };
581
+ }
582
+ const left = started + timeoutMs - Date.now();
583
+ if (left <= 0 || signal?.aborted) break;
584
+ const { promise, resolve } = Promise.withResolvers<void>();
585
+ wake = resolve;
586
+ const timer = setTimeout(resolve, Math.min(FILE_WAIT_POLL_MS, left));
587
+ await promise;
588
+ clearTimeout(timer);
589
+ }
590
+ } finally {
591
+ signal?.removeEventListener('abort', onAbort);
592
+ }
593
+ const reason = signal?.aborted ? 'aborted' : 'timeout';
594
+ const tail = text === null ? `\n${path} does not exist.` : text ? `\nrecent file tail:\n${text.slice(-WAIT_TAIL_CHARS)}` : `\n${path} is empty.`;
595
+ return {
596
+ content: [{ type: 'text', text: `no match in ${path} within ${timeoutMs}ms (${reason}).${tail}` }],
597
+ details: { terminal_id: entry.terminalId, matched: false, pattern, file: path, reason },
598
+ };
599
+ }
600
+
444
601
  /** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
445
602
  * panes, so one task can never read another's pane. Returns the error text instead of throwing. */
446
603
  async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
@@ -495,7 +652,13 @@ export default function terminalPlugin(ctx: Context): void {
495
652
  }
496
653
  const prev: ReadCursor | null = entry
497
654
  ? (entry.readRevision != null
498
- ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
655
+ ? {
656
+ revision: entry.readRevision,
657
+ len: entry.readLen ?? 0,
658
+ tail: entry.readTail ?? '',
659
+ textLen: entry.readTextLen ?? 0,
660
+ textHash: entry.readTextHash ?? 0,
661
+ }
499
662
  : null)
500
663
  : null;
501
664
  const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
@@ -503,7 +666,8 @@ export default function terminalPlugin(ctx: Context): void {
503
666
  entry.readRevision = inc.cursor.revision;
504
667
  entry.readLen = inc.cursor.len;
505
668
  entry.readTail = inc.cursor.tail;
506
- entry.readEoTail = inc.cursor.eoTail;
669
+ entry.readTextLen = inc.cursor.textLen;
670
+ entry.readTextHash = inc.cursor.textHash;
507
671
  touchTerminal(entry);
508
672
  }
509
673
  if (inc.mode === 'none') {