pi-pier 0.4.1 → 0.4.3

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 (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
@@ -4,6 +4,9 @@
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
12
  import { cordisGet } from '../cordis-services.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
33
  planTerminalReminder,
27
34
  planShellInit,
28
35
  promptStrategyFor,
36
+ regexLiteralPrefix,
37
+ redirectedSentinel,
29
38
  registerTerminal,
30
39
  sentinelEchoHazard,
31
40
  stripAnsi,
@@ -45,7 +54,31 @@ 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. */
@@ -172,19 +205,20 @@ export default function terminalPlugin(ctx: Context): void {
172
205
  });
173
206
  turnStartedAt = null;
174
207
  if (!plan.due || plan.content == null) return;
175
- // Goodbye-loop guard: bump each covered terminal's nudge count NOW (persisted), and deliver the
176
- // notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the agent.
177
- const now = Date.now();
178
- const ids = new Set(plan.ids);
179
- terminals = terminals.map((t) => (ids.has(t.terminalId)
180
- ? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
181
- : t));
182
- persistTerminals();
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.
183
211
  const content = plan.content;
184
212
  idleReminderTimer = setTimeout(() => {
185
213
  idleReminderTimer = null;
186
214
  const send = piSend.sendMessage;
187
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();
188
222
  try {
189
223
  // Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
190
224
  send(
@@ -222,31 +256,29 @@ export default function terminalPlugin(ctx: Context): void {
222
256
  name: 'terminal',
223
257
  label: 'Terminal',
224
258
  description: [
225
- 'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
226
- '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).',
227
- 'The shell keeps cwd, environment variables, and background processes across calls.',
228
- '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.',
229
262
  ].join(' '),
230
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.',
231
264
  promptGuidelines: [
232
- '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.',
233
- 'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
234
- 'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
235
- '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.',
236
- '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.',
237
268
  ],
238
269
  parameters: Type.Object({
239
- action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
240
- cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
241
- 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' })),
242
- text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
243
- 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' })),
244
- 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)' })),
245
- regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
246
- timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
247
- pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
248
- max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
249
- 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' })),
250
282
  }),
251
283
  async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
252
284
  const action = typeof params?.action === 'string' ? params.action : '';
@@ -257,7 +289,7 @@ export default function terminalPlugin(ctx: Context): void {
257
289
  ? `unknown action "${action}" (valid: ${valid})`
258
290
  : `action is required (${valid})`);
259
291
  }
260
- return handler(params, ctx);
292
+ return handler(params, ctx, _sig);
261
293
  },
262
294
  });
263
295
 
@@ -274,8 +306,9 @@ export default function terminalPlugin(ctx: Context): void {
274
306
  }
275
307
  }
276
308
 
277
- /** Best-effort `set +H` right after a shell becomes usable (history expansion would wedge it);
278
- * 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. */
279
312
  async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
280
313
  const init = planShellInit({ strategy: promptStrategyFor(), readiness });
281
314
  if (!init.shouldInit || !init.command) return false;
@@ -301,12 +334,28 @@ export default function terminalPlugin(ctx: Context): void {
301
334
  createdAt: Date.now(),
302
335
  });
303
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';
304
343
  let paneId: string;
305
344
  try {
306
- paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
345
+ paneId = await client.splitPane({ direction, cwd, focus: false, targetPaneId });
307
346
  } catch (e) {
308
- const hint = herdrUnavailableHint(e);
309
- 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
+ }
310
359
  }
311
360
  r.entry.paneId = paneId;
312
361
  terminals = r.entries;
@@ -330,9 +379,10 @@ export default function terminalPlugin(ctx: Context): void {
330
379
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
331
380
  if (!v.ok) return fail(v.error);
332
381
  if (!entry.initialized) {
333
- await injectShellInit(entry);
334
- entry.initialized = true;
335
- persistTerminals();
382
+ if (await injectShellInit(entry)) {
383
+ entry.initialized = true;
384
+ persistTerminals();
385
+ }
336
386
  }
337
387
  if (params?.wait_prompt === true) {
338
388
  // Orchestration convention: read before writing. Text typed while a previous command still owns the
@@ -356,10 +406,31 @@ export default function terminalPlugin(ctx: Context): void {
356
406
  // received, because the shell echoes typed commands back into the pane.
357
407
  lastSentText.set(entry.terminalId, v.text);
358
408
  touchTerminal(entry);
359
- 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 } };
360
431
  }
361
432
 
362
- async function executeTerminalWait(params: ToolParams) {
433
+ async function executeTerminalWait(params: ToolParams, _ctx?: unknown, signal?: AbortSignal) {
363
434
  const entry = findOpenTerminal(params?.terminal_id);
364
435
  if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
365
436
  // Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
@@ -369,23 +440,34 @@ export default function terminalPlugin(ctx: Context): void {
369
440
  const useRegex = params?.regex === true;
370
441
  if (useRegex) {
371
442
  try {
372
- new RegExp(raw);
443
+ compilePattern(raw);
373
444
  } catch (e) {
374
445
  return fail(`invalid regex: ${(e as Error).message}`);
375
446
  }
376
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
+ }
377
453
  // The shell echoes typed commands into the pane, so a pattern contained in the last sent text
378
454
  // matches its own echo and "succeeds" instantly, before the job finishes (observed: a
379
455
  // minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
380
456
  if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
381
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).';
382
465
  return fail(
383
466
  `pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
384
467
  + 'so the wait would match its own echo and return before the job finishes. '
385
468
  + 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
386
469
  + 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
387
- + 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
388
- + '(the echo contains `DONE_$?`, which that regex cannot match).',
470
+ + advice,
389
471
  );
390
472
  }
391
473
  const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
@@ -406,7 +488,10 @@ export default function terminalPlugin(ctx: Context): void {
406
488
  tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
407
489
  } catch {
408
490
  }
409
- 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';
410
495
  return {
411
496
  content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
412
497
  details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
@@ -418,7 +503,13 @@ export default function terminalPlugin(ctx: Context): void {
418
503
  // waitForOutput (that busy-loops on the old match).
419
504
  const deadline = started + timeoutMs;
420
505
  const cursor: ReadCursor | null = entry.readRevision != null
421
- ? { 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
+ }
422
513
  : null;
423
514
  const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
424
515
  let sawStale = false;
@@ -460,6 +551,53 @@ export default function terminalPlugin(ctx: Context): void {
460
551
  }
461
552
  }
462
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
+
463
601
  /** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
464
602
  * panes, so one task can never read another's pane. Returns the error text instead of throwing. */
465
603
  async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
@@ -514,7 +652,13 @@ export default function terminalPlugin(ctx: Context): void {
514
652
  }
515
653
  const prev: ReadCursor | null = entry
516
654
  ? (entry.readRevision != null
517
- ? { 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
+ }
518
662
  : null)
519
663
  : null;
520
664
  const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
@@ -522,7 +666,8 @@ export default function terminalPlugin(ctx: Context): void {
522
666
  entry.readRevision = inc.cursor.revision;
523
667
  entry.readLen = inc.cursor.len;
524
668
  entry.readTail = inc.cursor.tail;
525
- entry.readEoTail = inc.cursor.eoTail;
669
+ entry.readTextLen = inc.cursor.textLen;
670
+ entry.readTextHash = inc.cursor.textHash;
526
671
  touchTerminal(entry);
527
672
  }
528
673
  if (inc.mode === 'none') {
@@ -15,7 +15,7 @@ import { makeProgressUpdate } from '../subagent-core.ts';
15
15
  import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
16
16
  import { toolError } from '../tool-error.ts';
17
17
  import { formatAge, isArchived } from '../stale-core.ts';
18
- import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
18
+ import { TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, todoListHash, validateTodos, type TodoEditOp } from '../todo-core.ts';
19
19
  import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
20
20
  import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
21
21
  import { swallow } from '../swallow.ts';
@@ -52,13 +52,10 @@ export interface TodoDeps {
52
52
  }
53
53
 
54
54
  const TOOL_DESCRIPTION = [
55
- 'Record and update the complete task list for the current work. Every call REPLACES the entire list (last write wins), so always resend the full list.',
56
- 'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
57
- 'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
58
- 'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
59
- 'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
60
- 'Do not make todo_write the only tool call of a turn — batch it together with real work.',
61
- 'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
55
+ 'Record the complete task list for the current work. Every call REPLACES the whole list, so always resend it in full.',
56
+ 'Entries: `content` (unique short imperative, no numbering), `status`, `blocker` (only when blocked: what it waits for), optional `phase` (group for a multi-stage plan). At most 15 entries, one per meaningful unit of work; several may be in_progress when work truly runs in parallel.',
57
+ 'Do not make todo_write the only tool call of a turn — batch it with real work.',
58
+ 'When you hand an entry to a subagent, append ` <sub>` to its content; it auto-completes when the subagent settles ("Reconciled:" in the note), otherwise update it yourself.',
62
59
  ].join(' ');
63
60
 
64
61
 
@@ -126,7 +123,7 @@ export default function todoPlugin(ctx: Context): void {
126
123
  staleNotices = 0;
127
124
  });
128
125
 
129
- scoped.on('before_agent_start', async (eventCtx?: unknown) => {
126
+ scoped.on('before_agent_start', async (eventCtx?: unknown, ctx?: unknown) => {
130
127
  // pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
131
128
  // or consume the guard cadence (stop reminder, config guide, third-party custom runs).
132
129
  if (!shouldRunTodoRead(eventCtx)) return;
@@ -139,12 +136,28 @@ export default function todoPlugin(ctx: Context): void {
139
136
  now: Date.now(),
140
137
  staleNotices,
141
138
  lastStaleGuardTurn,
139
+ archiveWindowOffered: todos.archiveWindowOffered,
142
140
  });
143
141
  if (plan.inject && plan.effect === 'stale-notice') {
144
142
  staleNotices += 1;
145
143
  lastStaleGuardTurn = todoReadTurn;
146
144
  }
147
- // Empty, archive, and R2 rewrite notices share one cadence so adjacent notices do not each consume the grace window.
145
+ // Persist the one-window offer against this list identity: the plugin closure dies with the
146
+ // process, and on /resume the same dead list must reach the terminal clear instead of re-warning.
147
+ if (plan.inject && plan.offerArchiveWindow) {
148
+ try {
149
+ appendEntry(TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, {
150
+ version: 1,
151
+ itemsHash: todoListHash(todos.items),
152
+ });
153
+ } catch (err) {
154
+ // The in-memory mark still advances escalation in this process.
155
+ swallow('todo.persist-archive-window', err);
156
+ }
157
+ todos.markArchiveWindowOffered();
158
+ }
159
+ // Empty-list guards and the archive rewrite window share one cadence so adjacent notices do not
160
+ // each consume the grace window; the terminal clear is immediate and only marks the tick.
148
161
  if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
149
162
  lastEmptyGuardTurn = todoReadTurn;
150
163
  }
@@ -159,6 +172,11 @@ export default function todoPlugin(ctx: Context): void {
159
172
  swallow('todo.persist-archive', err);
160
173
  }
161
174
  todos.replace([], { source: 'archive' });
175
+ // The clear is the one todo mutation outside the tool/human paths that must refresh the
176
+ // title, sidebar token and widget; the dead list would otherwise stay authoritative. The hook's
177
+ // first argument is the event (no ui), so render through the run ctx; tests pass a ui ctx first.
178
+ mirrorTodos();
179
+ renderWidget(ctx ?? eventCtx);
162
180
  }
163
181
  todoReadTurn += 1;
164
182
  if (!plan.inject) return;
@@ -194,11 +212,18 @@ export default function todoPlugin(ctx: Context): void {
194
212
  const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
195
213
  if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
196
214
  });
197
- for (const ev of ['agent_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
198
- scoped.on('agent_settled', async () => {
215
+ // A retry starts a fresh turn, not necessarily a fresh agent_start: cancel the pending reminder
216
+ // there too so a provider-error retry cannot be interrupted by a stale 30 s timer.
217
+ for (const ev of ['agent_start', 'turn_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
218
+ scoped.on('agent_settled', async (event: unknown) => {
199
219
  cancelTodoReminder();
220
+ // pi ≥ 1.1 carries `aborted` on agent_settled: authoritative for ESC mid-tool, where stopReason
221
+ // tracking sees a stale natural reason and would still schedule a reminder.
222
+ const aborted = event !== null && typeof event === 'object'
223
+ && 'aborted' in event && event.aborted === true;
200
224
  const plan = planStopTodoReminder({
201
225
  lastStopReason: lastAssistantStopReason,
226
+ aborted,
202
227
  intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
203
228
  compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
204
229
  reminders: todoReminders,
@@ -236,23 +261,20 @@ export default function todoPlugin(ctx: Context): void {
236
261
  label: 'Todo',
237
262
  description: TOOL_DESCRIPTION,
238
263
  promptGuidelines: [
239
- 'Before stopping with open todo_write entries, reconcile them: entries waiting on a human decision, approval, or ops action must be marked blocked with a blocker note — never left pending, and never executed just to clear the list.',
264
+ 'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, an ops action you cannot perform) is marked blocked with a blocker note — never left pending, never executed just to clear the list; finished or obsolete entries are completed or removed.',
240
265
  ],
241
266
  parameters: Type.Object({
242
267
  todos: Type.Array(
243
268
  Type.Object(
244
269
  {
245
- content: Type.String({ description: 'What the task is — a short imperative line' }),
246
- status: Type.Union(
247
- TODO_STATUSES.map((status) => Type.Literal(status)),
248
- { description: TODO_STATUSES.join(' | ') },
249
- ),
250
- blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
251
- phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
270
+ content: Type.String({ description: 'Short imperative line' }),
271
+ status: Type.Union(TODO_STATUSES.map((status) => Type.Literal(status))),
272
+ blocker: Type.Optional(Type.String({ description: 'Only for blocked; omit otherwise (never an empty string)' })),
273
+ phase: Type.Optional(Type.String({ description: 'Group name (≤30 chars); omit for a flat list' })),
252
274
  },
253
275
  { additionalProperties: false },
254
276
  ),
255
- { description: 'The COMPLETE task list; this call replaces the previous list' },
277
+ { description: 'The COMPLETE list; replaces the previous one' },
256
278
  ),
257
279
  }),
258
280
  async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
@@ -354,7 +376,7 @@ export default function todoPlugin(ctx: Context): void {
354
376
  }
355
377
  const age = archivedAgeMs();
356
378
  const head = formatTodoSummary(todos.items)
357
- + (age != null ? ` · archived ${formatAge(age)}(不再注入)` : '');
379
+ + (age != null ? ` · archived ${formatAge(age)} (no longer injected)` : '');
358
380
  ui?.notify?.([head, ...body].join('\n'), 'info');
359
381
  },
360
382
  });
@@ -68,6 +68,8 @@ type ReducerModelOutput = {
68
68
  content?: readonly unknown[];
69
69
  text?: unknown;
70
70
  usage?: UsageLike;
71
+ stopReason?: string;
72
+ errorMessage?: string;
71
73
  };
72
74
 
73
75
  type ReducerModel = NonNullable<ExtensionContext['model']>;
@@ -103,7 +105,7 @@ function resolveCandidate(
103
105
  if (!isTrusted) {
104
106
  if (!untrustedWarningEmitted) {
105
107
  untrustedWarningEmitted = true;
106
- console.warn('[pi-herdr] EPR 提炼已在未受信任项目中被安全禁用');
108
+ console.warn('[pi-herdr] EPR refinement is safely disabled in untrusted projects');
107
109
  }
108
110
  return undefined;
109
111
  }
@@ -139,23 +141,23 @@ function resolveCandidate(
139
141
  }
140
142
 
141
143
  /**
142
- * Step 6, jev first (RFC §3 P0-1): a confident jev rejection is authoritative even for regex-listed
143
- * commands; the regex list decides only when jev is absent, fails or answers below the confidence
144
- * gate (credential-shaped command lines stay local).
144
+ * Step 6, regex first: only regex-listed commands are candidates; jev may veto them (a confident
145
+ * rejection is authoritative) but never promotes an unlisted command. Asking jev about every result
146
+ * flipped 0 of 437 decisions over a week while blocking each tool result ~0.8s and uploading every
147
+ * command line (D122). Jev failures, low confidence and credential-shaped commands keep the regex hit.
145
148
  */
146
149
  async function isDiagnosticCandidate(
147
150
  command: string,
148
151
  gate: JevEprGateDependency | undefined,
149
152
  sessionId: string | undefined,
150
153
  ): Promise<boolean> {
151
- const regexHit = isDiagnosticCommand(command);
152
- if (!gate || containsLikelySecret(command)) return regexHit;
154
+ if (!isDiagnosticCommand(command)) return false;
155
+ if (!gate || containsLikelySecret(command)) return true;
153
156
  const result = await gate.ask(diagnosticGateRequest(command), {
154
157
  questionId: 'epr-diagnostic-gate',
155
158
  timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
156
159
  sessionId,
157
- // regex_hit makes the flip observable: how often and in which direction jev overrides the regex list (jev.jsonl).
158
- extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
160
+ extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
159
161
  enrich: ({ answers }) => {
160
162
  if (!answers) return {};
161
163
  const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
@@ -167,10 +169,9 @@ async function isDiagnosticCandidate(
167
169
  };
168
170
  },
169
171
  });
170
- if (!result.ok) return regexHit;
172
+ if (!result.ok) return true;
171
173
  const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
172
- if (verdict.hit) return true;
173
- return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
174
+ return verdict.hit || isDiagnosticGateUnanswered(verdict);
174
175
  }
175
176
 
176
177
  /** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
@@ -317,7 +318,10 @@ export async function handleReducerToolResult(
317
318
  if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
318
319
 
319
320
  const reducer = resolveReducerModel(ctx, config);
320
- if (!reducer) return undefined;
321
+ if (!reducer) {
322
+ await log({ sourceBytes, model: config.model ?? 'default', verificationOk: false, reason: 'no-reducer-model', action: 'fallback_full_text' });
323
+ return undefined;
324
+ }
321
325
  /** Evidence row for a fail-open exit taken after the reducer model was resolved. */
322
326
  const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
323
327
  log({
@@ -354,7 +358,14 @@ export async function handleReducerToolResult(
354
358
  : typeof output.text === 'string'
355
359
  ? output.text
356
360
  : '';
357
- if (!modelOutput.trim()) return undefined;
361
+ if (!modelOutput.trim()) {
362
+ // pi's complete() reports provider failures as an empty `error` message instead of throwing.
363
+ await fallbackRow(output.stopReason === 'error' ? 'invoke-failed' : 'empty-output', durationMs, {
364
+ ...(output.stopReason ? { stopReason: output.stopReason } : {}),
365
+ ...(output.errorMessage ? { error: output.errorMessage } : {}),
366
+ });
367
+ return undefined;
368
+ }
358
369
 
359
370
  const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
360
371
  if (!validated.ok) {
@@ -377,7 +388,10 @@ export async function handleReducerToolResult(
377
388
  totalTokens: output.usage?.totalTokens,
378
389
  });
379
390
  const receiptBytes = Buffer.byteLength(receipt, 'utf8');
380
- if (receiptBytes >= sourceBytes) return undefined;
391
+ if (receiptBytes >= sourceBytes) {
392
+ await fallbackRow('receipt-not-smaller', durationMs, { receiptBytes });
393
+ return undefined;
394
+ }
381
395
 
382
396
  await log({
383
397
  sourceBytes,