pi-pier 0.2.4 → 0.2.6

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.
package/src/core/todo.ts CHANGED
@@ -3,22 +3,22 @@
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
- import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
17
+ import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
17
18
  import { formatAge, isArchived } from '../stale-core.ts';
18
19
  import {
19
20
  TODO_EDIT_CUSTOM_TYPE,
20
21
  completionTransitions,
21
- countTodos,
22
22
  fuzzyFind,
23
23
  listsEqual,
24
24
  makeSnapshot,
@@ -26,6 +26,7 @@ import {
26
26
  revertedCompleted,
27
27
  validateTodos,
28
28
  } from '../todo-core.ts';
29
+ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
29
30
 
30
31
  export interface TodoUiSlot {
31
32
  /** Lets index lifecycle events render through the currently mounted plugin. */
@@ -40,6 +41,11 @@ interface TodoDeps {
40
41
  mirrorTodos: () => void;
41
42
  appendEntry: (customType: string, data: unknown) => void;
42
43
  state: TodoUiSlot;
44
+ /** Master-only unfinished-todo reminder; omitted on worker panes. */
45
+ stopReminder?: {
46
+ getBlockedDepth: () => number;
47
+ getRunningSubs: () => number;
48
+ };
43
49
  }
44
50
 
45
51
  const TOOL_DESCRIPTION = [
@@ -52,37 +58,9 @@ const TOOL_DESCRIPTION = [
52
58
  'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
53
59
  ].join(' ');
54
60
 
55
- const TODO_MARKS: Record<TodoStatus, string> = {
56
- pending: '○',
57
- in_progress: '▶',
58
- completed: '✓',
59
- blocked: '■',
60
- abandoned: '✗',
61
- };
62
-
63
61
  /** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
64
62
  export const WIDGET_MAX_LINES = 10;
65
63
 
66
- /** Share phase rendering between the widget and /todos so both views order entries identically. */
67
- function renderGroups(items: readonly TodoItem[]): string[] {
68
- const groups = new Map<string, TodoItem[]>();
69
- for (const it of items) {
70
- const key = it.phase ?? '';
71
- const arr = groups.get(key) ?? [];
72
- arr.push(it);
73
- groups.set(key, arr);
74
- }
75
- const lines: string[] = [];
76
- for (const [phase, list] of groups) {
77
- if (phase) lines.push(` [${phase}]`);
78
- for (const it of list) {
79
- const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
80
- lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
81
- }
82
- }
83
- return lines;
84
- }
85
-
86
64
  /**
87
65
  * Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
88
66
  * tail. The window anchors on the first in-progress item, then the last open item, then recent
@@ -91,21 +69,23 @@ function renderGroups(items: readonly TodoItem[]): string[] {
91
69
  */
92
70
  export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
93
71
  if (items.length === 0) return [];
94
- const c = countTodos(items);
95
72
  // Collapse archived plans to two lines so stale work cannot monopolize the widget.
96
73
  if (opts?.archivedAgeMs != null) {
97
74
  return [
98
- `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓ · archived ${formatAge(opts.archivedAgeMs)}`,
75
+ `${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
99
76
  ' archived — /todos 全量',
100
77
  ];
101
78
  }
102
79
 
103
80
  // Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
104
81
  const renderBudget = WIDGET_MAX_LINES - 1 - 1;
105
- const [start, end] = anchorRange(items, renderBudget);
82
+ const [start, end] = anchorTodoRange(
83
+ items,
84
+ (s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
85
+ );
106
86
  const kept = items.slice(start, end);
107
87
  const hidden = items.filter((_, i) => i < start || i >= end);
108
- const lines = [`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...renderGroups(kept)];
88
+ const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
109
89
  if (hidden.length > 0) {
110
90
  const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
111
91
  lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
@@ -113,47 +93,9 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
113
93
  return lines;
114
94
  }
115
95
 
116
- /**
117
- * Expand around the active anchor within the rendered-line budget, favoring upcoming work while
118
- * guaranteeing that the anchor remains visible.
119
- */
120
- function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
121
- if (renderGroups(items).length <= renderBudget) return [0, items.length];
122
- const anchor = items.findIndex((it) => it.status === 'in_progress');
123
- const anchorIdx = anchor >= 0
124
- ? anchor
125
- : items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
126
- if (anchorIdx < 0) {
127
- // Completed plans keep the most recent tail visible within the line budget.
128
- let end = items.length;
129
- while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
130
- return [0, end];
131
- }
132
- let start = anchorIdx;
133
- let end = anchorIdx + 1;
134
- const fits = () => renderGroups(items.slice(start, end)).length <= renderBudget;
135
- let growTail = true;
136
- while ((end < items.length || start > 0) && (end - start) < items.length) {
137
- if (growTail && end < items.length) {
138
- end += 1;
139
- if (!fits()) { end -= 1; break; }
140
- } else if (start > 0) {
141
- start -= 1;
142
- if (!fits()) { start += 1; break; }
143
- } else if (end < items.length) {
144
- end += 1;
145
- if (!fits()) { end -= 1; break; }
146
- } else {
147
- break;
148
- }
149
- growTail = !growTail; // Alternate sides so both context and upcoming work remain visible.
150
- }
151
- return [start, end];
152
- }
153
-
154
96
  export default function todoPlugin(ctx: Context): void {
155
97
  const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
156
- const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
98
+ const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder } =
157
99
  ctx.get('pi-herdr.todo-deps') as TodoDeps;
158
100
  const scoped = surface.forModule(import.meta.url);
159
101
 
@@ -242,6 +184,64 @@ export default function todoPlugin(ctx: Context): void {
242
184
  };
243
185
  });
244
186
 
187
+ if (stopReminder) {
188
+ const pi = surface.raw as {
189
+ sendMessage?: (
190
+ message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
191
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
192
+ ) => Promise<void>;
193
+ };
194
+ let todoReminders = 0;
195
+ let lastAssistantStopReason: string | null = null;
196
+ let todoReminderTimer: NodeJS.Timeout | null = null;
197
+ function cancelTodoReminder(): void {
198
+ if (todoReminderTimer !== null) {
199
+ clearTimeout(todoReminderTimer);
200
+ todoReminderTimer = null;
201
+ }
202
+ }
203
+ scoped.on('turn_end', async (event: unknown) => {
204
+ if (event === null || typeof event !== 'object' || !('message' in event)) return;
205
+ const msg = (event as { message: unknown }).message;
206
+ if (msg === null || typeof msg !== 'object') return;
207
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
208
+ if (role === 'assistant' && typeof stopReason === 'string') {
209
+ lastAssistantStopReason = stopReason;
210
+ }
211
+ });
212
+ scoped.on('agent_start', () => cancelTodoReminder());
213
+ scoped.on('session_shutdown', () => cancelTodoReminder());
214
+ scoped.on('agent_settled', async () => {
215
+ cancelTodoReminder();
216
+ const plan = planStopTodoReminder({
217
+ lastStopReason: lastAssistantStopReason,
218
+ reminders: todoReminders,
219
+ runningSubs: stopReminder.getRunningSubs(),
220
+ blockedDepth: stopReminder.getBlockedDepth(),
221
+ items: todos.items,
222
+ });
223
+ if (!plan.due || plan.content == null) return;
224
+ const content = plan.content;
225
+ todoReminderTimer = setTimeout(() => {
226
+ todoReminderTimer = null;
227
+ void (async () => {
228
+ const send = pi.sendMessage;
229
+ if (typeof send !== 'function') return;
230
+ try {
231
+ await send(
232
+ { customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
233
+ { deliverAs: 'followUp', triggerTurn: true },
234
+ );
235
+ todoReminders += 1;
236
+ } catch {
237
+ /* Delivery failure is non-fatal. */
238
+ }
239
+ })();
240
+ }, todoReminderGraceMs());
241
+ todoReminderTimer.unref?.();
242
+ });
243
+ }
244
+
245
245
  /* ── todo_write tool ───────────────────────────────────────────── */
246
246
  scoped.registerTool({
247
247
  name: TODO_TOOL_NAME,
@@ -372,14 +372,13 @@ export default function todoPlugin(ctx: Context): void {
372
372
  return;
373
373
  }
374
374
  // /todos is the unbounded view because the widget intentionally prioritizes active context.
375
- const body = renderGroups(todos.items);
375
+ const body = renderTodoGroups(todos.items);
376
376
  if (body.length === 0) {
377
377
  ui?.notify?.('todo list is empty', 'info');
378
378
  return;
379
379
  }
380
- const c = countTodos(todos.items);
381
380
  const age = archivedAgeMs();
382
- const head = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`
381
+ const head = formatTodoSummary(todos.items)
383
382
  + (age != null ? ` · archived ${formatAge(age)}(不再注入)` : '');
384
383
  ui?.notify?.([head, ...body].join('\n'), 'info');
385
384
  },
@@ -89,6 +89,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
89
89
  (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
90
90
  },
91
91
  state: m.todoUi,
92
+ stopReminder: {
93
+ getBlockedDepth: m.getBlockedDepth,
94
+ getRunningSubs: () => m.port.current?.listRunningSubs().length ?? 0,
95
+ },
92
96
  });
93
97
  await loadEntry(sessionRoot, useLoader, './core/todo.ts', todoPlugin);
94
98
 
@@ -101,12 +105,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
101
105
  deliverNotice: m.deliverNotice,
102
106
  noticePending: m.noticePending,
103
107
  getSessionId: m.getSessionId,
104
- getBlockedDepth: m.getBlockedDepth,
105
108
  reconcileOnSettlement: m.reconcileOnSettlement,
106
109
  withReconcileNotes: m.withReconcileNotes,
107
110
  claimSettleNotice: m.claimSettleNotice,
108
111
  terminalState: terminalDeps.state,
109
- todos: m.todos,
110
112
  });
111
113
  await loadEntry(sessionRoot, useLoader, './core/subagent.ts', subagentPlugin);
112
114
 
@@ -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,19 @@ 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
+ /** Master-only stop reminder; omit on worker panes. */
20
+ stopReminder?: {
21
+ getBlockedDepth: () => number;
22
+ getRunningSubs: () => number;
23
+ };
19
24
  }
20
25
 
21
- export async function mountWorkerTodo(m: WorkerPluginMount): Promise<void> {
26
+ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
22
27
  const workerRoot = new Context();
23
28
  const workerSurface = new PiSurface(m.pi as unknown as object);
24
29
  workerRoot.provide('pi-herdr.surface', workerSurface);
@@ -31,6 +36,7 @@ export async function mountWorkerTodo(m: WorkerPluginMount): Promise<void> {
31
36
  (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
32
37
  },
33
38
  state: m.todoUi,
39
+ ...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
34
40
  });
35
41
  await workerRoot.plugin(todoPlugin);
36
42
  m.pi.on('session_shutdown', () => {
package/src/index.ts CHANGED
@@ -7,10 +7,7 @@
7
7
  * Capabilities:
8
8
  * - `todo_write`: replace the complete todo list; the current pi session JSONL is authoritative,
9
9
  * and branches automatically roll back in DSH-aligned semantics.
10
- * - `subagent`: foreground/background delegation. Each child pane is an interactive pi TUI
11
- * with an independent session/context that humans can enter directly; the controller uses
12
- * herdr for state-gated injection (idle only), waiting (agent.wait), and JSONL result reads.
13
- * - `list_agents` / `send_message` (followUp queue semantics) / `interrupt_agent` (esc).
10
+ * - `subagent` / `terminal`: herdr-master only. Bare pi does not register them.
14
11
  * - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
15
12
  *
16
13
  * pi 0.84.2 contract (validated):
@@ -111,9 +108,14 @@ export default async function (pi: ExtensionAPI) {
111
108
 
112
109
  /** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
113
110
  let roleBadge: string | null = null;
111
+ /** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
112
+ let blockedDepth = 0;
114
113
 
115
114
  function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
116
115
  if (!client.available) return;
116
+ // While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
117
+ // Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
118
+ if (blockedDepth > 0 && state !== 'blocked') return;
117
119
  const message = activity ?? (state === 'idle' ? roleBadge : null);
118
120
  client.reportAgent(state, message).catch(() => {});
119
121
  }
@@ -156,8 +158,8 @@ export default async function (pi: ExtensionAPI) {
156
158
  progressSuffix: suffix,
157
159
  lastWriteAt: todos.lastWriteAt,
158
160
  });
159
- // D97: narrow-frame static state shares inputs with the pane title; thinking tokens cannot reach it, so a static frame would freeze.
160
- updateSlimFrame(title);
161
+ // D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
162
+ updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
161
163
  client.reportMetadata({
162
164
  session: label,
163
165
  items: todos.items,
@@ -226,11 +228,13 @@ export default async function (pi: ExtensionAPI) {
226
228
  }
227
229
  });
228
230
 
229
- installWriteLocks(pi, {
230
- client,
231
- env,
232
- hard: process.env[WRITE_LOCK_ENV] === '1',
233
- });
231
+ if (env) {
232
+ installWriteLocks(pi, {
233
+ client,
234
+ env,
235
+ hard: process.env[WRITE_LOCK_ENV] === '1',
236
+ });
237
+ }
234
238
 
235
239
  /* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
236
240
  const todoUi: { renderWidget: (ctx: unknown) => void } = {
@@ -369,15 +373,16 @@ export default async function (pi: ExtensionAPI) {
369
373
  if (plan.notice && !isSubagent) {
370
374
  const running = subagentPort.current?.listRunningSubs() ?? [];
371
375
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
372
- const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(list_agents 查看状态);若不等待,请说明放弃原因。`;
376
+ const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
373
377
  void sendUserMessageIn(notice);
374
378
  }
375
379
  });
376
380
 
377
- // v1.3 M8: report blocked state ourselves (D29's exemption relies on blocked visibility).
378
- // Observed: pi 0.84.2 core emits no "herdr:blocked" (the official integration listener is dead code),
379
- // so this extension manages enterBlocked/exitBlocked while ask_user_question waits for human input.
380
- let blockedDepth = 0;
381
+ // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
382
+ // only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
383
+ // pane.report_agent is ignored while that authority is live
384
+ // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
385
+ // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
381
386
  function enterBlocked(label: string | null): void {
382
387
  blockedDepth += 1;
383
388
  reportAgent('blocked', label);
@@ -392,14 +397,34 @@ export default async function (pi: ExtensionAPI) {
392
397
  void client.reportAskFlag(null).catch(() => {});
393
398
  }
394
399
  }
395
- (pi as { events?: { on?: (ev: string, cb: (data: unknown) => void) => void } }).events?.on?.(
396
- 'herdr:blocked',
397
- (data) => {
398
- const d = data as { active?: boolean; label?: string } | undefined;
399
- if (d?.active) enterBlocked(typeof d.label === 'string' ? d.label : null);
400
+
401
+ // emit() is synchronous; this flag stops our own listener from double-counting depth.
402
+ let publishingHerdrBlocked = false;
403
+ function publishHerdrBlocked(active: boolean, label: string | null): void {
404
+ publishingHerdrBlocked = true;
405
+ try {
406
+ if (active) enterBlocked(label);
400
407
  else exitBlocked();
401
- },
402
- );
408
+ pi.events.emit(
409
+ 'herdr:blocked',
410
+ active ? { active: true, ...(label ? { label } : {}) } : { active: false },
411
+ );
412
+ } finally {
413
+ publishingHerdrBlocked = false;
414
+ }
415
+ }
416
+ pi.events.on('herdr:blocked', (data) => {
417
+ if (publishingHerdrBlocked) return;
418
+ if (!data || typeof data !== 'object') {
419
+ exitBlocked();
420
+ return;
421
+ }
422
+ if (!('active' in data) || data.active !== true) {
423
+ exitBlocked();
424
+ return;
425
+ }
426
+ enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
427
+ });
403
428
 
404
429
  /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
405
430
 
@@ -423,9 +448,10 @@ export default async function (pi: ExtensionAPI) {
423
448
  const ui = (ctx as {
424
449
  ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
425
450
  }).ui;
426
- enterBlocked(question);
427
- // v1.3 M8 observed: herdr keeps detecting working while waiting for a human, overriding a one-shot blocked report;
428
- // repeat it every 5s so the pane stays blocked and remains visible/GC-exempt.
451
+ publishHerdrBlocked(true, question);
452
+ // Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
453
+ // to later working reports; refresh while the gate is open. Do not re-emit
454
+ // herdr:blocked here — official blockedCount is edge-triggered.
429
455
  const hb = setInterval(() => { reportAgent('blocked', question); }, 5000);
430
456
  try {
431
457
  const answer = await ui?.input?.(question, 'your answer');
@@ -435,7 +461,7 @@ export default async function (pi: ExtensionAPI) {
435
461
  };
436
462
  } finally {
437
463
  clearInterval(hb);
438
- exitBlocked();
464
+ publishHerdrBlocked(false, null);
439
465
  }
440
466
  },
441
467
  });
@@ -533,8 +559,10 @@ export default async function (pi: ExtensionAPI) {
533
559
  }
534
560
  });
535
561
 
536
- /* subagent tools are master-only (depth 1). C3: worker never loads bootstrap. */
537
- if (!isSubagent) {
562
+ /* Workbench plugins (subagent/terminal + cordis) are herdr-master only.
563
+ * Bare pi and worker panes mount todo only so unused tools never appear.
564
+ * Dynamic import: worker/bare-pi must never load bootstrap/subagent-scope (C3). */
565
+ if (mode.composeMaster) {
538
566
  const { mountMasterPlugins } = await import('./index-master.ts');
539
567
  await mountMasterPlugins({
540
568
  pi,
@@ -555,7 +583,20 @@ export default async function (pi: ExtensionAPI) {
555
583
  claimSettleNotice,
556
584
  });
557
585
  } else {
558
- const { mountWorkerTodo } = await import('./index-worker.ts');
559
- await mountWorkerTodo({ pi, todos, todoUi, mirrorTodos });
586
+ const { mountTodoOnly } = await import('./index-worker.ts');
587
+ await mountTodoOnly({
588
+ pi,
589
+ todos,
590
+ todoUi,
591
+ mirrorTodos,
592
+ ...(isSubagent
593
+ ? {}
594
+ : {
595
+ stopReminder: {
596
+ getBlockedDepth: () => blockedDepth,
597
+ getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
598
+ },
599
+ }),
600
+ });
560
601
  }
561
602
  }
@@ -12,12 +12,12 @@ export const NOTICE_MAX_SHOWN = 3;
12
12
  * Collapse a batch into one message body.
13
13
  * - empty → null (do not inject)
14
14
  * - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
15
- * - >max → first max originals + a tail pointing at history / list_agents
15
+ * - >max → first max originals + a tail pointing at history / subagent list
16
16
  */
17
17
  export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
18
18
  if (contents.length === 0) return null;
19
19
  if (contents.length <= max) return contents.join('\n\n');
20
20
  const hidden = contents.length - max;
21
- const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 resume_subagent 工具描述);在跑代理用 list_agents 查看。`;
21
+ const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
22
22
  return [...contents.slice(0, max), tail].join('\n\n');
23
23
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "role": "master",
3
- "version": "1.3.1",
4
- "description": "主控角色(WS-D7 自应用;D83 镜像源):自研核心全量(todos/协调/subagent 族/终端族)+ pi 内建检视执行 + web_search;D82 unknownTools=allow——用户安装的任意扩展默认可见可用(install 即授予)",
3
+ "version": "1.4.0",
4
+ "description": "主控角色(WS-D7 自应用;D83 镜像源):核心工具(todos/协调/subagent/terminal)+ pi 内建 + web_search;D82 unknownTools=allow。v1.4.0: 工具合并 terminal 6→1, subagent 5→1",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
8
8
  "todo_write", "ask_user_question",
9
- "subagent", "resume_subagent", "list_agents", "send_message", "interrupt_agent",
10
- "terminal_open", "terminal_send", "terminal_read", "terminal_signal", "terminal_close", "terminal_list",
9
+ "subagent",
10
+ "terminal",
11
11
  "read", "grep", "glob", "bash", "pwsh", "edit", "write",
12
12
  "web_search"
13
13
  ],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "role": "worker-default",
3
- "version": "1.3.1",
4
- "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial",
3
+ "version": "1.4.0",
4
+ "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.4.0: 工具合并后更新 deny 列表",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
@@ -11,16 +11,7 @@
11
11
  ],
12
12
  "rules": {
13
13
  "subagent": "deny",
14
- "resume_subagent": "deny",
15
- "list_agents": "deny",
16
- "send_message": "deny",
17
- "interrupt_agent": "deny",
18
- "terminal_open": "deny",
19
- "terminal_send": "deny",
20
- "terminal_read": "deny",
21
- "terminal_signal": "deny",
22
- "terminal_close": "deny",
23
- "terminal_list": "deny",
14
+ "terminal": "deny",
24
15
  "*": "allow"
25
16
  }
26
17
  },