pi-pier 0.1.3 → 0.1.4

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
@@ -7,19 +7,18 @@
7
7
  * - `pi-herdr.todo-deps`:todos 服务(session 状态层,留在 index)+ options +
8
8
  * mirrorTodos 回调 + appendEntry + state 槽(renderWidget 回填给 index 生命周期)。
9
9
  *
10
- * 本文件承接:todo_write 工具 + /todos /todo 命令 + TUI widget(widgetLines/
11
- * renderWidget/formatTodosMarkdown)+ 读钩(before_agent_start planTodoReadHook,
10
+ * 本文件承接:todo_write 工具 + /todos 命令(查看/编辑)+ TUI widget(widgetLines/
11
+ * renderWidget)+ 读钩(before_agent_start planTodoReadHook,
12
12
  * 轮次计数器内聚)。reconcile / 镜像 / reportAgent 仍在 index(session 状态层)。
13
13
  */
14
14
  import { Context } from '@deepseek-ai/cordis';
15
15
  import { Type } from 'typebox';
16
- import { join } from 'node:path';
17
- import { writeFileSync } from 'node:fs';
18
16
  import type { PiSurface } from '../pi-surface.ts';
19
17
  import type { TodosService } from '../todos-service.ts';
20
18
  import { planTodoReadHook } from '../todo-read-hook.ts';
21
19
  import { makeProgressUpdate } from '../subagent-core.ts';
22
20
  import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem, type TodoStatus } from '../vocab.ts';
21
+ import { formatAge, isArchived } from '../stale-core.ts';
23
22
  import {
24
23
  TODO_EDIT_CUSTOM_TYPE,
25
24
  completionTransitions,
@@ -89,29 +88,32 @@ function renderGroups(items: readonly TodoItem[]): string[] {
89
88
  }
90
89
 
91
90
  /**
92
- * widget 行(全局预算版,用户实证修复):
93
- * 旧实现按分组插入序逐组渲染(每组预算 10),pi 头部截 10 行 → 老 phase 吃光窗口、
94
- * 最新任务组(in_progress 所在)整组不可见。新规则:
91
+ * widget 行(活动锚定窗口,用户实证二修):
95
92
  * - 全局预算 = WIDGET_MAX_LINES(含摘要行与 +N 行);
96
- * - 超额丢弃顺序 = 最老 completed → 最老 open(尾部/当前工作永远存活);
93
+ * - 窗口锚定第一条 in_progress:可见的条目是它前后的连续区段(正在做的
94
+ * 上下文——前因后果可见),不再是固定头部/尾部(旧实现超窗时只剩尾部,
95
+ * 正在执行的条目滚出视窗,01a03c0d 实证"显示最后几条,不管当前执行到哪");
96
+ * - 无 in_progress → 锚定最后一条 open;全完成 → 尾部切片(刚完成的可见);
97
97
  * - +N 行指路 /todos(全量视图)。
98
98
  */
99
- export function widgetLines(items: readonly TodoItem[]): string[] {
99
+ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
100
100
  if (items.length === 0) return [];
101
101
  const c = countTodos(items);
102
- const summary = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
103
-
104
- const kept = [...items];
105
- while (
106
- kept.length > 0
107
- && 1 + renderGroups(kept).length + (items.length > kept.length ? 1 : 0) > WIDGET_MAX_LINES
108
- ) {
109
- const oldestCompleted = kept.findIndex((it) => it.status === 'completed');
110
- kept.splice(oldestCompleted === -1 ? 0 : oldestCompleted, 1);
102
+ // 反冻结:归档列表不逐条渲染,两行说明 + 指路 /todos(死计划不再占满视窗)。
103
+ if (opts?.archivedAgeMs != null) {
104
+ return [
105
+ `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓ · archived ${formatAge(opts.archivedAgeMs)}`,
106
+ ' archived — /todos 全量',
107
+ ];
111
108
  }
112
109
 
113
- const hidden = items.filter((it) => !kept.includes(it));
114
- const lines = [summary, ...renderGroups(kept)];
110
+ // 条目预算 = 总预算 - 摘要行 -(隐藏时的)+N 行。phase 头也占行——按渲染行数
111
+ // 迭代收缩:锚定窗口切片 → 超行则两端各收一格(保锚点),直到装得下。
112
+ const renderBudget = WIDGET_MAX_LINES - 1 - 1;
113
+ const [start, end] = anchorRange(items, renderBudget);
114
+ const kept = items.slice(start, end);
115
+ const hidden = items.filter((_, i) => i < start || i >= end);
116
+ const lines = [`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...renderGroups(kept)];
115
117
  if (hidden.length > 0) {
116
118
  const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
117
119
  lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
@@ -119,30 +121,43 @@ export function widgetLines(items: readonly TodoItem[]): string[] {
119
121
  return lines;
120
122
  }
121
123
 
122
- /** D38:TODO.md 导出(分组 + 任务列表语法)。 */
123
- function formatTodosMarkdown(items: TodoItem[]): string {
124
- const groups = new Map<string, TodoItem[]>();
125
- for (const it of items) {
126
- const key = it.phase ?? '默认';
127
- const arr = groups.get(key) ?? [];
128
- arr.push(it);
129
- groups.set(key, arr);
124
+ /**
125
+ * 活动锚定窗口:锚点 = 第一条 in_progress(无则最后一条 open;全完成 → 尾部)。
126
+ * 以渲染行数(含 phase 头)为预算,从锚点贪心扩张(尾部优先——后续工作先
127
+ * 可见),装不下即停;锚点条目永远在窗内。
128
+ */
129
+ function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
130
+ if (renderGroups(items).length <= renderBudget) return [0, items.length];
131
+ const anchor = items.findIndex((it) => it.status === 'in_progress');
132
+ const anchorIdx = anchor >= 0
133
+ ? anchor
134
+ : items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
135
+ if (anchorIdx < 0) {
136
+ // 全完成:尾部切片(按行数从头收缩)
137
+ let end = items.length;
138
+ while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
139
+ return [0, end];
130
140
  }
131
- const lines = ['# TODO'];
132
- for (const [phase, list] of groups) {
133
- lines.push('', `## ${phase}`);
134
- for (const it of list) {
135
- const box = it.status === 'completed' ? 'x'
136
- : it.status === 'in_progress' ? '/'
137
- : it.status === 'blocked' ? '!'
138
- : it.status === 'abandoned' ? '~'
139
- : ' ';
140
- const suffix = it.status === 'blocked' && it.blocker ? ` — blocker: ${it.blocker}` : '';
141
- lines.push(`- [${box}] ${it.content}${suffix}`);
141
+ let start = anchorIdx;
142
+ let end = anchorIdx + 1;
143
+ const fits = () => renderGroups(items.slice(start, end)).length <= renderBudget;
144
+ let growTail = true;
145
+ while ((end < items.length || start > 0) && (end - start) < items.length) {
146
+ if (growTail && end < items.length) {
147
+ end += 1;
148
+ if (!fits()) { end -= 1; break; }
149
+ } else if (start > 0) {
150
+ start -= 1;
151
+ if (!fits()) { start += 1; break; }
152
+ } else if (end < items.length) {
153
+ end += 1;
154
+ if (!fits()) { end -= 1; break; }
155
+ } else {
156
+ break;
142
157
  }
158
+ growTail = !growTail; // 严格交替:锚点前因与后果均衡可见(用户实证诉求)
143
159
  }
144
- lines.push('');
145
- return lines.join('\n');
160
+ return [start, end];
146
161
  }
147
162
 
148
163
  export default function todoPlugin(ctx: Context): void {
@@ -151,12 +166,25 @@ export default function todoPlugin(ctx: Context): void {
151
166
  ctx.get('pi-herdr.todo-deps') as TodoDeps;
152
167
  const scoped = surface.forModule(import.meta.url);
153
168
 
169
+ /** 归档年龄(非归档 → null);widget / /todos 共用。 */
170
+ function archivedAgeMs(now = Date.now()): number | null {
171
+ return todos.lastWriteAt != null && isArchived(todos.items, todos.lastWriteAt, now)
172
+ ? now - todos.lastWriteAt
173
+ : null;
174
+ }
175
+
176
+ /** 事件 ctx 里的 widget UI(有则 setWidget;守卫后具名使用,不做行内断言)。 */
177
+ function widgetUi(eventCtx: unknown): { setWidget?: (id: string, lines: string[]) => void } | undefined {
178
+ if (eventCtx === null || typeof eventCtx !== 'object' || !('ui' in eventCtx)) return undefined;
179
+ const ui = (eventCtx as { ui: unknown }).ui; // 'ui' in 已守卫
180
+ return ui !== null && typeof ui === 'object'
181
+ ? (ui as { setWidget?: (id: string, lines: string[]) => void })
182
+ : undefined;
183
+ }
184
+
154
185
  function renderWidget(eventCtx: unknown): void {
155
186
  try {
156
- (eventCtx as { ui?: { setWidget?: (id: string, lines: string[]) => void } })?.ui?.setWidget?.(
157
- 'todos',
158
- widgetLines(todos.items),
159
- );
187
+ widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, { archivedAgeMs: archivedAgeMs() }));
160
188
  } catch {
161
189
  /* 无 widget 能力的 pi 版本静默降级 */
162
190
  }
@@ -165,18 +193,55 @@ export default function todoPlugin(ctx: Context): void {
165
193
  // 槽回填:index 的 session_start/session_tree 调用
166
194
  state.renderWidget = renderWidget;
167
195
 
168
- /* ── 读钩(轮次计数器内聚;D39 提示注入) ── */
196
+ /* ── 读钩(轮次计数器内聚;D39 提示注入 + 反冻结 stale-core) ── */
169
197
  let todoReadTurn = 0;
170
198
  let lastEmptyGuardTurn: number | null = null;
171
- scoped.on('before_agent_start', async () => {
199
+ // 反冻结状态:距上次写入轮数 / stale 警告计数与节奏 / 归档翻转检测
200
+ let lastWriteTurn: number | null = null;
201
+ let staleNotices = 0;
202
+ let lastStaleGuardTurn: number | null = null;
203
+ let lastArchivedMirror = false;
204
+
205
+ // 任何真实变更(todo_write / 人类编辑 / 对账 / 分支重建)都终结当前停滞期
206
+ todos.on('todo.updated', () => {
207
+ lastWriteTurn = todoReadTurn;
208
+ staleNotices = 0;
209
+ });
210
+
211
+ scoped.on('before_agent_start', async (eventCtx?: unknown) => {
172
212
  const plan = planTodoReadHook({
173
213
  items: todos.items,
174
214
  turn: todoReadTurn,
175
215
  lastEmptyGuardTurn,
216
+ lastWriteAt: todos.lastWriteAt,
217
+ turnsSinceWrite: lastWriteTurn == null ? null : todoReadTurn - lastWriteTurn,
218
+ now: Date.now(),
219
+ staleNotices,
220
+ lastStaleGuardTurn,
176
221
  });
222
+ if (plan.inject && plan.effect === 'stale-notice') {
223
+ staleNotices += 1;
224
+ lastStaleGuardTurn = todoReadTurn;
225
+ }
226
+ // 空守卫 / 归档通知 / R2 重写窗口共用注入节奏(archived=true 的 stale 窗口同享,
227
+ // 否则终态通知会在窗口后紧跟一拍挤掉宽限)。
228
+ if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
229
+ lastEmptyGuardTurn = todoReadTurn;
230
+ }
231
+ // R1:归档通知注入即清空——明细早已不再注入,保留死列表只会挡住空守卫的
232
+ // before-stopping 驱动(01a03c0d:归档后 2h 多步工作零跟踪)。rm 全量落
233
+ // JSONL,重启 rebuild 同样折叠得空表;历史明细仍在会话文件里。
234
+ if (plan.inject && plan.effect === 'archive-notice' && plan.clearArchived && todos.items.length > 0) {
235
+ const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
236
+ try {
237
+ appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
238
+ } catch {
239
+ /* 尽力而为(内存清空照常) */
240
+ }
241
+ todos.replace([]);
242
+ }
177
243
  todoReadTurn += 1;
178
244
  if (!plan.inject) return;
179
- if (todos.items.length === 0) lastEmptyGuardTurn = todoReadTurn - 1;
180
245
  return {
181
246
  message: {
182
247
  customType: plan.message.customType,
@@ -264,7 +329,7 @@ export default function todoPlugin(ctx: Context): void {
264
329
  },
265
330
  });
266
331
 
267
- /* ── 命令:/todos(查看 + done/drop/rm 人类编辑,D38)+ /todo export ── */
332
+ /* ── 命令:/todos(查看 + done/drop/rm/unblock 人类编辑,D38)── */
268
333
  scoped.registerCommand('todos', {
269
334
  description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
270
335
  handler: async (args: unknown, eventCtx: unknown) => {
@@ -312,29 +377,18 @@ export default function todoPlugin(ctx: Context): void {
312
377
  ui?.notify?.(`"${content}" ${verb}`, 'info');
313
378
  return;
314
379
  }
315
- // 全量视图(/todos 不受 widget 10 行预算限制——"看整个列表"的正式入口)
380
+ // 全量视图(/todos 不受 widget 行预算限制——"看整个列表"的正式入口)
316
381
  const body = renderGroups(todos.items);
317
382
  if (body.length === 0) {
318
383
  ui?.notify?.('todo list is empty', 'info');
319
384
  return;
320
385
  }
321
386
  const c = countTodos(todos.items);
322
- ui?.notify?.([`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`, ...body].join('\n'), 'info');
323
- },
324
- });
325
-
326
- scoped.registerCommand('todo', {
327
- description: 'Export the current todo list to TODO.md in the working directory',
328
- handler: async (_args: unknown, eventCtx: unknown) => {
329
- const ui = (eventCtx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
330
- const cwd = (eventCtx as { cwd?: string }).cwd ?? process.cwd();
331
- try {
332
- const target = join(cwd, 'TODO.md');
333
- writeFileSync(target, formatTodosMarkdown(todos.items));
334
- ui?.notify?.(`TODO.md written: ${target} (${todos.items.length} items)`, 'info');
335
- } catch (err) {
336
- ui?.notify?.(`failed to write TODO.md: ${(err as Error).message}`, 'error');
337
- }
387
+ const age = archivedAgeMs();
388
+ const head = `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`
389
+ + (age != null ? ` · archived ${formatAge(age)}(不再注入)` : '');
390
+ ui?.notify?.([head, ...body].join('\n'), 'info');
338
391
  },
339
392
  });
340
393
  }
394
+
@@ -77,7 +77,7 @@ export interface HerdrClientLike {
77
77
  readonly available: boolean;
78
78
  reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
79
79
  reportAgentSession(sessionPath: string | null): Promise<void>;
80
- reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null }): Promise<void>;
80
+ reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
81
81
  /** M18:写锁 token 上报(键 lock-<hash>,值 paneId|path;null = 释放;≤16 键/批自动分批)。 */
82
82
  reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
83
83
  /**
@@ -302,10 +302,13 @@ export class HerdrClient implements HerdrClientLike {
302
302
  }
303
303
  }
304
304
 
305
- async reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null }): Promise<void> {
305
+ async reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void> {
306
306
  try {
307
307
  void meta.session;
308
- const title = formatPaneTitle(meta.items, null, { progressSuffix: meta.progressSuffix });
308
+ const title = formatPaneTitle(meta.items, null, {
309
+ progressSuffix: meta.progressSuffix,
310
+ lastWriteAt: meta.lastWriteAt,
311
+ });
309
312
  const blocked = formatBlockedLabel(meta.items);
310
313
  // D96:stale 清理与日常上报分离——stale(16) + pi-todo(1) = 17 > herdr tokens max=16
311
314
  // → 整个请求被拒 → title/tokens 全丢(D93 回归根因)。stale 单独批次,成功后才置位。
@@ -47,6 +47,16 @@ export interface HistoryEntry {
47
47
  closedAt?: number | null;
48
48
  /** 打回重做/复活时的前代 paneId。 */
49
49
  revivedFrom?: string | null;
50
+ /** B5:写手标记(spawn / poll-settle / poll-timeout / gc / zombie-sweep …)——
51
+ * 同秒多行可审计(01a03c0d 台账 row#23/24 双写实证)。 */
52
+ via?: string;
53
+ }
54
+
55
+ /** B5:closed 行 outcome 继承——patch 未显式指定 outcome 时沿用该 taskId 最近非空值,
56
+ * 「最新行即现状」语义下结算成果不因 GC 补记而丢失。 */
57
+ export function inheritOutcome(lastOutcome: string | null | undefined, patchOutcome: string | null | undefined): string | null {
58
+ if (patchOutcome !== undefined) return patchOutcome;
59
+ return lastOutcome ?? null;
50
60
  }
51
61
 
52
62
  export function historyFilePath(agentRoot: string, cwd: string): string {
package/src/index.ts CHANGED
@@ -24,9 +24,7 @@ import {
24
24
  import { collapseNotices } from './notice-buffer.ts';
25
25
  import {
26
26
  TODO_EDIT_CUSTOM_TYPE,
27
- applyTodoEdits,
28
27
  currentActivity,
29
- foldLatestTodos,
30
28
  } from './todo-core.ts';
31
29
  import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
32
30
 
@@ -55,9 +53,12 @@ import {
55
53
  releaseTokensFor,
56
54
  type LockAgentView,
57
55
  } from './lock-core.ts';
56
+ import { ABORT_STOP_REASON, planSettleWake } from './settle-wake-core.ts';
58
57
  import { composeForRole } from './manifest-compose.ts';
59
58
  import { parseRuntimeManifest, planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
60
59
  import { detectHerdrEnv } from './herdr-client.ts';
60
+ import { formatPaneTitle } from './pane-title.ts';
61
+ import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
61
62
 
62
63
  /**
63
64
  * WS-D7:master pane 自应用 manifest——与 subagent 同一条强制链
@@ -156,7 +157,19 @@ export default async function (pi: ExtensionAPI) {
156
157
  const p = progressOf(todos.items);
157
158
  const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
158
159
  const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
159
- client.reportMetadata({ session: label, items: todos.items, progressSuffix: suffix }).catch(() => {});
160
+ // 反冻结:lastWriteAt 随行 → 标题侧 archived 判定(pane-title)
161
+ const title = formatPaneTitle(todos.items, null, {
162
+ progressSuffix: suffix,
163
+ lastWriteAt: todos.lastWriteAt,
164
+ });
165
+ // D97:窄格静帧与 pane 标题同参同源(thinking token 碰不到 → 静帧即冻结)
166
+ updateSlimFrame(title);
167
+ client.reportMetadata({
168
+ session: label,
169
+ items: todos.items,
170
+ progressSuffix: suffix,
171
+ lastWriteAt: todos.lastWriteAt,
172
+ }).catch(() => {});
160
173
  }
161
174
 
162
175
  /* ── M17:结算自动对账(纯规划器 + D38 权威路径;幂等,双路径双跑无害) ── */
@@ -323,6 +336,10 @@ export default async function (pi: ExtensionAPI) {
323
336
  // 此时再 setWidget 会破坏 TUI 的 '/' 命令面板输入路由('/' 被当消息文本送给模型)。
324
337
  const reason = (event as { reason?: string } | undefined)?.reason;
325
338
  if (reason !== 'resume') todoUi.renderWidget(ctx);
339
+ // D97:窄格静帧 overlay(幂等注册;herdr pane 内才有意义——普通终端窄窗
340
+ // 盖住交互面是事故,且热力放大才提供退出路径。resume 也重注册:pi 会话
341
+ // 切换会 resetExtensionUI 拆掉 overlay)。
342
+ if (env) registerSlimFrame(ctx);
326
343
  mirrorTodos();
327
344
  reportSession(sessionId);
328
345
  // D93:侧边栏身份 = role 名(display_agent 优先于 agent 检测值,0.8.2 actions.rs:563)。
@@ -401,18 +418,47 @@ export default async function (pi: ExtensionAPI) {
401
418
  });
402
419
 
403
420
  let agentActive = false;
421
+ // D96 状态(settle-wake-core 去重/冷却的锚点)。
422
+ let d96NoticeKey: string | null = null;
423
+ let d96NoticeAt = 0;
424
+ // 反唤醒风暴(settle-wake-core):追踪最后一次 assistant turn 的 stopReason,
425
+ // 'aborted' = 用户 ESC 显式叫停 → settled 时不得注入任何唤醒型消息。
426
+ let lastStopReason: string | null = null;
404
427
  pi.on('turn_start', async () => {
405
428
  agentActive = true;
406
429
  reportAgent('working', currentActivity(todos.items));
407
430
  });
431
+ pi.on('turn_end', async (event: unknown) => {
432
+ if (event === null || typeof event !== 'object' || !('message' in event)) return;
433
+ const msg = (event as { message: unknown }).message; // 'message' in 已守卫
434
+ if (msg === null || typeof msg !== 'object') return;
435
+ const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
436
+ if (role === 'assistant' && typeof stopReason === 'string') {
437
+ lastStopReason = stopReason;
438
+ }
439
+ });
408
440
 
409
441
  pi.on('agent_settled', async () => {
410
442
  agentActive = false;
411
443
  reportAgent('idle', null);
444
+ const plan = planSettleWake({
445
+ lastStopReason,
446
+ running: subagentSlots.listRunningSubs?.() ?? [],
447
+ lastNoticeKey: d96NoticeKey,
448
+ lastNoticeAt: d96NoticeAt,
449
+ now: Date.now(),
450
+ });
451
+ d96NoticeKey = plan.noticeKey;
452
+ d96NoticeAt = plan.noticeAt;
453
+ if (!plan.wake) {
454
+ // 用户 abort 后静默:结算缓冲保留(pendingSettleNotices 不清空),
455
+ // 待下次自然 run 的 turn_end steer / 自然 settled 再投。
456
+ return;
457
+ }
412
458
  // D96:master settled(run 结束/想总结)时仍有后台 subagent running → 注入提醒。
413
459
  // worker 侧 subagentSlots.listRunningSubs 为 null(不注入)。
414
- const running = subagentSlots.listRunningSubs?.() ?? [];
415
- if (running.length > 0 && !isSubagent) {
460
+ if (plan.notice && !isSubagent) {
461
+ const running = subagentSlots.listRunningSubs?.() ?? [];
416
462
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
417
463
  const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(list_agents 查看状态);若不等待,请说明放弃原因。`;
418
464
  void sendUserMessageIn(notice);
@@ -531,22 +577,32 @@ export default async function (pi: ExtensionAPI) {
531
577
  * - agent_settled 兜底:run 真正结束后残余缓冲直投(触发新 run)。
532
578
  * 折叠规则(notice-buffer.ts):≤3 条原文逐条,>3 条前 3 + 尾行计数/全量指引。 */
533
579
  const pendingSettleNotices: string[] = [];
534
- const deliverNotice = async (content: string): Promise<void> => {
535
- if (agentActive) {
580
+ /** B2:缓冲中尚未送达的结算通知所属 pane(GC 豁免判据——01a03c0d 实证:
581
+ * pD 结算 3s 后 pane 被 GC 关掉,通知 100s 后才经 turn 间隙送达,master 只好 revive)。 */
582
+ const pendingNoticePaneIds = new Set<string>();
583
+ const deliverNotice = async (content: string, paneId?: string): Promise<void> => {
584
+ // 反唤醒风暴:abort 后不直投(会触发新 run)——入缓冲,待下次自然 turn 投递。
585
+ // 结算内容本身持久在台账(list_agents / resume_subagent 可查),推送让位于用户意图。
586
+ if (content !== '' && paneId !== undefined) pendingNoticePaneIds.add(paneId);
587
+ if (agentActive || lastStopReason === ABORT_STOP_REASON) {
536
588
  pendingSettleNotices.push(content);
537
589
  return;
538
590
  }
539
591
  await sendUserMessageIn(content);
592
+ if (content !== '' && paneId !== undefined) pendingNoticePaneIds.delete(paneId);
540
593
  };
541
594
  const flushSettleNotices = async (mode: 'steer' | 'followUp'): Promise<void> => {
542
595
  if (pendingSettleNotices.length === 0) return;
543
596
  const collapsed = collapseNotices(pendingSettleNotices.splice(0));
597
+ pendingNoticePaneIds.clear(); // B2:折叠批次视为已送达(GC 恢复正常回收)
544
598
  if (collapsed) await sendUserMessageAs(collapsed, mode);
545
599
  };
546
600
  pi.on('turn_end', () => {
547
601
  void flushSettleNotices('steer');
548
602
  });
549
603
  pi.on('agent_settled', () => {
604
+ // abort 后不投缓冲(会触发新 run;缓冲保留待下次自然 turn)——见 settle-wake-core。
605
+ if (lastStopReason === ABORT_STOP_REASON) return;
550
606
  void flushSettleNotices('followUp');
551
607
  });
552
608
 
@@ -570,7 +626,12 @@ export default async function (pi: ExtensionAPI) {
570
626
  push: req.push === true,
571
627
  sinceTs: Date.now(),
572
628
  };
573
- await sendUserMessageIn(req.text);
629
+ // B3:follow_up 带 steer → 以 steer 投递(pi 语义:当前 tool call 执行完、
630
+ // 下次 LLM 调用前到达)——长 run 中途的补充契约秒级生效,不再排队整个 run
631
+ // (01a03c0d:契约 20min 后才到,worker 按旧契约实现被迫返工)。
632
+ // 初始 prompt 不 steer:空闲 worker 上的 followUp 才触发新 run(D96 语义)。
633
+ if (req.steer === true) await sendUserMessageAs(req.text, 'steer');
634
+ else await sendUserMessageIn(req.text);
574
635
  return { type: 'ok', id: req.id };
575
636
  }
576
637
  if (req.type === 'interrupt') {
@@ -589,7 +650,7 @@ export default async function (pi: ExtensionAPI) {
589
650
  const body = (req.sessionFile ? `${head}\nSession: ${req.sessionFile}` : head)
590
651
  + (notes.length ? `\n${notes.join('\n')}` : '');
591
652
  // D92:经缓冲注入(忙时 turn_end 折叠 steer,闲时直投)
592
- await deliverNotice(body);
653
+ await deliverNotice(body, req.paneId);
593
654
  }
594
655
  return { type: 'ok', id: req.id };
595
656
  }
@@ -700,7 +761,8 @@ export default async function (pi: ExtensionAPI) {
700
761
  env,
701
762
  extPath: fileURLToPath(import.meta.url),
702
763
  sessionRoot,
703
- slots: subagentSlots,
764
+ deliverNotice,
765
+ noticePending: () => pendingNoticePaneIds,
704
766
  getSessionId: () => sessionId,
705
767
  getBlockedDepth: () => blockedDepth,
706
768
  reconcileOnSettlement,
package/src/pane-title.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * herdr 把 title / state_label 截到 80 字符——本地先裁,避免半截 emoji。
6
6
  */
7
7
  import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
8
+ import { formatAge, isArchived } from './stale-core.ts';
8
9
 
9
10
  /** herdr 对 title / state_label 的字符上限(官方文档 + schema 实测)。 */
10
11
  export const TITLE_MAX = 80;
@@ -46,14 +47,23 @@ function clipTitle(s: string): string {
46
47
  * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>`(D91 图标统一:黑白字符四件套)。
47
48
  * activity = 第一条 in_progress,否则 fallbackDescription,再否则只报计数。
48
49
  * 空列表 → null。progressSuffix(M16)= formatProgressSuffix 产物。
50
+ *
51
+ * 反冻结(stale-core D):全完成 + 墙钟过期(archived)→ 死列表降权为
52
+ * `✓N done <age>`,不再以 ▶○■✓ 满权重视窗冒充当前状态;"在干嘛"由
53
+ * report_agent 的工具徽标/活动行承担,todo token 只报计划真实状态。
49
54
  */
50
55
  export function formatPaneTitle(
51
56
  items: readonly TodoItem[],
52
57
  fallbackDescription?: string | null,
53
- opts?: { progressSuffix?: string | null },
58
+ opts?: { progressSuffix?: string | null; lastWriteAt?: number | null; now?: number },
54
59
  ): string | null {
55
60
  if (items.length === 0) return TITLE_EMPTY;
56
61
  const c = countTodos(items);
62
+ const now = opts?.now ?? Date.now();
63
+ if (isArchived(items, opts?.lastWriteAt ?? null, now)) {
64
+ const age = formatAge(now - (opts?.lastWriteAt as number));
65
+ return clipTitle(`✓${c.completed} done ${age}`);
66
+ }
57
67
  const activity =
58
68
  items.find((it) => it.status === 'in_progress')?.content
59
69
  ?? (fallbackDescription?.trim() ? fallbackDescription.trim() : null);
@@ -37,6 +37,9 @@ export type PipeRequest =
37
37
  from?: string | null;
38
38
  /** D50:结算时是否 push reply(后台 true、前台 false——前台由调用方同步取结果)。 */
39
39
  push?: boolean;
40
+ /** B3:follow_up 以 steer 投递(tool-call 间隙即达,秒级)。缺省/ false =
41
+ * followUp 队列语义(run 结束才投)——初始 prompt 保持队列语义,续作指令走 steer。 */
42
+ steer?: boolean;
40
43
  }
41
44
  | { type: 'interrupt'; id: string }
42
45
  | {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * settled 唤醒决策纯核心(反唤醒风暴;session 01a03bf0 实证)。
3
+ *
4
+ * 实证链:ESC 中止 → agent_settled → D96「仍有后台 subagent」提醒
5
+ * sendUserMessage(followUp)(空闲时必触发新 run)→ master 重启 → 用户再 ESC → …
6
+ * 30 连 abort,ESC 永远打不断,Ctrl+C 杀进程才停。
7
+ * 且自然 settle 也有自激励环:settle → 提醒 → 新 run → settle → 提醒……
8
+ *
9
+ * 规则:
10
+ * - abort 抑制:上次 assistant stopReason === 'aborted' → 本次 settled 不做任何
11
+ * 唤醒型注入(用户显式叫停;结算缓冲保留待下次自然 turn 投递);
12
+ * - D96 去重:同一组 running paneId 只提醒一次;集合变化(新 sub / 结算)重置;
13
+ * - D96 冷却:同集合重提醒至少间隔 REPEAT_NOTICE_MS(集合没变却再提醒 = 噪音)。
14
+ */
15
+ export const ABORT_STOP_REASON = 'aborted';
16
+
17
+ /** D96:同一 running 集合的重提醒冷却。 */
18
+ export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
19
+
20
+ export interface SettleWakeInput {
21
+ /** 本次 settled 前最后一次 assistant turn 的 stopReason(未知 = null,视作自然结束)。 */
22
+ lastStopReason: string | null;
23
+ /** 仍在 running 的后台 subagent(空数组 = 无)。 */
24
+ running: ReadonlyArray<{ paneId: string }>;
25
+ /** 上次 D96 提醒时的集合键(无 = null)。 */
26
+ lastNoticeKey: string | null;
27
+ /** 上次 D96 提醒时间(epoch ms)。 */
28
+ lastNoticeAt: number;
29
+ now: number;
30
+ }
31
+
32
+ export interface SettleWakePlan {
33
+ /** false = 本次 settled 静默:不投任何唤醒型消息。 */
34
+ wake: boolean;
35
+ /** 是否注入 D96「仍在运行」提醒。 */
36
+ notice: boolean;
37
+ /** 新的集合键(提醒时更新;running 空 → null)。 */
38
+ noticeKey: string | null;
39
+ noticeAt: number;
40
+ }
41
+
42
+ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
43
+ if (input.lastStopReason === ABORT_STOP_REASON) {
44
+ return { wake: false, notice: false, noticeKey: input.lastNoticeKey, noticeAt: input.lastNoticeAt };
45
+ }
46
+ const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
47
+ if (key === null) {
48
+ return { wake: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
49
+ }
50
+ const newSet = key !== input.lastNoticeKey;
51
+ const cooled = input.now - input.lastNoticeAt >= D96_REPEAT_NOTICE_MS;
52
+ const notice = newSet || cooled;
53
+ return { wake: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
54
+ }