pi-pier 0.1.0

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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * 档1 core/terminal(M14 六工具迁 loader entry —— D78 挂载树 / D81 三分法)。
3
+ *
4
+ * 形态:cordis 插件(loader entry,热换面 = 本文件)。依赖全部经服务注入:
5
+ * - `pi-herdr.surface`:pi 注册面代理(D79 tombstone —— hmr reload 翻墓碑,
6
+ * 新版本同 key 重挂载、同重注册覆盖 pi 工具表 Map);
7
+ * - `pi-herdr.terminal-deps`:client/env(外层 createHerdrClient 产物)+
8
+ * state 槽(index 的 GC 豁免查询 activePaneIds)。
9
+ *
10
+ * 本文件不 import index.ts(无环);terminal-core 为纯逻辑层照旧。
11
+ */
12
+ import { Context } from '@deepseek-ai/cordis';
13
+ import { Type } from 'typebox';
14
+ import type { PiSurface } from '../pi-surface.ts';
15
+ import type { HerdrClientLike } from '../herdr-client.ts';
16
+ import {
17
+ READINESS_TIMEOUT_MS,
18
+ READ_MAX_CHARS,
19
+ TERMINALS_CUSTOM_TYPE,
20
+ activeTerminalPaneIds,
21
+ classifyReadiness,
22
+ closeTerminal,
23
+ computeIncrement,
24
+ detectFullscreenTUI,
25
+ foldTerminalsRegistry,
26
+ makeTerminalsRegistry,
27
+ registerTerminal,
28
+ stripAnsi,
29
+ summarizeSessions,
30
+ validateSendText,
31
+ validateSignal,
32
+ type ReadCursor,
33
+ type TerminalEntry,
34
+ } from '../terminal-core.ts';
35
+
36
+ export interface TerminalStateSlot {
37
+ /** index 的 GC 豁免查询(活跃终端 pane 不回收)。 */
38
+ activePaneIds: () => Set<string>;
39
+ }
40
+
41
+ export interface TerminalDeps {
42
+ client: HerdrClientLike;
43
+ env: { paneId: string; tabId: string } | null;
44
+ state: TerminalStateSlot;
45
+ }
46
+
47
+ export default function terminalPlugin(ctx: Context): void {
48
+ const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
49
+ const { client, env, state } = ctx.get('pi-herdr.terminal-deps') as TerminalDeps;
50
+ const pi = surface.raw as {
51
+ appendEntry?: (customType: string, data: unknown) => void;
52
+ };
53
+ // 模块 key = 本文件(hmr/reload 的 filename 与 ledger.disposeKey 规范化互比)
54
+ const scoped = surface.forModule(import.meta.url);
55
+
56
+ /* ── M14:常驻交互终端六工具(D71 / T1–T6;master 侧 v1,worker 不挂本 entry) ──
57
+ * 后端 = 独立 herdr pane(不复用 pi TUI pane):open=pane.split 常驻 shell /
58
+ * send=pane.send_text+\r / read=pane.read(recent)+本地增量 / signal=pane.send_keys /
59
+ * close=pane.close。有活跃终端的 pane 豁免 GC(index gcPass 查 state.activePaneIds)。 */
60
+
61
+ let terminals: TerminalEntry[] = [];
62
+
63
+ function persistTerminals(): void {
64
+ try {
65
+ pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
66
+ } catch {
67
+ /* 持久化尽力而为 */
68
+ }
69
+ }
70
+
71
+ function rebuildTerminals(eventCtx: unknown): void {
72
+ try {
73
+ const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
74
+ ?.sessionManager?.getBranch?.() ?? [];
75
+ terminals = foldTerminalsRegistry(entries as Parameters<typeof foldTerminalsRegistry>[0]);
76
+ } catch {
77
+ /* 重建失败不影响主流程 */
78
+ }
79
+ }
80
+
81
+ function findOpenTerminal(id: unknown): TerminalEntry | null {
82
+ if (typeof id !== 'string') return null;
83
+ return terminals.find((t) => t.terminalId === id && t.status === 'open') ?? null;
84
+ }
85
+
86
+ function touchTerminal(entry: TerminalEntry): void {
87
+ entry.lastActivityAt = Date.now();
88
+ persistTerminals();
89
+ }
90
+
91
+ // GC 豁免槽回填(index gcPass 消费)
92
+ state.activePaneIds = () => activeTerminalPaneIds(terminals);
93
+
94
+ scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
95
+ rebuildTerminals(eventCtx);
96
+ });
97
+ scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
98
+ rebuildTerminals(eventCtx);
99
+ });
100
+
101
+ const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
102
+ const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
103
+
104
+ scoped.registerTool({
105
+ name: 'terminal_open',
106
+ label: 'Terminal Open',
107
+ description: [
108
+ 'Open a persistent interactive terminal (a resident shell in its own herdr pane, next to this session) and return its terminal_id.',
109
+ 'The shell keeps its cwd, environment variables, and background processes across terminal_send/terminal_read calls — use it for dev servers, REPLs, or multi-step shell work instead of one-shot bash calls.',
110
+ '`cwd` (optional): working directory for the shell (defaults to this session cwd).',
111
+ 'Readiness: waits for the shell prompt (up to 30s); on timeout the terminal still opens — check with terminal_read before sending.',
112
+ 'A human can also type into the pane directly; terminal_close closes it.',
113
+ ].join(' '),
114
+ parameters: Type.Object({
115
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the shell' })),
116
+ }),
117
+ async execute(_tc, params, _sig, _upd, ctx) {
118
+ if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
119
+ const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
120
+ : (ctx as { cwd?: string }).cwd ?? process.cwd();
121
+ const r = registerTerminal(terminals, {
122
+ paneId: env.paneId, // 占位:split 后回填真实 paneId
123
+ tabId: env.tabId,
124
+ cwd,
125
+ createdAt: Date.now(),
126
+ });
127
+ if (!r.ok) return errText(r.error);
128
+ let paneId: string;
129
+ try {
130
+ paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
131
+ } catch (e) {
132
+ return errText(`failed to split terminal pane: ${(e as Error).message}`);
133
+ }
134
+ r.entry.paneId = paneId;
135
+ terminals = r.entries;
136
+ persistTerminals();
137
+ // T3 readiness:PS1 尾匹配(server 端等待);超时 → 本地兜底再判一次
138
+ let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
139
+ try {
140
+ const matched = await client.waitForOutput(paneId, { type: 'regex', value: '[$>#❯]\\s*$' }, READINESS_TIMEOUT_MS);
141
+ if (matched) readiness = 'prompt';
142
+ else {
143
+ const read = await client.readPane(paneId, { stripAnsi: false });
144
+ readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0 });
145
+ }
146
+ } catch {
147
+ /* readiness 尽力而为 */
148
+ }
149
+ const text = [
150
+ `terminal ${r.entry.terminalId} open (pane ${paneId})`,
151
+ `cwd: ${cwd}`,
152
+ `readiness: ${readiness}${readiness !== 'prompt' ? ' (prompt not detected yet — check with terminal_read before sending)' : ''}`,
153
+ ].join('\n');
154
+ return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
155
+ },
156
+ });
157
+
158
+ scoped.registerTool({
159
+ name: 'terminal_send',
160
+ label: 'Terminal Send',
161
+ description: [
162
+ 'Send a text command (plain text only, no ANSI escapes) to a persistent terminal opened with terminal_open; Enter is appended automatically.',
163
+ 'Returns after the keystrokes are delivered — poll terminal_read for output (long-running commands keep producing output across reads).',
164
+ 'For control keys use terminal_signal instead. Interactive/fullscreen programs (vim, less, top) are outside this seam.',
165
+ ].join(' '),
166
+ parameters: Type.Object({
167
+ terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
168
+ text: Type.String({ description: 'Command text to type and run' }),
169
+ }),
170
+ async execute(_tc, params) {
171
+ const entry = findOpenTerminal(params?.terminal_id);
172
+ if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see terminal_list)`);
173
+ const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
174
+ if (!v.ok) return errText(v.error);
175
+ try {
176
+ await client.sendPaneText(entry.paneId, v.text);
177
+ } catch (e) {
178
+ return errText(`send failed (pane may be closed): ${(e as Error).message}`);
179
+ }
180
+ touchTerminal(entry);
181
+ return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
182
+ },
183
+ });
184
+
185
+ scoped.registerTool({
186
+ name: 'terminal_read',
187
+ label: 'Terminal Read',
188
+ description: [
189
+ 'Read new output from a persistent terminal since your last read (cursor-based increment; the first read returns the recent buffer).',
190
+ '`terminal_id`: your terminal from terminal_open. Alternatively `pane_id`: a pane in this session\'s own tab (narrow scope by design).',
191
+ 'Output is ANSI-stripped and bounded; if the buffer scrolled past your last read you get a reset marker plus the current tail.',
192
+ 'Detecting a fullscreen TUI program (vim/less/top) returns an error with suggestions instead of garbage.',
193
+ ].join(' '),
194
+ parameters: Type.Object({
195
+ terminal_id: Type.Optional(Type.String({ description: 'Terminal id from terminal_open' })),
196
+ pane_id: Type.Optional(Type.String({ description: 'Direct pane read (own tab panes only)' })),
197
+ max_chars: Type.Optional(Type.Number({ description: 'Output cap for this read' })),
198
+ }),
199
+ async execute(_tc, params) {
200
+ let paneId: string | null = null;
201
+ let entry: TerminalEntry | null = null;
202
+ if (typeof params?.terminal_id === 'string') {
203
+ entry = findOpenTerminal(params.terminal_id);
204
+ if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see terminal_list)`);
205
+ paneId = entry.paneId;
206
+ } else if (typeof params?.pane_id === 'string') {
207
+ // T5 先窄:仅本任务 tab 的 pane 或自建终端 pane
208
+ try {
209
+ const panes = await client.listPanes();
210
+ const target = panes.find((p) => p.paneId === params.pane_id);
211
+ const ownTab = target?.tabId && target.tabId === env?.tabId;
212
+ const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
213
+ if (!target || (!ownTab && !ownTerminal)) {
214
+ return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
215
+ }
216
+ paneId = params.pane_id;
217
+ } catch (e) {
218
+ return errText(`pane lookup failed: ${(e as Error).message}`);
219
+ }
220
+ } else {
221
+ return errText('provide terminal_id (or pane_id for a direct own-tab read)');
222
+ }
223
+ const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
224
+ ? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
225
+ let read: { text: string; revision: number; truncated: boolean };
226
+ try {
227
+ read = await client.readPane(paneId, { stripAnsi: false });
228
+ } catch (e) {
229
+ return errText(`read failed (pane may be closed): ${(e as Error).message}`);
230
+ }
231
+ // T6 检测点1:全屏 TUI(alternate screen)——在 raw 上识别
232
+ const tui = detectFullscreenTUI(read.text);
233
+ if (tui.detected) {
234
+ const head = stripAnsi(read.text).slice(0, 200);
235
+ const text = [
236
+ 'FULLSCREEN_TUI_DETECTED: this terminal is running a fullscreen program (e.g. vim/less/top); its screen output is not readable here.',
237
+ 'Suggestions:',
238
+ '1. Run such programs in a native herdr pane where the human can interact directly.',
239
+ '2. Use non-interactive alternatives (cat/grep instead of less; edit/write tools instead of vim).',
240
+ `Raw head (first 200 chars): ${head}`,
241
+ ].join('\n');
242
+ return { content: [{ type: 'text', text }], details: { error: 'FULLSCREEN_TUI_DETECTED' } };
243
+ }
244
+ const prev: ReadCursor | null = entry
245
+ ? (entry.readRevision != null
246
+ ? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
247
+ : null)
248
+ : null;
249
+ const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
250
+ if (entry) {
251
+ entry.readRevision = inc.cursor.revision;
252
+ entry.readLen = inc.cursor.len;
253
+ entry.readTail = inc.cursor.tail;
254
+ entry.readEoTail = inc.cursor.eoTail;
255
+ touchTerminal(entry);
256
+ }
257
+ if (inc.mode === 'none') {
258
+ return { content: [{ type: 'text', text: 'no new output' }], details: { mode: 'none', revision: read.revision } };
259
+ }
260
+ const output = stripAnsi(inc.text);
261
+ const text = output.length ? output : '(empty)';
262
+ return {
263
+ content: [{ type: 'text', text }],
264
+ details: { mode: inc.mode, revision: read.revision, hard_capped: inc.hardCapped, source_revision_truncated: read.truncated },
265
+ };
266
+ },
267
+ });
268
+
269
+ scoped.registerTool({
270
+ name: 'terminal_signal',
271
+ label: 'Terminal Signal',
272
+ description: [
273
+ 'Send a control key to a persistent terminal: ctrl+c (interrupt), ctrl+d (EOF), ctrl+z (suspend), esc, enter.',
274
+ 'Use ctrl+c to stop a running command; anything beyond this whitelist (complex key sequences) is outside the terminal seam.',
275
+ ].join(' '),
276
+ parameters: Type.Object({
277
+ terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
278
+ key: Type.String({ description: 'One of: ctrl+c, ctrl+d, ctrl+z, esc, enter' }),
279
+ }),
280
+ async execute(_tc, params) {
281
+ const entry = findOpenTerminal(params?.terminal_id);
282
+ if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see terminal_list)`);
283
+ const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
284
+ if (!v.ok) return errText(v.error);
285
+ try {
286
+ await client.sendPaneKeys(entry.paneId, [v.key]);
287
+ } catch (e) {
288
+ return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
289
+ }
290
+ touchTerminal(entry);
291
+ return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
292
+ },
293
+ });
294
+
295
+ scoped.registerTool({
296
+ name: 'terminal_close',
297
+ label: 'Terminal Close',
298
+ description: [
299
+ 'Close a persistent terminal (kills its shell process tree and removes the pane).',
300
+ 'Terminal state does not survive close — persist anything you need to files first.',
301
+ ].join(' '),
302
+ parameters: Type.Object({
303
+ terminal_id: Type.String({ description: 'Terminal id from terminal_open' }),
304
+ }),
305
+ async execute(_tc, params) {
306
+ const id = params?.terminal_id;
307
+ const entry = terminals.find((t) => t.terminalId === id);
308
+ if (!entry) return errText(`unknown terminal "${String(id)}" (see terminal_list)`);
309
+ if (entry.status === 'open') {
310
+ try {
311
+ await client.closePane(entry.paneId);
312
+ } catch {
313
+ /* pane 已消失(人关/随 tab 关)→ 直接补记 */
314
+ }
315
+ }
316
+ terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
317
+ persistTerminals();
318
+ return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
319
+ },
320
+ });
321
+
322
+ scoped.registerTool({
323
+ name: 'terminal_list',
324
+ label: 'Terminal List',
325
+ description: [
326
+ 'List this session\'s persistent terminals with open/closed and pane-liveness status.',
327
+ 'Entries whose pane is gone (human closed it, restart) are marked closed — terminal sessions do not survive pane closure or restarts; persist state to files instead of relying on terminal reuse.',
328
+ ].join(' '),
329
+ parameters: Type.Object({}),
330
+ async execute() {
331
+ let livePaneIds: string[] = [];
332
+ try {
333
+ livePaneIds = (await client.listPanes()).map((p) => p.paneId);
334
+ } catch {
335
+ /* 查询失败:全部按未知(不误标 closed)——live 集为空只影响 stale 判定 */
336
+ }
337
+ const s = summarizeSessions(terminals, livePaneIds);
338
+ // T6 检测点3:pane 消失 → 补记 closed(跨重启边界:不复活,提示持久化)
339
+ if (s.stalePaneIds.length > 0) {
340
+ terminals = terminals.map((t) =>
341
+ s.stalePaneIds.includes(t.paneId)
342
+ ? { ...t, status: 'closed' as const, closedAt: t.closedAt ?? Date.now() }
343
+ : t,
344
+ );
345
+ persistTerminals();
346
+ }
347
+ if (s.terminals.length === 0) {
348
+ return { content: [{ type: 'text', text: 'no terminals (open one with terminal_open)' }], details: { terminals: [] } };
349
+ }
350
+ const lines = s.terminals.map((t) =>
351
+ `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
352
+ );
353
+ const note = s.stalePaneIds.length > 0
354
+ ? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
355
+ : '';
356
+ return { content: [{ type: 'text', text: lines.join('\n') + note }], details: { terminals: s.terminals } };
357
+ },
358
+ });
359
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * 档1 core/todo(todo 族迁 loader entry —— D78 挂载树 / D81 融合核心)。
3
+ *
4
+ * 形态:cordis 插件(master 经 loader.create 挂 = 热换面;worker 裸根手动 mount,
5
+ * D81 one-shot 裁剪)。依赖全部经服务注入:
6
+ * - `pi-herdr.surface`:pi 注册面代理(D79 tombstone);
7
+ * - `pi-herdr.todo-deps`:todos 服务(session 状态层,留在 index)+ options +
8
+ * mirrorTodos 回调 + appendEntry + state 槽(renderWidget 回填给 index 生命周期)。
9
+ *
10
+ * 本文件承接:todo_write 工具 + /todos /todo 命令 + TUI widget(widgetLines/
11
+ * renderWidget/formatTodosMarkdown)+ 读钩(before_agent_start planTodoReadHook,
12
+ * 轮次计数器内聚)。reconcile / 镜像 / reportAgent 仍在 index(session 状态层)。
13
+ */
14
+ import { Context } from '@deepseek-ai/cordis';
15
+ import { Type } from 'typebox';
16
+ import { join } from 'node:path';
17
+ import { writeFileSync } from 'node:fs';
18
+ import type { PiSurface } from '../pi-surface.ts';
19
+ import type { TodosService } from '../todos-service.ts';
20
+ import { planTodoReadHook } from '../todo-read-hook.ts';
21
+ import { makeProgressUpdate } from '../subagent-core.ts';
22
+ import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
23
+ import {
24
+ TODO_EDIT_CUSTOM_TYPE,
25
+ boundedView,
26
+ completionTransitions,
27
+ fuzzyFind,
28
+ listsEqual,
29
+ makeSnapshot,
30
+ normalizeStrict,
31
+ revertedCompleted,
32
+ validateTodos,
33
+ } from '../todo-core.ts';
34
+
35
+ export interface TodoUiSlot {
36
+ /** index 的 session_start/session_tree 生命周期调用(plugin 回填)。 */
37
+ renderWidget: (ctx: unknown) => void;
38
+ }
39
+
40
+ export interface TodoDeps {
41
+ todos: TodosService;
42
+ allowParallelInProgress: boolean;
43
+ /** 软上限提示(不硬拒)。 */
44
+ maxItems: number;
45
+ mirrorTodos: () => void;
46
+ appendEntry: (customType: string, data: unknown) => void;
47
+ state: TodoUiSlot;
48
+ }
49
+
50
+ const TOOL_DESCRIPTION = [
51
+ 'Record and update the complete task list for the current work. Every call REPLACES the entire list (last write wins), so always resend the full list.',
52
+ 'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
53
+ 'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
54
+ 'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
55
+ 'Mark tasks done immediately after finishing. Never mark a task completed while its tests fail or its work is incomplete. If you cannot remember the exact content of an entry, re-derive it from the previous todo_write result instead of guessing.',
56
+ 'Do not make todo_write the only tool call of a turn — batch it together with real work.',
57
+ '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.',
58
+ ].join(' ');
59
+
60
+ const TODO_MARKS: Record<TodoStatus, string> = {
61
+ pending: '○',
62
+ in_progress: '▶',
63
+ completed: '✓',
64
+ blocked: '■',
65
+ abandoned: '✗',
66
+ };
67
+
68
+ function widgetLines(items: TodoItem[]): string[] {
69
+ if (items.length === 0) return [];
70
+ const counts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
71
+ for (const it of items) {
72
+ if (it.status === 'pending') counts.pending++;
73
+ else if (it.status === 'in_progress') counts.inProgress++;
74
+ else if (it.status === 'completed') counts.completed++;
75
+ else if (it.status === 'blocked') counts.blocked++;
76
+ }
77
+ const lines = [`todo: ${counts.inProgress}▶ ${counts.pending}○ ${counts.blocked}■ ${counts.completed}✓`];
78
+ // D43 phase 分组 + D39 有界(每组预算 10 行)
79
+ const groups = new Map<string, TodoItem[]>();
80
+ for (const it of items) {
81
+ const key = it.phase ?? '';
82
+ const arr = groups.get(key) ?? [];
83
+ arr.push(it);
84
+ groups.set(key, arr);
85
+ }
86
+ for (const [phase, list] of groups) {
87
+ if (phase) lines.push(` [${phase}]`);
88
+ const view = boundedView(list, 10);
89
+ for (const it of view.visible) {
90
+ const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
91
+ lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
92
+ }
93
+ if (view.hiddenCompleted > 0 || view.hiddenOpen > 0) {
94
+ lines.push(` +${view.hiddenCompleted + view.hiddenOpen} more (${view.hiddenCompleted} completed, ${view.hiddenOpen} open)`);
95
+ }
96
+ }
97
+ return lines;
98
+ }
99
+
100
+ /** D38:TODO.md 导出(分组 + 任务列表语法)。 */
101
+ function formatTodosMarkdown(items: TodoItem[]): string {
102
+ const groups = new Map<string, TodoItem[]>();
103
+ for (const it of items) {
104
+ const key = it.phase ?? '默认';
105
+ const arr = groups.get(key) ?? [];
106
+ arr.push(it);
107
+ groups.set(key, arr);
108
+ }
109
+ const lines = ['# TODO'];
110
+ for (const [phase, list] of groups) {
111
+ lines.push('', `## ${phase}`);
112
+ for (const it of list) {
113
+ const box = it.status === 'completed' ? 'x'
114
+ : it.status === 'in_progress' ? '/'
115
+ : it.status === 'blocked' ? '!'
116
+ : it.status === 'abandoned' ? '~'
117
+ : ' ';
118
+ const suffix = it.status === 'blocked' && it.blocker ? ` — blocker: ${it.blocker}` : '';
119
+ lines.push(`- [${box}] ${it.content}${suffix}`);
120
+ }
121
+ }
122
+ lines.push('');
123
+ return lines.join('\n');
124
+ }
125
+
126
+ export default function todoPlugin(ctx: Context): void {
127
+ const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
128
+ const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
129
+ ctx.get('pi-herdr.todo-deps') as TodoDeps;
130
+ const scoped = surface.forModule(import.meta.url);
131
+
132
+ function renderWidget(eventCtx: unknown): void {
133
+ try {
134
+ (eventCtx as { ui?: { setWidget?: (id: string, lines: string[]) => void } })?.ui?.setWidget?.(
135
+ 'todos',
136
+ widgetLines(todos.items),
137
+ );
138
+ } catch {
139
+ /* 无 widget 能力的 pi 版本静默降级 */
140
+ }
141
+ }
142
+
143
+ // 槽回填:index 的 session_start/session_tree 调用
144
+ state.renderWidget = renderWidget;
145
+
146
+ /* ── 读钩(轮次计数器内聚;D39 提示注入) ── */
147
+ let todoReadTurn = 0;
148
+ let lastEmptyGuardTurn: number | null = null;
149
+ scoped.on('before_agent_start', async () => {
150
+ const plan = planTodoReadHook({
151
+ items: todos.items,
152
+ turn: todoReadTurn,
153
+ lastEmptyGuardTurn,
154
+ });
155
+ todoReadTurn += 1;
156
+ if (!plan.inject) return;
157
+ if (todos.items.length === 0) lastEmptyGuardTurn = todoReadTurn - 1;
158
+ return {
159
+ message: {
160
+ customType: plan.message.customType,
161
+ content: plan.message.content,
162
+ display: false,
163
+ },
164
+ };
165
+ });
166
+
167
+ /* ── 工具:todo_write ── */
168
+ scoped.registerTool({
169
+ name: TODO_TOOL_NAME,
170
+ label: 'Todo',
171
+ description: TOOL_DESCRIPTION,
172
+ parameters: Type.Object({
173
+ todos: Type.Array(
174
+ Type.Object(
175
+ {
176
+ content: Type.String({ description: 'What the task is — a short imperative line' }),
177
+ status: Type.Union([
178
+ Type.Literal('pending'),
179
+ Type.Literal('in_progress'),
180
+ Type.Literal('completed'),
181
+ Type.Literal('blocked'),
182
+ Type.Literal('abandoned'),
183
+ ], { description: 'pending | in_progress | completed | blocked | abandoned' }),
184
+ blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for' })),
185
+ phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list' })),
186
+ },
187
+ { additionalProperties: false },
188
+ ),
189
+ { description: 'The COMPLETE task list; this call replaces the previous list' },
190
+ ),
191
+ }),
192
+ async execute(toolCallId, params, signal, onUpdate, eventCtx) {
193
+ void toolCallId;
194
+ void signal;
195
+ const result = validateTodos(params?.todos, allowParallelInProgress);
196
+ if (!result.ok) {
197
+ return {
198
+ content: [{ type: 'text', text: `Error: ${result.error}` }],
199
+ details: {},
200
+ };
201
+ }
202
+ const strictMode = todos.config.strict; // D75 阶段 2:策略进 service config
203
+ let next = result.items!;
204
+ const strictNotes: string[] = [];
205
+ if (strictMode) {
206
+ const strict = normalizeStrict(next);
207
+ const demoted = next
208
+ .filter((it, i) => it.status === 'in_progress' && strict[i]?.status === 'pending')
209
+ .map((it) => it.content);
210
+ const promoted = strict
211
+ .find((it, i) => it.status === 'in_progress' && next[i]?.status !== 'in_progress')?.content;
212
+ if (demoted.length > 0) strictNotes.push(`demoted to pending: ${demoted.join(', ')}`);
213
+ if (promoted) strictNotes.push(`auto-promoted to in_progress: ${promoted}`);
214
+ next = strict;
215
+ }
216
+ // D35:no-op(与当前列表全等 → 不落盘不镜像)
217
+ if (listsEqual(todos.items, next)) {
218
+ return { content: [{ type: 'text', text: 'No change: todo list already matches.' }], details: {} };
219
+ }
220
+ // D36/D37:过渡信号与回退警告
221
+ const completed = completionTransitions(todos.items, next);
222
+ const reverted = revertedCompleted(todos.items, next);
223
+ todos.replace(next);
224
+ if (todos.items.length > maxItems) {
225
+ onUpdate?.(makeProgressUpdate(`Note: list has ${todos.items.length} entries; consider keeping it under ${maxItems}.`));
226
+ }
227
+ mirrorTodos();
228
+ renderWidget(eventCtx);
229
+ const lines = [formatTodoConfirmation(todos.items)];
230
+ if (completed.length > 0) lines.push(`Completed: ${completed.join(' | ')}`);
231
+ if (reverted.length > 0) {
232
+ lines.push(`Warning: previously completed item(s) reverted to open: ${reverted.join(' | ')} — is that intentional?`);
233
+ }
234
+ for (const n of strictNotes) lines.push(`Normalized (strict mode): ${n}`);
235
+ return {
236
+ content: [{ type: 'text', text: lines.join('\n') }],
237
+ details: {
238
+ [TODO_DETAILS_KEY]: makeSnapshot(todos.items),
239
+ ...(completed.length > 0 ? { completedTasks: completed } : {}),
240
+ },
241
+ };
242
+ },
243
+ });
244
+
245
+ /* ── 命令:/todos(查看 + done/drop/rm 人类编辑,D38)+ /todo export ── */
246
+ scoped.registerCommand('todos', {
247
+ description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
248
+ handler: async (args: unknown, eventCtx: unknown) => {
249
+ const ui = (eventCtx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
250
+ const ops = ['done', 'drop', 'rm', 'unblock'] as const;
251
+ const raw = typeof args === 'string'
252
+ ? args.split(/\s+/).filter(Boolean)
253
+ : Array.isArray(args) ? args.map(String) : [];
254
+ const opArg = raw[0];
255
+ if (opArg && (ops as readonly string[]).includes(opArg)) {
256
+ const query = raw.slice(1).join(' ').trim();
257
+ if (!query) {
258
+ ui?.notify?.('usage: /todos done|drop|rm|unblock <content>', 'warning');
259
+ return;
260
+ }
261
+ const candidates = fuzzyFind(todos.items, query);
262
+ if (candidates.length === 0) {
263
+ ui?.notify?.(`no todo matches "${query}"`, 'warning');
264
+ return;
265
+ }
266
+ if (candidates.length > 1) {
267
+ ui?.notify?.(`ambiguous — matches:\n${candidates.map((c) => ` - ${c}`).join('\n')}`, 'warning');
268
+ return;
269
+ }
270
+ const content = candidates[0];
271
+ const edit = { op: opArg as 'done' | 'drop' | 'rm' | 'unblock', content };
272
+ const before = todos.items;
273
+ todos.applyEdits([edit]);
274
+ if (listsEqual(before, todos.items)) {
275
+ ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
276
+ return;
277
+ }
278
+ // D38:人类编辑写回权威(custom 条目,重启后分支回放生效)
279
+ try {
280
+ appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits: [edit], ts: Date.now() });
281
+ } catch {
282
+ /* 尽力而为 */
283
+ }
284
+ mirrorTodos();
285
+ renderWidget(eventCtx);
286
+ const verb = opArg === 'done' ? 'completed'
287
+ : opArg === 'drop' ? 'abandoned'
288
+ : opArg === 'unblock' ? 'unblocked (back to pending)'
289
+ : 'removed';
290
+ ui?.notify?.(`"${content}" ${verb}`, 'info');
291
+ return;
292
+ }
293
+ const lines = widgetLines(todos.items);
294
+ const text = lines.length ? lines.join('\n') : 'todo list is empty';
295
+ ui?.notify?.(text, 'info');
296
+ },
297
+ });
298
+
299
+ scoped.registerCommand('todo', {
300
+ description: 'Export the current todo list to TODO.md in the working directory',
301
+ handler: async (_args: unknown, eventCtx: unknown) => {
302
+ const ui = (eventCtx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
303
+ const cwd = (eventCtx as { cwd?: string }).cwd ?? process.cwd();
304
+ try {
305
+ const target = join(cwd, 'TODO.md');
306
+ writeFileSync(target, formatTodosMarkdown(todos.items));
307
+ ui?.notify?.(`TODO.md written: ${target} (${todos.items.length} items)`, 'info');
308
+ } catch (err) {
309
+ ui?.notify?.(`failed to write TODO.md: ${(err as Error).message}`, 'error');
310
+ }
311
+ },
312
+ });
313
+ }