pi-pier 0.3.0 → 0.4.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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  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 +10 -13
  9. package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,20 +1,15 @@
1
1
  /**
2
2
  * M14 terminal loader entry for the D78 mount tree and D81 responsibility split.
3
- *
4
- * Keeping this as a Cordis loader plugin makes the terminal surface hot-swappable. Services
5
- * provide the D79 tombstone-aware pi surface, herdr client/environment, and the state slot
6
- * used by index GC. Avoiding an index.ts import preserves an acyclic dependency graph, while
7
- * terminal-core remains independently testable pure logic.
3
+ * A Cordis loader plugin so the terminal surface stays hot-swappable: services provide the tombstone-aware
4
+ * pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
5
+ * pure, independently testable logic.
8
6
  */
9
7
  import { Context } from '@deepseek-ai/cordis';
10
8
  import { Type } from 'typebox';
11
- import type { PiSurface } from '../pi-surface.ts';
9
+ import { cordisGet } from '../cordis-services.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,50 +19,62 @@ import {
24
19
  classifyReadiness,
25
20
  closeTerminal,
26
21
  computeIncrement,
22
+ classifyWaitMatch,
27
23
  detectFullscreenTUI,
28
24
  foldTerminalsRegistry,
29
25
  makeTerminalsRegistry,
30
- planIdleTerminalReminder,
26
+ planTerminalReminder,
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>;
48
53
  }
49
54
 
50
- interface TerminalDeps {
55
+ export interface TerminalDeps {
51
56
  client: HerdrClientLike;
52
57
  env: { paneId: string; tabId: string } | null;
53
58
  state: TerminalStateSlot;
54
59
  }
55
60
 
56
61
  export default function terminalPlugin(ctx: Context): void {
57
- const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
58
- const { client, env, state } = ctx.get('pi-herdr.terminal-deps') as TerminalDeps;
62
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
63
+ const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
59
64
  const pi = surface.raw as {
60
65
  appendEntry?: (customType: string, data: unknown) => void;
61
66
  };
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,18 +129,20 @@ 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
129
- // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
132
+ // Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
133
+ // threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
134
+ // capped per terminal.
130
135
  const piSend = surface.raw as {
131
136
  sendMessage?: (
132
137
  message: { customType: string; content: string; display?: boolean },
133
138
  opts?: { deliverAs?: string; triggerTurn?: boolean },
134
- ) => Promise<void>;
139
+ ) => void;
135
140
  };
136
- let idleReminders = 0;
141
+ /** Start of the current agent run; terminals touched since then count as "used this turn". */
142
+ let turnStartedAt: number | null = null;
137
143
  let idleReminderTimer: NodeJS.Timeout | null = null;
138
144
  scoped.on('agent_start', () => {
145
+ turnStartedAt = Date.now();
139
146
  if (idleReminderTimer !== null) {
140
147
  clearTimeout(idleReminderTimer);
141
148
  idleReminderTimer = null;
@@ -146,35 +153,46 @@ export default function terminalPlugin(ctx: Context): void {
146
153
  clearTimeout(idleReminderTimer);
147
154
  idleReminderTimer = null;
148
155
  }
149
- const plan = planIdleTerminalReminder({
150
- open: terminals.filter((t) => t.status === 'open'),
156
+ const open = await Promise.all(
157
+ terminals
158
+ .filter((t) => t.status === 'open')
159
+ .map(async (t) => {
160
+ // A pane running a foreground job (dev server, REPL, load test) is in use even when the
161
+ // last tool interaction is old: nudge only after it is back at a shell prompt.
162
+ const info = await client.processInfo(t.paneId).catch(() => null);
163
+ const busy = info?.shellPid != null
164
+ && info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
165
+ return { ...t, busy };
166
+ }),
167
+ );
168
+ const plan = planTerminalReminder({
169
+ open,
151
170
  now: Date.now(),
152
- reminders: idleReminders,
171
+ turnStartedAt,
153
172
  });
173
+ turnStartedAt = null;
154
174
  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 …
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.
158
177
  const now = Date.now();
159
178
  const ids = new Set(plan.ids);
160
- terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
179
+ terminals = terminals.map((t) => (ids.has(t.terminalId)
180
+ ? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
181
+ : t));
161
182
  persistTerminals();
162
- idleReminders += 1;
163
183
  const content = plan.content;
164
184
  idleReminderTimer = setTimeout(() => {
165
185
  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
- })();
186
+ const send = piSend.sendMessage;
187
+ if (typeof send !== 'function') return;
188
+ try {
189
+ // Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
190
+ send(
191
+ { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
192
+ { deliverAs: 'followUp' },
193
+ );
194
+ } catch {
195
+ }
178
196
  }, terminalReminderGraceMs());
179
197
  idleReminderTimer.unref?.();
180
198
  });
@@ -189,6 +207,17 @@ export default function terminalPlugin(ctx: Context): void {
189
207
  /** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
190
208
  const fail = (msg: string): never => toolError(msg);
191
209
 
210
+ // Action routing: each handler validates its own params, and `open` additionally needs the tool ctx.
211
+ const handlers: Record<TerminalAction, ActionHandler> = {
212
+ open: executeTerminalOpen,
213
+ send: executeTerminalSend,
214
+ wait: executeTerminalWait,
215
+ read: executeTerminalRead,
216
+ signal: executeTerminalSignal,
217
+ close: executeTerminalClose,
218
+ list: executeTerminalList,
219
+ };
220
+
192
221
  scoped.registerTool({
193
222
  name: 'terminal',
194
223
  label: 'Terminal',
@@ -196,10 +225,7 @@ export default function terminalPlugin(ctx: Context): void {
196
225
  'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
197
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).',
198
227
  '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.',
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.',
203
229
  ].join(' '),
204
230
  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
231
  promptGuidelines: [
@@ -210,20 +236,12 @@ export default function terminalPlugin(ctx: Context): void {
210
236
  'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
211
237
  ],
212
238
  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' }),
239
+ action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
222
240
  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' })),
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' })),
224
242
  text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
225
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' })),
226
- pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
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)' })),
227
245
  regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
228
246
  timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
229
247
  pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
@@ -232,39 +250,43 @@ export default function terminalPlugin(ctx: Context): void {
232
250
  }),
233
251
  async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
234
252
  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)');
253
+ const handler: ActionHandler | undefined = handlers[action as TerminalAction];
254
+ if (!handler) {
255
+ const valid = TERMINAL_ACTIONS.join(', ');
256
+ return fail(action
257
+ ? `unknown action "${action}" (valid: ${valid})`
258
+ : `action is required (${valid})`);
247
259
  }
260
+ return handler(params, ctx);
248
261
  },
249
262
  });
250
263
 
251
264
  /** 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'> {
265
+ async function probeReadiness(paneId: string): Promise<ReadinessTier> {
253
266
  const prompt = promptStrategyFor();
254
267
  try {
255
268
  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';
269
+ if (waitRes.matched) return 'prompt';
260
270
  const read = await client.readPane(paneId, { stripAnsi: false });
261
271
  return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
262
272
  } catch {
263
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
264
273
  return 'busy';
265
274
  }
266
275
  }
267
276
 
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. */
279
+ async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
280
+ const init = planShellInit({ strategy: promptStrategyFor(), readiness });
281
+ if (!init.shouldInit || !init.command) return false;
282
+ try {
283
+ await client.sendPaneText(entry.paneId, init.command);
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
268
290
  async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
269
291
  if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
270
292
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
@@ -273,6 +295,9 @@ export default function terminalPlugin(ctx: Context): void {
273
295
  paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
274
296
  tabId: env.tabId,
275
297
  cwd,
298
+ // Adopt the caller-requested id so `open {terminal_id}` + `send {terminal_id}` agree;
299
+ // silently ignoring it made the follow-up send fail on an unknown terminal.
300
+ terminalId: typeof params?.terminal_id === 'string' ? params.terminal_id : null,
276
301
  createdAt: Date.now(),
277
302
  });
278
303
  if (!r.ok) return fail(r.error);
@@ -287,16 +312,9 @@ export default function terminalPlugin(ctx: Context): void {
287
312
  terminals = r.entries;
288
313
  persistTerminals();
289
314
  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
- }
315
+ if (await injectShellInit(r.entry, readiness)) {
316
+ r.entry.initialized = true;
317
+ persistTerminals();
300
318
  }
301
319
  const text = [
302
320
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
@@ -312,22 +330,13 @@ export default function terminalPlugin(ctx: Context): void {
312
330
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
313
331
  if (!v.ok) return fail(v.error);
314
332
  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
- }
333
+ await injectShellInit(entry);
324
334
  entry.initialized = true;
325
335
  persistTerminals();
326
336
  }
327
337
  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.
338
+ // Orchestration convention: read before writing. Text typed while a previous command still owns the
339
+ // foreground gets queued and fires later, which reads as "the shell ignored me"; refuse with the observed readiness.
331
340
  const readiness = await probeReadiness(entry.paneId);
332
341
  if (readiness !== 'prompt') {
333
342
  return fail(
@@ -343,6 +352,9 @@ export default function terminalPlugin(ctx: Context): void {
343
352
  // model hunting for a pane that is fine. Prefer the actionable transport sentence.
344
353
  return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
345
354
  }
355
+ // In-memory only (never persisted): the wait echo-hazard check needs the text this pane last
356
+ // received, because the shell echoes typed commands back into the pane.
357
+ lastSentText.set(entry.terminalId, v.text);
346
358
  touchTerminal(entry);
347
359
  return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
348
360
  }
@@ -350,6 +362,8 @@ export default function terminalPlugin(ctx: Context): void {
350
362
  async function executeTerminalWait(params: ToolParams) {
351
363
  const entry = findOpenTerminal(params?.terminal_id);
352
364
  if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
365
+ // Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
366
+ touchTerminal(entry);
353
367
  const raw = typeof params?.pattern === 'string' ? params.pattern : '';
354
368
  if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
355
369
  const useRegex = params?.regex === true;
@@ -360,33 +374,37 @@ export default function terminalPlugin(ctx: Context): void {
360
374
  return fail(`invalid regex: ${(e as Error).message}`);
361
375
  }
362
376
  }
377
+ // The shell echoes typed commands into the pane, so a pattern contained in the last sent text
378
+ // matches its own echo and "succeeds" instantly, before the job finishes (observed: a
379
+ // minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
380
+ if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
381
+ const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
382
+ return fail(
383
+ `pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
384
+ + 'so the wait would match its own echo and return before the job finishes. '
385
+ + 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
386
+ + '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).',
389
+ );
390
+ }
363
391
  const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
364
392
  const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
393
+ const started = Date.now();
365
394
  const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
366
395
  let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
367
396
  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
- }
397
+ waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
378
398
  } catch (e) {
379
399
  return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
380
400
  }
381
401
  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.
402
+ // wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
384
403
  let tail = '';
385
404
  try {
386
405
  const read = await client.readPane(entry.paneId, { stripAnsi: false });
387
406
  tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
388
407
  } catch {
389
- /* The pane is gone; the timeout text alone still tells the model it is stuck. */
390
408
  }
391
409
  const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
392
410
  return {
@@ -394,47 +412,87 @@ export default function terminalPlugin(ctx: Context): void {
394
412
  details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
395
413
  };
396
414
  }
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 },
415
+ // herdr matches the whole pane, including text a prior read already returned, and a second
416
+ // waitForOutput on that same text returns immediately. After a stale hit, poll the unread
417
+ // increment until a new occurrence appears or the original budget is gone — do not re-enter
418
+ // waitForOutput (that busy-loops on the old match).
419
+ const deadline = started + timeoutMs;
420
+ const cursor: ReadCursor | null = entry.readRevision != null
421
+ ? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
422
+ : null;
423
+ const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
424
+ let sawStale = false;
425
+ const staleFailure = {
426
+ 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.` }],
427
+ details: { terminal_id: entry.terminalId, matched: false, stale: true, pattern: raw },
400
428
  };
429
+ while (true) {
430
+ let paneText = '';
431
+ try {
432
+ const read = await client.readPane(entry.paneId, { stripAnsi: false });
433
+ paneText = stripAnsi(read.text);
434
+ } catch {
435
+ if (sawStale) return staleFailure;
436
+ return {
437
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
438
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
439
+ };
440
+ }
441
+ const classified = classifyWaitMatch(cursor, paneText, raw, useRegex);
442
+ if (classified.kind === 'fresh') {
443
+ return {
444
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — new output contains the pattern:\n${classified.excerpt}` }],
445
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
446
+ };
447
+ }
448
+ if (classified.kind !== 'stale') {
449
+ return {
450
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
451
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
452
+ };
453
+ }
454
+ sawStale = true;
455
+ const left = deadline - Date.now();
456
+ if (left <= 0) return staleFailure;
457
+ const { promise, resolve } = Promise.withResolvers<void>();
458
+ setTimeout(resolve, Math.min(400, left));
459
+ await promise;
460
+ }
461
+ }
462
+
463
+ /** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
464
+ * panes, so one task can never read another's pane. Returns the error text instead of throwing. */
465
+ async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
466
+ let panes;
467
+ try {
468
+ panes = await client.listPanes();
469
+ } catch (e) {
470
+ return { ok: false, error: herdrUnavailableHint(e) ?? `pane lookup failed: ${(e as Error).message}` };
471
+ }
472
+ const target = panes.find((p) => p.paneId === paneId);
473
+ const ownTab = Boolean(target?.tabId) && target?.tabId === env?.tabId;
474
+ if (!target || !(ownTab || activeTerminalPaneIds(terminals).has(paneId))) {
475
+ return { ok: false, error: 'pane_id read is limited to panes in this session\'s own tab or self-created terminal panes' };
476
+ }
477
+ return { ok: true, paneId };
401
478
  }
402
479
 
403
480
  async function executeTerminalRead(params: ToolParams) {
404
- let paneId: string | null = null;
481
+ let paneId: string;
405
482
  let entry: TerminalEntry | null = null;
406
483
  if (typeof params?.terminal_id === 'string') {
407
484
  entry = findOpenTerminal(params.terminal_id);
408
485
  if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
409
486
  paneId = entry.paneId;
410
487
  } 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
- }
488
+ const resolved = await resolveDirectPaneId(params.pane_id);
489
+ if (!resolved.ok) return fail(resolved.error);
490
+ paneId = resolved.paneId;
432
491
  } else {
433
492
  return fail('provide terminal_id (or pane_id for a direct own-tab read)');
434
493
  }
435
494
  const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
436
495
  ? 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
496
  let read: { text: string; revision: number; truncated: boolean };
439
497
  try {
440
498
  read = await client.readPane(paneId, { stripAnsi: false });
@@ -504,6 +562,7 @@ export default function terminalPlugin(ctx: Context): void {
504
562
  }
505
563
  }
506
564
  terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
565
+ lastSentText.delete(entry.terminalId);
507
566
  persistTerminals();
508
567
  return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
509
568
  }