pi-pier 0.3.0 → 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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -1,10 +1,8 @@
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';
@@ -12,9 +10,6 @@ import type { PiSurface } from '../pi-surface.ts';
12
10
  import type { HerdrClientLike } from '../herdr-client.ts';
13
11
  import { herdrUnavailableHint } from '../herdr-client.ts';
14
12
  import { toolError } from '../tool-error.ts'
15
-
16
- /** Raw tool arguments: every field is validated inside the action handlers. */
17
- type ToolParams = Record<string, unknown> | undefined;;
18
13
  import {
19
14
  READINESS_TIMEOUT_MS,
20
15
  READ_MAX_CHARS,
@@ -24,6 +19,7 @@ import {
24
19
  classifyReadiness,
25
20
  closeTerminal,
26
21
  computeIncrement,
22
+ classifyWaitMatch,
27
23
  detectFullscreenTUI,
28
24
  foldTerminalsRegistry,
29
25
  makeTerminalsRegistry,
@@ -31,17 +27,26 @@ import {
31
27
  planShellInit,
32
28
  promptStrategyFor,
33
29
  registerTerminal,
30
+ sentinelEchoHazard,
34
31
  stripAnsi,
35
32
  summarizeSessions,
36
33
  terminalReminderGraceMs,
37
34
  validateSendText,
38
35
  validateSignal,
39
36
  type ReadCursor,
37
+ type ReadinessTier,
40
38
  type TerminalEntry,
41
39
  } from '../terminal-core.ts';
42
40
  import { swallow } from '../swallow.ts';
43
41
  import { pierOption } from '../pier-options.ts';
44
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>;
49
+
45
50
  export interface TerminalStateSlot {
46
51
  /** Lets index GC preserve panes that host active terminals. */
47
52
  activePaneIds: () => Set<string>;
@@ -62,12 +67,14 @@ export default function terminalPlugin(ctx: Context): void {
62
67
  // Keying the surface by this file lets HMR tombstone and replace exactly this registration.
63
68
  const scoped = surface.forModule(import.meta.url);
64
69
 
65
- /* ── M14 resident terminal tools (D71 / T1–T6) ────────────────────
66
- * Dedicated herdr panes preserve shell state without reusing the pi TUI pane, and active
67
- * 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. */
68
72
 
69
73
  let terminals: TerminalEntry[] = [];
70
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
+
71
78
  function persistTerminals(): void {
72
79
  try {
73
80
  pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
@@ -97,7 +104,6 @@ export default function terminalPlugin(ctx: Context): void {
97
104
  persistTerminals();
98
105
  }
99
106
 
100
- // Expose active panes so index GC does not collect resident terminal shells.
101
107
  state.activePaneIds = () => activeTerminalPaneIds(terminals);
102
108
 
103
109
  scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
@@ -108,9 +114,8 @@ export default function terminalPlugin(ctx: Context): void {
108
114
  });
109
115
 
110
116
  // Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
111
- // terminals are persistent by design, so a master that exits without closing them leaks live
112
- // zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
113
- // switching branches cannot kill shells that the registry replays on the next session_start.
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.
114
119
  scoped.on('session_shutdown', async () => {
115
120
  idleReminderTimer && clearTimeout(idleReminderTimer);
116
121
  idleReminderTimer = null;
@@ -124,14 +129,13 @@ export default function terminalPlugin(ctx: Context): void {
124
129
  persistTerminals();
125
130
  });
126
131
 
127
- // Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
128
- // a one-shot background compile left a dead split in the main tab forever). Same delivery
132
+ // Turn-end nudge: remind the model to close terminals idle past the threshold. Same delivery
129
133
  // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
130
134
  const piSend = surface.raw as {
131
135
  sendMessage?: (
132
136
  message: { customType: string; content: string; display?: boolean },
133
137
  opts?: { deliverAs?: string; triggerTurn?: boolean },
134
- ) => Promise<void>;
138
+ ) => void;
135
139
  };
136
140
  let idleReminders = 0;
137
141
  let idleReminderTimer: NodeJS.Timeout | null = null;
@@ -152,9 +156,8 @@ export default function terminalPlugin(ctx: Context): void {
152
156
  reminders: idleReminders,
153
157
  });
154
158
  if (!plan.due || plan.content == null) return;
155
- // 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
156
- // notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
157
- // agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
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.
158
161
  const now = Date.now();
159
162
  const ids = new Set(plan.ids);
160
163
  terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
@@ -163,18 +166,16 @@ export default function terminalPlugin(ctx: Context): void {
163
166
  const content = plan.content;
164
167
  idleReminderTimer = setTimeout(() => {
165
168
  idleReminderTimer = null;
166
- void (async () => {
167
- const send = piSend.sendMessage;
168
- if (typeof send !== 'function') return;
169
- try {
170
- await send(
171
- { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
172
- { deliverAs: 'followUp' },
173
- );
174
- } catch {
175
- /* Delivery failure is non-fatal; the cap already bounds retries. */
176
- }
177
- })();
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
+ }
178
179
  }, terminalReminderGraceMs());
179
180
  idleReminderTimer.unref?.();
180
181
  });
@@ -189,6 +190,17 @@ export default function terminalPlugin(ctx: Context): void {
189
190
  /** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
190
191
  const fail = (msg: string): never => toolError(msg);
191
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
+ };
203
+
192
204
  scoped.registerTool({
193
205
  name: 'terminal',
194
206
  label: 'Terminal',
@@ -196,10 +208,7 @@ export default function terminalPlugin(ctx: Context): void {
196
208
  'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
197
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).',
198
210
  'The shell keeps cwd, environment variables, and background processes across calls.',
199
- 'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
200
- 'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
201
- 'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
202
- 'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
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.',
203
212
  ].join(' '),
204
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.',
205
214
  promptGuidelines: [
@@ -210,20 +219,12 @@ export default function terminalPlugin(ctx: Context): void {
210
219
  'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
211
220
  ],
212
221
  parameters: Type.Object({
213
- action: Type.Union([
214
- Type.Literal('open'),
215
- Type.Literal('send'),
216
- Type.Literal('wait'),
217
- Type.Literal('read'),
218
- Type.Literal('signal'),
219
- Type.Literal('close'),
220
- Type.Literal('list'),
221
- ], { description: 'Terminal operation to perform' }),
222
+ action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
222
223
  cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
223
- 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' })),
224
225
  text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
225
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' })),
226
- 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)' })),
227
228
  regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
228
229
  timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
229
230
  pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
@@ -232,39 +233,43 @@ export default function terminalPlugin(ctx: Context): void {
232
233
  }),
233
234
  async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
234
235
  const action = typeof params?.action === 'string' ? params.action : '';
235
- switch (action) {
236
- case 'open': return executeTerminalOpen(params, ctx);
237
- case 'send': return executeTerminalSend(params);
238
- case 'wait': return executeTerminalWait(params);
239
- case 'read': return executeTerminalRead(params);
240
- case 'signal': return executeTerminalSignal(params);
241
- case 'close': return executeTerminalClose(params);
242
- case 'list': return executeTerminalList();
243
- default:
244
- return fail(action
245
- ? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
246
- : 'action is required (open, send, wait, read, signal, close, list)');
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})`);
247
242
  }
243
+ return handler(params, ctx);
248
244
  },
249
245
  });
250
246
 
251
247
  /** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
252
- async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
248
+ async function probeReadiness(paneId: string): Promise<ReadinessTier> {
253
249
  const prompt = promptStrategyFor();
254
250
  try {
255
251
  const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
256
- const isMatched = waitRes && typeof waitRes === 'object' && 'matched' in waitRes
257
- ? (waitRes as { matched: boolean }).matched
258
- : Boolean(waitRes);
259
- if (isMatched) return 'prompt';
252
+ if (waitRes.matched) return 'prompt';
260
253
  const read = await client.readPane(paneId, { stripAnsi: false });
261
254
  return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
262
255
  } catch {
263
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
264
256
  return 'busy';
265
257
  }
266
258
  }
267
259
 
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
+
268
273
  async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
269
274
  if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
270
275
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
@@ -273,6 +278,9 @@ export default function terminalPlugin(ctx: Context): void {
273
278
  paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
274
279
  tabId: env.tabId,
275
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,
276
284
  createdAt: Date.now(),
277
285
  });
278
286
  if (!r.ok) return fail(r.error);
@@ -287,16 +295,9 @@ export default function terminalPlugin(ctx: Context): void {
287
295
  terminals = r.entries;
288
296
  persistTerminals();
289
297
  const readiness = await probeReadiness(paneId);
290
- const prompt = promptStrategyFor();
291
- const init = planShellInit({ strategy: prompt, readiness });
292
- if (init.shouldInit && init.command) {
293
- try {
294
- await client.sendPaneText(paneId, init.command);
295
- r.entry.initialized = true;
296
- persistTerminals();
297
- } catch {
298
- /* Shell init is best-effort: failure to set +H does not block terminal usage */
299
- }
298
+ if (await injectShellInit(r.entry, readiness)) {
299
+ r.entry.initialized = true;
300
+ persistTerminals();
300
301
  }
301
302
  const text = [
302
303
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
@@ -312,22 +313,13 @@ export default function terminalPlugin(ctx: Context): void {
312
313
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
313
314
  if (!v.ok) return fail(v.error);
314
315
  if (!entry.initialized) {
315
- const prompt = promptStrategyFor();
316
- const init = planShellInit({ strategy: prompt });
317
- if (init.shouldInit && init.command) {
318
- try {
319
- await client.sendPaneText(entry.paneId, init.command);
320
- } catch {
321
- /* Shell init is best-effort */
322
- }
323
- }
316
+ await injectShellInit(entry);
324
317
  entry.initialized = true;
325
318
  persistTerminals();
326
319
  }
327
320
  if (params?.wait_prompt === true) {
328
- // Orchestration convention: read before writing. Text typed while a previous command still
329
- // owns the foreground gets queued and fires later, which reads as "the shell ignored me".
330
- // Refuse with the observed readiness instead of guessing.
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.
331
323
  const readiness = await probeReadiness(entry.paneId);
332
324
  if (readiness !== 'prompt') {
333
325
  return fail(
@@ -343,6 +335,9 @@ export default function terminalPlugin(ctx: Context): void {
343
335
  // model hunting for a pane that is fine. Prefer the actionable transport sentence.
344
336
  return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
345
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);
346
341
  touchTerminal(entry);
347
342
  return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
348
343
  }
@@ -360,33 +355,37 @@ export default function terminalPlugin(ctx: Context): void {
360
355
  return fail(`invalid regex: ${(e as Error).message}`);
361
356
  }
362
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
+ }
363
372
  const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
364
373
  const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
374
+ const started = Date.now();
365
375
  const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
366
376
  let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
367
377
  try {
368
- const res = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
369
- if (typeof res === 'object' && res !== null && 'matched' in res) {
370
- waitResult = res as { matched: boolean; reason?: 'timeout' | 'unavailable' };
371
- } else if (res === true) {
372
- waitResult = { matched: true };
373
- } else if (res === false) {
374
- waitResult = { matched: false, reason: 'timeout' };
375
- } else {
376
- waitResult = { matched: false, reason: 'unavailable' };
377
- }
378
+ waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
378
379
  } catch (e) {
379
380
  return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
380
381
  }
381
382
  if (!waitResult.matched) {
382
- // wait-for-text convention: on timeout, hand back the recent tail so the model can decide
383
- // the next move instead of re-polling blind.
383
+ // wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
384
384
  let tail = '';
385
385
  try {
386
386
  const read = await client.readPane(entry.paneId, { stripAnsi: false });
387
387
  tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
388
388
  } catch {
389
- /* The pane is gone; the timeout text alone still tells the model it is stuck. */
390
389
  }
391
390
  const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
392
391
  return {
@@ -394,47 +393,87 @@ export default function terminalPlugin(ctx: Context): void {
394
393
  details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
395
394
  };
396
395
  }
397
- return {
398
- content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
399
- details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
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 },
400
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
+ }
442
+ }
443
+
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 };
401
459
  }
402
460
 
403
461
  async function executeTerminalRead(params: ToolParams) {
404
- let paneId: string | null = null;
462
+ let paneId: string;
405
463
  let entry: TerminalEntry | null = null;
406
464
  if (typeof params?.terminal_id === 'string') {
407
465
  entry = findOpenTerminal(params.terminal_id);
408
466
  if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
409
467
  paneId = entry.paneId;
410
468
  } else if (typeof params?.pane_id === 'string') {
411
- // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
412
- // A1: the guard result is carried out of the try block — a thrown tool error must not be eaten
413
- // by the lookup catch below (it would be reported as a bogus "pane lookup failed").
414
- let allowed = false;
415
- let lookupError: unknown = null;
416
- try {
417
- const panes = await client.listPanes();
418
- const target = panes.find((p) => p.paneId === params.pane_id);
419
- const ownTab = target?.tabId && target.tabId === env?.tabId;
420
- const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
421
- allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
422
- if (allowed) paneId = params.pane_id;
423
- } catch (e) {
424
- lookupError = e;
425
- }
426
- if (lookupError !== null) {
427
- return fail(herdrUnavailableHint(lookupError) ?? `pane lookup failed: ${(lookupError as Error).message}`);
428
- }
429
- if (!allowed) {
430
- return fail('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
431
- }
469
+ const resolved = await resolveDirectPaneId(params.pane_id);
470
+ if (!resolved.ok) return fail(resolved.error);
471
+ paneId = resolved.paneId;
432
472
  } else {
433
473
  return fail('provide terminal_id (or pane_id for a direct own-tab read)');
434
474
  }
435
475
  const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
436
476
  ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
437
- if (paneId === null) return fail('no readable pane resolved (pass terminal_id, or pane_id for an own-tab pane)');
438
477
  let read: { text: string; revision: number; truncated: boolean };
439
478
  try {
440
479
  read = await client.readPane(paneId, { stripAnsi: false });
@@ -504,6 +543,7 @@ export default function terminalPlugin(ctx: Context): void {
504
543
  }
505
544
  }
506
545
  terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
546
+ lastSentText.delete(entry.terminalId);
507
547
  persistTerminals();
508
548
  return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
509
549
  }