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/package.json +1 -1
- package/src/core/grid-shape.ts +10 -5
- package/src/core/subagent.ts +157 -53
- package/src/core/todo.ts +120 -66
- package/src/herdr-client.ts +6 -3
- package/src/history-store.ts +10 -0
- package/src/index.ts +72 -10
- package/src/pane-title.ts +11 -1
- package/src/pipe-channel.ts +3 -0
- package/src/settle-wake-core.ts +54 -0
- package/src/slim-frame.ts +219 -0
- package/src/stale-core.ts +76 -0
- package/src/subagent-core.ts +30 -0
- package/src/todo-core.ts +41 -4
- package/src/todo-read-hook.ts +122 -20
- package/src/todos-service.ts +13 -3
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
|
|
11
|
-
* renderWidget
|
|
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
|
-
* -
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
|
package/src/herdr-client.ts
CHANGED
|
@@ -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, {
|
|
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 单独批次,成功后才置位。
|
package/src/history-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/pipe-channel.ts
CHANGED
|
@@ -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
|
+
}
|