pi-pier 0.2.5 → 0.2.7

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.
@@ -13,6 +13,7 @@ import type { HerdrClientLike } from '../herdr-client.ts';
13
13
  import {
14
14
  READINESS_TIMEOUT_MS,
15
15
  READ_MAX_CHARS,
16
+ TERM_REMINDER_CUSTOM_TYPE,
16
17
  TERMINALS_CUSTOM_TYPE,
17
18
  activeTerminalPaneIds,
18
19
  classifyReadiness,
@@ -21,10 +22,12 @@ import {
21
22
  detectFullscreenTUI,
22
23
  foldTerminalsRegistry,
23
24
  makeTerminalsRegistry,
25
+ planIdleTerminalReminder,
24
26
  promptStrategyFor,
25
27
  registerTerminal,
26
28
  stripAnsi,
27
29
  summarizeSessions,
30
+ terminalReminderGraceMs,
28
31
  validateSendText,
29
32
  validateSignal,
30
33
  type ReadCursor,
@@ -95,7 +98,85 @@ export default function terminalPlugin(ctx: Context): void {
95
98
  rebuildTerminals(eventCtx);
96
99
  });
97
100
 
101
+ // Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
102
+ // terminals are persistent by design, so a master that exits without closing them leaks live
103
+ // zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
104
+ // switching branches cannot kill shells that the registry replays on the next session_start.
105
+ scoped.on('session_shutdown', async () => {
106
+ idleReminderTimer && clearTimeout(idleReminderTimer);
107
+ idleReminderTimer = null;
108
+ const open = terminals.filter((t) => t.status === 'open');
109
+ if (open.length === 0) return;
110
+ await Promise.allSettled(open.map((t) => client.closePane(t.paneId)));
111
+ const now = Date.now();
112
+ terminals = terminals.map((t) =>
113
+ t.status === 'open' ? { ...t, status: 'closed' as const, closedAt: now } : t,
114
+ );
115
+ persistTerminals();
116
+ });
117
+
118
+ // Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
119
+ // a one-shot background compile left a dead split in the main tab forever). Same delivery
120
+ // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
121
+ const piSend = surface.raw as {
122
+ sendMessage?: (
123
+ message: { customType: string; content: string; display?: boolean },
124
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
125
+ ) => Promise<void>;
126
+ };
127
+ let idleReminders = 0;
128
+ let idleReminderTimer: NodeJS.Timeout | null = null;
129
+ scoped.on('agent_start', () => {
130
+ if (idleReminderTimer !== null) {
131
+ clearTimeout(idleReminderTimer);
132
+ idleReminderTimer = null;
133
+ }
134
+ });
135
+ scoped.on('agent_settled', async () => {
136
+ if (idleReminderTimer !== null) {
137
+ clearTimeout(idleReminderTimer);
138
+ idleReminderTimer = null;
139
+ }
140
+ const plan = planIdleTerminalReminder({
141
+ open: terminals.filter((t) => t.status === 'open'),
142
+ now: Date.now(),
143
+ reminders: idleReminders,
144
+ });
145
+ if (!plan.due || plan.content == null) return;
146
+ // 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
147
+ // notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
148
+ // agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
149
+ const now = Date.now();
150
+ const ids = new Set(plan.ids);
151
+ terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
152
+ persistTerminals();
153
+ idleReminders += 1;
154
+ const content = plan.content;
155
+ idleReminderTimer = setTimeout(() => {
156
+ idleReminderTimer = null;
157
+ void (async () => {
158
+ const send = piSend.sendMessage;
159
+ if (typeof send !== 'function') return;
160
+ try {
161
+ await send(
162
+ { customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
163
+ { deliverAs: 'followUp' },
164
+ );
165
+ } catch {
166
+ /* Delivery failure is non-fatal; the cap already bounds retries. */
167
+ }
168
+ })();
169
+ }, terminalReminderGraceMs());
170
+ idleReminderTimer.unref?.();
171
+ });
172
+
98
173
  const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
174
+ /** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
175
+ const WAIT_DEFAULT_MS = 120_000;
176
+ const WAIT_MAX_MS = 600_000;
177
+ /** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
178
+ const WAIT_TAIL_CHARS = 2_000;
179
+
99
180
  const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
100
181
 
101
182
  scoped.registerTool({
@@ -103,22 +184,30 @@ export default function terminalPlugin(ctx: Context): void {
103
184
  label: 'Terminal',
104
185
  description: [
105
186
  'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
106
- 'Operations: open (create session), send (type commands), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
187
+ '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).',
107
188
  'The shell keeps cwd, environment variables, and background processes across calls.',
108
189
  'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
190
+ 'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
191
+ 'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
192
+ 'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
109
193
  ].join(' '),
110
194
  parameters: Type.Object({
111
195
  action: Type.Union([
112
196
  Type.Literal('open'),
113
197
  Type.Literal('send'),
198
+ Type.Literal('wait'),
114
199
  Type.Literal('read'),
115
200
  Type.Literal('signal'),
116
201
  Type.Literal('close'),
117
202
  Type.Literal('list'),
118
203
  ], { description: 'Terminal operation to perform' }),
119
204
  cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
120
- terminal_id: Type.Optional(Type.String({ description: '[send|read|signal|close] Terminal id returned by open action' })),
205
+ terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
121
206
  text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
207
+ wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Verify the shell is back at a prompt before sending; refuses with the detected readiness instead of queueing text behind a running command' })),
208
+ pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
209
+ regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
210
+ timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
122
211
  pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
123
212
  max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
124
213
  key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
@@ -128,18 +217,33 @@ export default function terminalPlugin(ctx: Context): void {
128
217
  switch (action) {
129
218
  case 'open': return executeTerminalOpen(params, ctx);
130
219
  case 'send': return executeTerminalSend(params);
220
+ case 'wait': return executeTerminalWait(params);
131
221
  case 'read': return executeTerminalRead(params);
132
222
  case 'signal': return executeTerminalSignal(params);
133
223
  case 'close': return executeTerminalClose(params);
134
224
  case 'list': return executeTerminalList();
135
225
  default:
136
226
  return errText(action
137
- ? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
138
- : 'action is required (open, send, read, signal, close, list)');
227
+ ? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
228
+ : 'action is required (open, send, wait, read, signal, close, list)');
139
229
  }
140
230
  },
141
231
  });
142
232
 
233
+ /** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
234
+ async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
235
+ const prompt = promptStrategyFor();
236
+ try {
237
+ const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
238
+ if (matched) return 'prompt';
239
+ const read = await client.readPane(paneId, { stripAnsi: false });
240
+ return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
241
+ } catch {
242
+ /* Readiness is advisory, so probe failures still leave a usable terminal. */
243
+ return 'busy';
244
+ }
245
+ }
246
+
143
247
  async function executeTerminalOpen(params, ctx) {
144
248
  if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
145
249
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
@@ -160,18 +264,7 @@ export default function terminalPlugin(ctx: Context): void {
160
264
  r.entry.paneId = paneId;
161
265
  terminals = r.entries;
162
266
  persistTerminals();
163
- const prompt = promptStrategyFor();
164
- let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
165
- try {
166
- const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
167
- if (matched) readiness = 'prompt';
168
- else {
169
- const read = await client.readPane(paneId, { stripAnsi: false });
170
- readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
171
- }
172
- } catch {
173
- /* Readiness is advisory, so probe failures still leave a usable terminal. */
174
- }
267
+ const readiness = await probeReadiness(paneId);
175
268
  const text = [
176
269
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
177
270
  `cwd: ${cwd}`,
@@ -185,6 +278,18 @@ export default function terminalPlugin(ctx: Context): void {
185
278
  if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
186
279
  const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
187
280
  if (!v.ok) return errText(v.error);
281
+ if (params?.wait_prompt === true) {
282
+ // Orchestration convention: read before writing. Text typed while a previous command still
283
+ // owns the foreground gets queued and fires later, which reads as "the shell ignored me".
284
+ // Refuse with the observed readiness instead of guessing.
285
+ const readiness = await probeReadiness(entry.paneId);
286
+ if (readiness !== 'prompt') {
287
+ return errText(
288
+ `shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
289
+ + 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
290
+ );
291
+ }
292
+ }
188
293
  try {
189
294
  await client.sendPaneText(entry.paneId, v.text);
190
295
  } catch (e) {
@@ -194,6 +299,50 @@ export default function terminalPlugin(ctx: Context): void {
194
299
  return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
195
300
  }
196
301
 
302
+ async function executeTerminalWait(params) {
303
+ const entry = findOpenTerminal(params?.terminal_id);
304
+ if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
305
+ const raw = typeof params?.pattern === 'string' ? params.pattern : '';
306
+ if (!raw) return errText('pattern is required (literal text, or a regular expression with regex: true)');
307
+ const useRegex = params?.regex === true;
308
+ if (useRegex) {
309
+ try {
310
+ new RegExp(raw);
311
+ } catch (e) {
312
+ return errText(`invalid regex: ${(e as Error).message}`);
313
+ }
314
+ }
315
+ const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
316
+ const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
317
+ const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
318
+ let matched: boolean | null;
319
+ try {
320
+ matched = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
321
+ } catch (e) {
322
+ return errText(`wait failed (pane may be closed): ${(e as Error).message}`);
323
+ }
324
+ if (matched !== true) {
325
+ // wait-for-text convention: on timeout, hand back the recent tail so the model can decide
326
+ // the next move instead of re-polling blind.
327
+ let tail = '';
328
+ try {
329
+ const read = await client.readPane(entry.paneId, { stripAnsi: false });
330
+ tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
331
+ } catch {
332
+ /* The pane is gone; the timeout text alone still tells the model it is stuck. */
333
+ }
334
+ const reason = matched === false ? 'no match' : 'wait unavailable';
335
+ return {
336
+ content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
337
+ details: { terminal_id: entry.terminalId, matched: false, pattern: raw },
338
+ };
339
+ }
340
+ return {
341
+ content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
342
+ details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
343
+ };
344
+ }
345
+
197
346
  async function executeTerminalRead(params) {
198
347
  let paneId: string | null = null;
199
348
  let entry: TerminalEntry | null = null;
package/src/core/todo.ts CHANGED
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * A Cordis plugin keeps the master surface hot-swappable while allowing workers to mount the
5
5
  * one-shot subset directly. Injected services preserve the D79 registration boundary and keep
6
- * session-owned todo state in index. This module owns the tool, command, widget, and read hook;
7
- * reconciliation, mirroring, and agent reporting stay with the session state layer to avoid a
8
- * dependency cycle.
6
+ * session-owned todo state in index. This module owns the tool, command, widget, read hook,
7
+ * and master stop reminder; reconciliation, mirroring, and agent reporting stay with the
8
+ * session state layer to avoid a dependency cycle.
9
9
  */
10
10
  import { Context } from '@deepseek-ai/cordis';
11
11
  import { Type } from 'typebox';
12
12
  import type { PiSurface } from '../pi-surface.ts';
13
13
  import type { TodosService } from '../todos-service.ts';
14
14
  import { planTodoReadHook } from '../todo-read-hook.ts';
15
+ import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
15
16
  import { makeProgressUpdate } from '../subagent-core.ts';
16
17
  import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
17
18
  import { formatAge, isArchived } from '../stale-core.ts';
@@ -30,6 +31,8 @@ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-wi
30
31
  export interface TodoUiSlot {
31
32
  /** Lets index lifecycle events render through the currently mounted plugin. */
32
33
  renderWidget: (ctx: unknown) => void;
34
+ /** Re-render from the last seen context without a fresh event (human-gate open/close). */
35
+ rerenderWidget?: () => void;
33
36
  }
34
37
 
35
38
  interface TodoDeps {
@@ -40,6 +43,13 @@ interface TodoDeps {
40
43
  mirrorTodos: () => void;
41
44
  appendEntry: (customType: string, data: unknown) => void;
42
45
  state: TodoUiSlot;
46
+ /** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
47
+ getBlockedDepth?: () => number;
48
+ /** Master-only unfinished-todo reminder; omitted on worker panes. */
49
+ stopReminder?: {
50
+ getBlockedDepth: () => number;
51
+ getRunningSubs: () => number;
52
+ };
43
53
  }
44
54
 
45
55
  const TOOL_DESCRIPTION = [
@@ -61,8 +71,18 @@ export const WIDGET_MAX_LINES = 10;
61
71
  * completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
62
72
  * where active work scrolled out while only the final entries remained visible.
63
73
  */
64
- export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
74
+ export function widgetLines(
75
+ items: readonly TodoItem[],
76
+ opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
77
+ ): string[] {
65
78
  if (items.length === 0) return [];
79
+ // Human gate open (ask_user_question waiting): the question + editor own the fixed area, so the
80
+ // widget collapses to the one-line summary — /todos still reaches the full list. Without this,
81
+ // a 10-line widget plus a multi-line question leaves almost no scrollable transcript
82
+ // (user-reported: both blocks visible ⇒ history range too small).
83
+ if ((opts?.blockedDepth ?? 0) > 0) {
84
+ return [`${formatTodoSummary(items)} · /todos 全量`];
85
+ }
66
86
  // Collapse archived plans to two lines so stale work cannot monopolize the widget.
67
87
  if (opts?.archivedAgeMs != null) {
68
88
  return [
@@ -89,7 +109,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
89
109
 
90
110
  export default function todoPlugin(ctx: Context): void {
91
111
  const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
92
- const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
112
+ const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
93
113
  ctx.get('pi-herdr.todo-deps') as TodoDeps;
94
114
  const scoped = surface.forModule(import.meta.url);
95
115
 
@@ -109,9 +129,15 @@ export default function todoPlugin(ctx: Context): void {
109
129
  : undefined;
110
130
  }
111
131
 
132
+ /** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
133
+ let lastEventCtx: unknown = null;
112
134
  function renderWidget(eventCtx: unknown): void {
135
+ if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
113
136
  try {
114
- widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, { archivedAgeMs: archivedAgeMs() }));
137
+ widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
138
+ archivedAgeMs: archivedAgeMs(),
139
+ blockedDepth: getBlockedDepth?.() ?? 0,
140
+ }));
115
141
  } catch {
116
142
  /* Older pi versions may omit widget support without disabling todo tracking. */
117
143
  }
@@ -119,6 +145,7 @@ export default function todoPlugin(ctx: Context): void {
119
145
 
120
146
  // Let index lifecycle hooks call the current plugin implementation.
121
147
  state.renderWidget = renderWidget;
148
+ state.rerenderWidget = () => renderWidget(lastEventCtx);
122
149
 
123
150
  /* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
124
151
  let todoReadTurn = 0;
@@ -178,6 +205,64 @@ export default function todoPlugin(ctx: Context): void {
178
205
  };
179
206
  });
180
207
 
208
+ if (stopReminder) {
209
+ const pi = surface.raw as {
210
+ sendMessage?: (
211
+ message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
212
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
213
+ ) => Promise<void>;
214
+ };
215
+ let todoReminders = 0;
216
+ let lastAssistantStopReason: string | null = null;
217
+ let todoReminderTimer: NodeJS.Timeout | null = null;
218
+ function cancelTodoReminder(): void {
219
+ if (todoReminderTimer !== null) {
220
+ clearTimeout(todoReminderTimer);
221
+ todoReminderTimer = null;
222
+ }
223
+ }
224
+ scoped.on('turn_end', async (event: unknown) => {
225
+ if (event === null || typeof event !== 'object' || !('message' in event)) return;
226
+ const msg = (event as { message: unknown }).message;
227
+ if (msg === null || typeof msg !== 'object') return;
228
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
229
+ if (role === 'assistant' && typeof stopReason === 'string') {
230
+ lastAssistantStopReason = stopReason;
231
+ }
232
+ });
233
+ scoped.on('agent_start', () => cancelTodoReminder());
234
+ scoped.on('session_shutdown', () => cancelTodoReminder());
235
+ scoped.on('agent_settled', async () => {
236
+ cancelTodoReminder();
237
+ const plan = planStopTodoReminder({
238
+ lastStopReason: lastAssistantStopReason,
239
+ reminders: todoReminders,
240
+ runningSubs: stopReminder.getRunningSubs(),
241
+ blockedDepth: stopReminder.getBlockedDepth(),
242
+ items: todos.items,
243
+ });
244
+ if (!plan.due || plan.content == null) return;
245
+ const content = plan.content;
246
+ todoReminderTimer = setTimeout(() => {
247
+ todoReminderTimer = null;
248
+ void (async () => {
249
+ const send = pi.sendMessage;
250
+ if (typeof send !== 'function') return;
251
+ try {
252
+ await send(
253
+ { customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
254
+ { deliverAs: 'followUp', triggerTurn: true },
255
+ );
256
+ todoReminders += 1;
257
+ } catch {
258
+ /* Delivery failure is non-fatal. */
259
+ }
260
+ })();
261
+ }, todoReminderGraceMs());
262
+ todoReminderTimer.unref?.();
263
+ });
264
+ }
265
+
181
266
  /* ── todo_write tool ───────────────────────────────────────────── */
182
267
  scoped.registerTool({
183
268
  name: TODO_TOOL_NAME,
@@ -86,9 +86,14 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
86
86
  maxItems: 15,
87
87
  mirrorTodos: m.mirrorTodos,
88
88
  appendEntry: (customType: string, data: unknown) => {
89
- (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
89
+ (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
90
90
  },
91
91
  state: m.todoUi,
92
+ getBlockedDepth: m.getBlockedDepth,
93
+ stopReminder: {
94
+ getBlockedDepth: m.getBlockedDepth,
95
+ getRunningSubs: () => m.port.current?.listRunningSubs().length ?? 0,
96
+ },
92
97
  });
93
98
  await loadEntry(sessionRoot, useLoader, './core/todo.ts', todoPlugin);
94
99
 
@@ -101,12 +106,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
101
106
  deliverNotice: m.deliverNotice,
102
107
  noticePending: m.noticePending,
103
108
  getSessionId: m.getSessionId,
104
- getBlockedDepth: m.getBlockedDepth,
105
109
  reconcileOnSettlement: m.reconcileOnSettlement,
106
110
  withReconcileNotes: m.withReconcileNotes,
107
111
  claimSettleNotice: m.claimSettleNotice,
108
112
  terminalState: terminalDeps.state,
109
- todos: m.todos,
110
113
  });
111
114
  await loadEntry(sessionRoot, useLoader, './core/subagent.ts', subagentPlugin);
112
115
 
@@ -10,14 +10,18 @@ import { detectHerdrEnv } from './herdr-client.ts';
10
10
  export interface IndexMode {
11
11
  /** PI_HERDR_SUBAGENT=1 → worker pane (no subagent tools, no cordis loader). */
12
12
  readonly isSubagent: boolean;
13
- /** herdr pane that is not a worker → apply builtin master manifest. */
13
+ /** HERDR_ENV + socket + pane are present. */
14
+ readonly hasHerdr: boolean;
15
+ /** herdr pane that is not a worker → apply builtin master manifest and mount workbench plugins. */
14
16
  readonly composeMaster: boolean;
15
17
  }
16
18
 
17
19
  export function planIndexMode(env: NodeJS.ProcessEnv = process.env): IndexMode {
18
20
  const isSubagent = env.PI_HERDR_SUBAGENT === '1';
21
+ const hasHerdr = Boolean(detectHerdrEnv(env));
19
22
  return {
20
23
  isSubagent,
21
- composeMaster: Boolean(detectHerdrEnv(env)) && !isSubagent,
24
+ hasHerdr,
25
+ composeMaster: hasHerdr && !isSubagent,
22
26
  };
23
27
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Worker-process todo plugin mount (bare cordis root, no loader/hmr).
2
+ * Todo-only plugin mount (bare cordis root, no loader/hmr).
3
3
  *
4
- * Why: short-lived worker panes only need the todo coordination plugin.
5
- * Dynamically imported from the worker branch so bootstrap/loader stay out.
4
+ * Used by worker panes and by standalone pi outside herdr, so bootstrap/subagent/terminal
5
+ * stay out of those processes.
6
6
  */
7
7
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
8
8
  import { Context } from '@deepseek-ai/cordis';
@@ -11,14 +11,21 @@ import todoPlugin from './core/todo.ts';
11
11
  import type { TodosService } from './todos-service.ts';
12
12
  import type { TodoUiSlot } from './core/todo.ts';
13
13
 
14
- export interface WorkerPluginMount {
14
+ export interface TodoOnlyMount {
15
15
  pi: ExtensionAPI;
16
16
  todos: TodosService;
17
17
  todoUi: TodoUiSlot;
18
18
  mirrorTodos: () => void;
19
+ /** Human-gate depth source; collapses the todo widget to one line while a gate is open. */
20
+ getBlockedDepth?: () => number;
21
+ /** Master-only stop reminder; omit on worker panes. */
22
+ stopReminder?: {
23
+ getBlockedDepth: () => number;
24
+ getRunningSubs: () => number;
25
+ };
19
26
  }
20
27
 
21
- export async function mountWorkerTodo(m: WorkerPluginMount): Promise<void> {
28
+ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
22
29
  const workerRoot = new Context();
23
30
  const workerSurface = new PiSurface(m.pi as unknown as object);
24
31
  workerRoot.provide('pi-herdr.surface', workerSurface);
@@ -28,9 +35,11 @@ export async function mountWorkerTodo(m: WorkerPluginMount): Promise<void> {
28
35
  maxItems: 15,
29
36
  mirrorTodos: m.mirrorTodos,
30
37
  appendEntry: (customType: string, data: unknown) => {
31
- (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
38
+ (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
32
39
  },
33
40
  state: m.todoUi,
41
+ ...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
42
+ ...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
34
43
  });
35
44
  await workerRoot.plugin(todoPlugin);
36
45
  m.pi.on('session_shutdown', () => {