pi-pier 0.1.2 → 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/README.md +23 -5
- package/package.json +1 -1
- package/src/core/grid-shape.ts +10 -5
- package/src/core/subagent.ts +297 -96
- package/src/core/todo.ts +150 -69
- 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 +100 -0
- package/src/todo-core.ts +66 -15
- package/src/todo-read-hook.ts +122 -20
- package/src/todos-service.ts +13 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D97 窄格静帧(slim frame):pane 长宽放不下最小可用 TUI 时,用非捕获
|
|
3
|
+
* 全屏 overlay 盖一张 pane title 同源静帧。
|
|
4
|
+
*
|
|
5
|
+
* 动机(用户实证):热力把非焦点格压窄后,worker 的流式 thinking 在窄格
|
|
6
|
+
* 里持续整屏重绘 → 闪烁。静帧只在 todo_write / 状态上报时更新(与
|
|
7
|
+
* formatPaneTitle 同参同源),thinking token 碰不到它 → alt-screen 行级
|
|
8
|
+
* 差分下 PTY 零输出,闪烁物理消失。
|
|
9
|
+
*
|
|
10
|
+
* 前提:worker/master 以 --tui-mode fullscreen 运行(buildLaunchParts 默认
|
|
11
|
+
* 注入)。regular 主屏的整屏 dump 路径(tui-main-screen firstChanged <
|
|
12
|
+
* viewportTop → 全量重倒)overlay 盖不住——composite 在文档之上,dump 在
|
|
13
|
+
* 文档之内。
|
|
14
|
+
*
|
|
15
|
+
* 注册闸:仅 herdr pane 内(index.ts 以 env 判定)——普通小终端窗口里盖住
|
|
16
|
+
* 交互面是事故;herdr 窄格点击聚焦即被热力放大,visible 谓词随 SIGWINCH
|
|
17
|
+
* 每帧重估,静帧自动消失、真 TUI 原样在底下(agent/会话/widget 从未停)。
|
|
18
|
+
*
|
|
19
|
+
* 生命周期:进程内单例,session_start(非 resume)注册一次;overlay 常驻
|
|
20
|
+
* (done() 永不调用 → Promise 永挂,吞 rejection,绝不 await)。pi 会话
|
|
21
|
+
* 切换若拆掉 overlay(resetExtensionUI)则静帧自然退场,不影响主流程。
|
|
22
|
+
* PI_HERDR_SLIM_FRAME=0 逃生口。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** TUI 可用下限(pi interactive-mode 布局:editor 3 行 + footer 1 + transcript ≥3 + 间距)。
|
|
26
|
+
* 任一轴低于此值 → 该格读不了 TUI,画 title 静帧。 */
|
|
27
|
+
export const SLIM_MIN_COLS = 24;
|
|
28
|
+
export const SLIM_MIN_ROWS = 12;
|
|
29
|
+
|
|
30
|
+
/** 静帧可见性谓词(overlayOptions.visible 每渲染帧回调)。 */
|
|
31
|
+
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
32
|
+
return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ── 宽度感知折行(保守 East Asian 宽度;▶○■✓ 等歧义宽字符按 1 记,仅影响折点位置) ── */
|
|
36
|
+
|
|
37
|
+
function isWide(cp: number): boolean {
|
|
38
|
+
return (
|
|
39
|
+
(cp >= 0x1100 && cp <= 0x115f) // 谚文兼容字母
|
|
40
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK 部首~彝文(含假名)
|
|
41
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) // 谚文音节
|
|
42
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK 兼容表意
|
|
43
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK 兼容形式
|
|
44
|
+
|| (cp >= 0xff00 && cp <= 0xff60) // 全角形式
|
|
45
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function displayWidth(s: string): number {
|
|
50
|
+
let w = 0;
|
|
51
|
+
for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
52
|
+
return w;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 按可见宽度折行:词折行(行内有空格则退到最后一个空格断,英文短语不腰斩;
|
|
57
|
+
* CJK 无空格自然硬折)。折行处吞掉断点空格。
|
|
58
|
+
*/
|
|
59
|
+
export function wrapByWidth(text: string, width: number): string[] {
|
|
60
|
+
const w = Math.max(1, Math.floor(width));
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
let line = '';
|
|
63
|
+
let lineW = 0;
|
|
64
|
+
const push = () => {
|
|
65
|
+
lines.push(line);
|
|
66
|
+
line = '';
|
|
67
|
+
lineW = 0;
|
|
68
|
+
};
|
|
69
|
+
for (const ch of text) {
|
|
70
|
+
const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
71
|
+
if (lineW + cw > w) {
|
|
72
|
+
const lastSpace = line.lastIndexOf(' ');
|
|
73
|
+
if (lastSpace > 0) {
|
|
74
|
+
lines.push(line.slice(0, lastSpace));
|
|
75
|
+
line = line.slice(lastSpace + 1) + ch;
|
|
76
|
+
lineW = displayWidth(line);
|
|
77
|
+
} else {
|
|
78
|
+
push();
|
|
79
|
+
line = ch;
|
|
80
|
+
lineW = cw;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
line += ch;
|
|
84
|
+
lineW += cw;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (line !== '' || lines.length === 0) lines.push(line);
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 静帧行:title 折行 → 垂直居中 → 钳到 rows。每行都补齐到满宽(可见宽度):
|
|
93
|
+
* overlay 的合成按行覆盖,空行会让底下的 TUI 透出来(漏 flicker),
|
|
94
|
+
* 满宽空格行才是真正的不透明冻结。无 title → 居中一枚 `·`(活着、没计划)。
|
|
95
|
+
*/
|
|
96
|
+
export function frameLines(
|
|
97
|
+
text: string,
|
|
98
|
+
opts: { width: number; rows: number; colorize?: (s: string) => string },
|
|
99
|
+
): string[] {
|
|
100
|
+
const width = Math.max(1, Math.floor(opts.width));
|
|
101
|
+
const rows = Math.max(1, Math.floor(opts.rows));
|
|
102
|
+
const colorize = opts.colorize ?? ((s: string) => s);
|
|
103
|
+
const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
|
|
104
|
+
|
|
105
|
+
const body = text.trim()
|
|
106
|
+
? wrapByWidth(text.trim(), width).slice(0, rows)
|
|
107
|
+
: ['·'];
|
|
108
|
+
const top = Math.max(0, Math.floor((rows - body.length) / 2));
|
|
109
|
+
const out: string[] = [];
|
|
110
|
+
for (let i = 0; i < rows; i++) {
|
|
111
|
+
const idx = i - top;
|
|
112
|
+
out.push(idx >= 0 && idx < body.length ? pad(colorize(body[idx])) : ' '.repeat(width));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── overlay 组件 + 注册/更新(进程内单例) ─────────────────────────── */
|
|
118
|
+
|
|
119
|
+
interface FrameTui {
|
|
120
|
+
requestRender(): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class SlimFrameComponent {
|
|
124
|
+
private text = '';
|
|
125
|
+
private lastRows = 0;
|
|
126
|
+
private cache: { width: number; lines: string[] } | null = null;
|
|
127
|
+
private readonly tui: FrameTui;
|
|
128
|
+
private readonly colorize: (s: string) => string;
|
|
129
|
+
|
|
130
|
+
constructor(tui: FrameTui, colorize: (s: string) => string) {
|
|
131
|
+
this.tui = tui;
|
|
132
|
+
this.colorize = colorize;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** visible 谓词每帧回调:顺手记录行数(render(width) 拿不到高度,居中布局靠它)。 */
|
|
136
|
+
onViewport(cols: number, rows: number): boolean {
|
|
137
|
+
if (this.lastRows !== rows) {
|
|
138
|
+
this.lastRows = rows;
|
|
139
|
+
this.cache = null; // 行数变了 → 居中重排
|
|
140
|
+
}
|
|
141
|
+
return isSlimFrame(cols, rows);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setText(text: string): void {
|
|
145
|
+
if (this.text === text) return;
|
|
146
|
+
this.text = text;
|
|
147
|
+
this.cache = null;
|
|
148
|
+
this.tui.requestRender();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
if (this.cache && this.cache.width === width) return this.cache.lines;
|
|
153
|
+
const lines = frameLines(this.text, { width, rows: this.lastRows, colorize: this.colorize });
|
|
154
|
+
this.cache = { width, lines };
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
invalidate(): void {
|
|
159
|
+
this.cache = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** pi 拆 overlay(会话切换/退出)时回收模块单例,重注册不被 no-op。 */
|
|
163
|
+
dispose(): void {
|
|
164
|
+
if (active === this) active = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/** 测试注入点(module state 重置)。 */
|
|
170
|
+
export function resetForTest(): void {
|
|
171
|
+
active = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let active: SlimFrameComponent | null = null;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 注册静帧 overlay(幂等:进程一次)。eventCtx = pi 生命周期事件上下文
|
|
178
|
+
* (session_start 的 ctx,与 todo widget 的 widgetUi 同来源)。
|
|
179
|
+
*/
|
|
180
|
+
export function registerSlimFrame(eventCtx: unknown): void {
|
|
181
|
+
if (active) return;
|
|
182
|
+
if (process.env.PI_HERDR_SLIM_FRAME === '0') return;
|
|
183
|
+
const ui = eventCtx !== null && typeof eventCtx === 'object' && 'ui' in eventCtx
|
|
184
|
+
? eventCtx.ui
|
|
185
|
+
: null;
|
|
186
|
+
const custom = ui !== null && typeof ui === 'object' && 'custom' in ui && typeof ui.custom === 'function'
|
|
187
|
+
? ui.custom
|
|
188
|
+
: undefined;
|
|
189
|
+
try {
|
|
190
|
+
// overlay: true = 浮层不清屏(pi ctx.ui.custom 默认替换编辑器区,必须显式盖);
|
|
191
|
+
// nonCapturing = 键盘仍归编辑器(人点进窄格也能打字,热力放大后静帧自动消失)。
|
|
192
|
+
const pending = custom(
|
|
193
|
+
(tui, theme) => {
|
|
194
|
+
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
195
|
+
active = comp;
|
|
196
|
+
return comp;
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
overlay: true,
|
|
200
|
+
overlayOptions: {
|
|
201
|
+
width: '100%',
|
|
202
|
+
maxHeight: '100%',
|
|
203
|
+
anchor: 'top-left',
|
|
204
|
+
nonCapturing: true,
|
|
205
|
+
visible: (cols: number, rows: number) => active !== null && active.onViewport(cols, rows),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
// done() 永不调用 → Promise 永挂(overlay 常驻);吞 rejection,绝不 await。
|
|
210
|
+
Promise.resolve(pending).catch(() => {});
|
|
211
|
+
} catch {
|
|
212
|
+
/* 无 overlay 能力的 pi 版本静默降级 */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 静帧内容更新(title 与 pane 标题投影同参同源;null = 无计划)。 */
|
|
217
|
+
export function updateSlimFrame(title: string | null): void {
|
|
218
|
+
active?.setText(title ?? '');
|
|
219
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo 陈旧度纯核心(反冻结:会话 01a03253 实证)。
|
|
3
|
+
*
|
|
4
|
+
* 实证:全完成列表(3✓)冻结 16h / 37 轮,模型持续做新工作但 todo_write 零调用;
|
|
5
|
+
* 读钩每轮复读死列表反而背书"无事可跟踪",标题满权重渲染死计数。
|
|
6
|
+
* 本模块把"列表是否还反映现实"收敛为纯函数,三层消费:
|
|
7
|
+
* - stale(A):open==0 且 ≥ STALE_TURNS 轮未写 → 读钩注入改 stale 警告(限频 + 封顶);
|
|
8
|
+
* - archived(B):open==0 且墙钟 ≥ STALE_CLOCK_MS 未写 → 按不存在处理
|
|
9
|
+
* (注入/投影不再复读明细;条目保留,/todos 可查;会话 JSONL 权威不动);
|
|
10
|
+
* - 标题(D):archived 时窗格/侧栏渲染 `✓N done <age>`,不再冒充当前状态。
|
|
11
|
+
*
|
|
12
|
+
* 有 open 项(pending/in_progress/blocked)的列表永不判 stale——未完成工作
|
|
13
|
+
* 仍由 agent_settled 提醒兜底;lastWriteAt 未知(旧会话无时间戳)→ 时钟维度
|
|
14
|
+
* 保守不判 archived,turns 维度不受影响。
|
|
15
|
+
*/
|
|
16
|
+
import { countTodos, type TodoItem } from './vocab.ts';
|
|
17
|
+
|
|
18
|
+
/** A:turns 维度过期阈值(用户轮次,自上次 todo 写入起算)。 */
|
|
19
|
+
export const STALE_TURNS = 6;
|
|
20
|
+
|
|
21
|
+
/** B:墙钟维度归档阈值(全完成列表最后一次写入距今)。 */
|
|
22
|
+
export const STALE_CLOCK_MS = 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** A:每次停滞期最多注入的 stale 警告数(防空转噪音;archived 后转归档通知节奏)。 */
|
|
25
|
+
export const STALE_NOTICE_MAX = 3;
|
|
26
|
+
|
|
27
|
+
export type StalenessKind = 'fresh' | 'stale' | 'archived';
|
|
28
|
+
|
|
29
|
+
export interface Staleness {
|
|
30
|
+
kind: StalenessKind;
|
|
31
|
+
/** open = pending + in_progress + blocked(abandoned 不算未完成)。 */
|
|
32
|
+
open: number;
|
|
33
|
+
/** now - lastWriteAt;lastWriteAt 未知 → null。 */
|
|
34
|
+
ageMs: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** open 计数:stale/archived 只对 open==0 的全完成(或全放弃)列表生效。 */
|
|
38
|
+
export function openTodos(items: readonly TodoItem[]): number {
|
|
39
|
+
const c = countTodos(items as TodoItem[]);
|
|
40
|
+
return c.pending + c.inProgress + c.blocked;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 陈旧度判定(双条件:turns 维度 → stale;墙钟维度 → archived,时钟优先)。
|
|
45
|
+
* 空列表 → fresh(空守卫另有归属);lastWriteAt null → 永不 stale(保守)。
|
|
46
|
+
*/
|
|
47
|
+
export function evaluateStaleness(opts: {
|
|
48
|
+
items: readonly TodoItem[];
|
|
49
|
+
lastWriteAt: number | null;
|
|
50
|
+
turnsSinceWrite: number | null;
|
|
51
|
+
now: number;
|
|
52
|
+
}): Staleness {
|
|
53
|
+
const open = openTodos(opts.items);
|
|
54
|
+
const ageMs = opts.lastWriteAt == null ? null : Math.max(0, opts.now - opts.lastWriteAt);
|
|
55
|
+
const st: Staleness = { kind: 'fresh', open, ageMs };
|
|
56
|
+
if (opts.items.length === 0 || open > 0) return st;
|
|
57
|
+
if (ageMs != null && ageMs >= STALE_CLOCK_MS) st.kind = 'archived';
|
|
58
|
+
else if (opts.turnsSinceWrite != null && opts.turnsSinceWrite >= STALE_TURNS) st.kind = 'stale';
|
|
59
|
+
return st;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 标题/镜像路径专用(无 turn 信息):仅墙钟维度的 archived 判定。 */
|
|
63
|
+
export function isArchived(
|
|
64
|
+
items: readonly TodoItem[],
|
|
65
|
+
lastWriteAt: number | null,
|
|
66
|
+
now: number,
|
|
67
|
+
): boolean {
|
|
68
|
+
return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 年龄显示:<60m → `Nm`;<48h → `Nh`(floor);否则 `Nd`。 */
|
|
72
|
+
export function formatAge(ms: number): string {
|
|
73
|
+
if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
|
|
74
|
+
if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
|
|
75
|
+
return `${Math.floor(ms / 86_400_000)}d`;
|
|
76
|
+
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -98,6 +98,76 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/* ── 探活 + 存活通知(A2/B1 统一出口;纯逻辑,IO 在 adapter) ────── */
|
|
102
|
+
|
|
103
|
+
/** 探活快照:pane 实时状态 + 会话活动时间(毫秒 epoch;null = 未知)。 */
|
|
104
|
+
export interface AliveProbe {
|
|
105
|
+
paneExists: boolean;
|
|
106
|
+
agentStatus: string | null;
|
|
107
|
+
/** 会话文件 mtime(最新候选);无文件/不可读 = null。 */
|
|
108
|
+
lastActivityMs: number | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 探活判活:pane 在 +(working/blocked 或近期有会话活动)→ 视为还在干活。 */
|
|
112
|
+
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
113
|
+
if (!probe.paneExists) return false;
|
|
114
|
+
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
115
|
+
// idle/unknown:会话 2 分钟内有活动仍算活(写作间隙/刚结算未采集)
|
|
116
|
+
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 人话相对时间("12s ago" / "3m ago")。 */
|
|
120
|
+
export function agoText(ms: number, nowMs: number): string {
|
|
121
|
+
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
122
|
+
if (s < 60) return `${s}s ago`;
|
|
123
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
124
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 存活通知(A2 转后台返回值 与 B1 tool_result 改写 的统一构造出口):
|
|
129
|
+
* 两场景同一话术——"它在干活 / 别重做 / 转后台了 / 结算会通知 / list_agents 可查"。
|
|
130
|
+
* scenario: 'moved-to-bg'(耐心阈值转后台)| 'error-alive'(工具报错但探活判活,兜底改写)。
|
|
131
|
+
*/
|
|
132
|
+
export function buildAliveNotice(
|
|
133
|
+
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
134
|
+
nowMs: number,
|
|
135
|
+
): string {
|
|
136
|
+
const { paneId, description, scenario, probe } = opts;
|
|
137
|
+
const status = probe.agentStatus ?? 'unknown';
|
|
138
|
+
const activity = probe.lastActivityMs != null ? `, last session activity ${agoText(probe.lastActivityMs, nowMs)}` : '';
|
|
139
|
+
if (scenario === 'moved-to-bg') {
|
|
140
|
+
return [
|
|
141
|
+
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
142
|
+
'The foreground wait has moved it to background so this call returns now.',
|
|
143
|
+
'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
|
|
144
|
+
'Use list_agents to check its live state; send_message to give it follow-up work.',
|
|
145
|
+
].join(' ');
|
|
146
|
+
}
|
|
147
|
+
return [
|
|
148
|
+
`The subagent "${description}" is ALIVE in pane ${paneId} (agent_status=${status}${activity}) — this "no readable output" result only means its final answer could not be read yet, NOT that the task failed.`,
|
|
149
|
+
'Do NOT redo its work. It is being moved to background; you will be notified when it settles.',
|
|
150
|
+
'Use list_agents to check its live state.',
|
|
151
|
+
].join(' ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 人类闸门通知(E1/E2 统一出口):subagent blocked(ask_user_question 等人类)时
|
|
156
|
+
* 给 master 的话术——不揽活、引导用户去 pane、授权例外经 send_message 转达。
|
|
157
|
+
* question 来自 herdr tokens['pi-ask'](子扩展 reportAskFlag 上报)。
|
|
158
|
+
*/
|
|
159
|
+
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
160
|
+
const { paneId, description, question } = opts;
|
|
161
|
+
const q = question ? ` (question: "${question}")` : '';
|
|
162
|
+
return [
|
|
163
|
+
`Subagent "${description}" is BLOCKED waiting for a HUMAN decision in pane ${paneId}${q}.`,
|
|
164
|
+
'It keeps running in background; once the human answers it resumes, and you will be notified when it settles.',
|
|
165
|
+
'Do NOT take over its work and do NOT answer on the human\'s behalf.',
|
|
166
|
+
'Tell the user to open that pane in herdr and answer directly.',
|
|
167
|
+
'Only if the user explicitly authorizes you: relay their decision to the subagent via send_message — do not redo the work yourself.',
|
|
168
|
+
].join(' ');
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
/* ── 子代理注册表(custom 条目持久化) ────────────────────────────── */
|
|
102
172
|
|
|
103
173
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
|
@@ -308,3 +378,33 @@ export function planTabPlacement(opts: {
|
|
|
308
378
|
const tabName = nextTaskTabName(base, new Set(known.keys()));
|
|
309
379
|
return { mode: 'new', tabName, tabId: null };
|
|
310
380
|
}
|
|
381
|
+
|
|
382
|
+
/* ── D97:worker 启动参数(纯逻辑,可单测) ───────────────────────── */
|
|
383
|
+
|
|
384
|
+
/** launchLine 的运行时三要素(master 侧探测:node 解释器 / pi cli / 本扩展入口)。 */
|
|
385
|
+
export interface LaunchRuntime {
|
|
386
|
+
nodePath: string;
|
|
387
|
+
cliPath: string;
|
|
388
|
+
extPath: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* worker 启动裸 argv。`--tui-mode fullscreen`(D97 静帧前提):
|
|
393
|
+
* alt-screen 行级差分——窄条静帧不变 → PTY 零输出,流式 thinking 不再整屏
|
|
394
|
+
* dump 闪烁;regular 主屏改动在视口上方即全量重倒(pi tui-main-screen
|
|
395
|
+
* firstChanged < viewportTop 路径),overlay 盖不住。
|
|
396
|
+
* `PI_HERDR_TUI=regular` 回退旧行为(逃生口)。
|
|
397
|
+
*/
|
|
398
|
+
export function buildLaunchParts(
|
|
399
|
+
runtime: LaunchRuntime,
|
|
400
|
+
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
401
|
+
env: Pick<NodeJS.ProcessEnv, 'PI_HERDR_TUI'> = process.env,
|
|
402
|
+
): string[] {
|
|
403
|
+
const parts = [runtime.nodePath, runtime.cliPath];
|
|
404
|
+
if (opts.approve) parts.push('-a');
|
|
405
|
+
parts.push('-e', runtime.extPath);
|
|
406
|
+
if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
|
|
407
|
+
if (opts.roleModel) parts.push('--provider', opts.roleModel.split('/')[0], '--model', opts.roleModel.split('/')[1] ?? opts.roleModel);
|
|
408
|
+
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
409
|
+
return parts;
|
|
410
|
+
}
|
package/src/todo-core.ts
CHANGED
|
@@ -93,20 +93,31 @@ export function validateTodos(
|
|
|
93
93
|
const item: TodoItem = { content, status: status as TodoStatus };
|
|
94
94
|
if ('blocker' in entry) {
|
|
95
95
|
const blocker = entry.blocker;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
96
|
+
// 容忍归一(用户实证修复):Optional 字段用空串/null 填充是常见模型行为
|
|
97
|
+
//(grok 实测 9 连拒死循环);status 是权威——非 blocked 态的 blocker 一律视为
|
|
98
|
+
// 缺省丢弃(无论是否有值),空值同样丢弃。仅类型畸形仍硬拒。
|
|
99
|
+
if (blocker != null && typeof blocker !== 'string') {
|
|
100
100
|
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
if (status === 'blocked') {
|
|
103
|
+
if (typeof blocker !== 'string' || blocker.trim() === '') {
|
|
104
|
+
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
105
|
+
}
|
|
106
|
+
item.blocker = blocker;
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
if ('phase' in entry) {
|
|
105
110
|
const phase = entry.phase;
|
|
106
|
-
if (
|
|
111
|
+
if (phase != null && typeof phase !== 'string') {
|
|
107
112
|
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
108
113
|
}
|
|
109
|
-
|
|
114
|
+
// 空串/纯空白 = 缺省(实证循环里 phase:"" 同样是被掩盖的雷);超长是真实约束仍硬拒
|
|
115
|
+
if (typeof phase === 'string') {
|
|
116
|
+
if (phase.length > PHASE_MAX_LEN) {
|
|
117
|
+
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
118
|
+
}
|
|
119
|
+
if (phase.trim() !== '') item.phase = phase;
|
|
120
|
+
}
|
|
110
121
|
}
|
|
111
122
|
if (status === 'in_progress') {
|
|
112
123
|
inProgress++;
|
|
@@ -187,6 +198,8 @@ export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
|
|
|
187
198
|
export interface BranchEntryLike {
|
|
188
199
|
/** pi 会话条目:type 为 'message' 的消息条目或 'custom' 的自定义条目。 */
|
|
189
200
|
type?: string;
|
|
201
|
+
/** 会话 JSONL 顶层时间戳(ISO 字符串或 epoch ms;重建 lastWriteAt 用)。 */
|
|
202
|
+
timestamp?: unknown;
|
|
190
203
|
message?: {
|
|
191
204
|
role?: string;
|
|
192
205
|
toolName?: string;
|
|
@@ -259,21 +272,56 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
|
|
|
259
272
|
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
260
273
|
* 分支正确性 = 沿分支路径顺序折叠。
|
|
261
274
|
*/
|
|
262
|
-
export
|
|
275
|
+
export interface FoldedTodos {
|
|
276
|
+
items: TodoItem[];
|
|
277
|
+
/** 产生该列表的最后一个条目时间戳(epoch ms);条目无时间戳 → null。 */
|
|
278
|
+
writtenAt: number | null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function entryTimestamp(entry: BranchEntryLike): number | null {
|
|
282
|
+
const ts = entry.timestamp;
|
|
283
|
+
if (typeof ts === 'number') return ts;
|
|
284
|
+
if (typeof ts === 'string') {
|
|
285
|
+
const parsed = Date.parse(ts);
|
|
286
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
|
|
293
|
+
* - toolResult(todo_write) → 快照替换(last-wins);
|
|
294
|
+
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
295
|
+
* 分支正确性 = 沿分支路径顺序折叠。writtenAt = 最后一次触碰该列表的条目时间戳
|
|
296
|
+
* (无时间戳的旧条目 → null,陈旧度判定保守判 fresh)。
|
|
297
|
+
*/
|
|
298
|
+
export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
|
|
263
299
|
let found: TodoItem[] | null = null;
|
|
300
|
+
let writtenAt: number | null = null;
|
|
264
301
|
for (const entry of entries) {
|
|
265
302
|
if (entry.type === 'message') {
|
|
266
303
|
const msg = entry.message;
|
|
267
304
|
if (typeof msg !== 'object' || msg === null) continue;
|
|
268
305
|
if (msg.role !== 'toolResult' || msg.toolName !== TODO_TOOL_NAME) continue;
|
|
269
306
|
const snapshot = extractSnapshotFromDetails(msg.details);
|
|
270
|
-
if (snapshot)
|
|
307
|
+
if (snapshot) {
|
|
308
|
+
found = snapshot.items.map((it) => ({ ...it }));
|
|
309
|
+
writtenAt = entryTimestamp(entry);
|
|
310
|
+
}
|
|
271
311
|
} else if (entry.type === 'custom' && entry.customType === TODO_EDIT_CUSTOM_TYPE) {
|
|
272
312
|
const payload = extractEditPayload(entry.data);
|
|
273
|
-
if (payload && found)
|
|
313
|
+
if (payload && found) {
|
|
314
|
+
found = applyTodoEdits(found, payload.edits);
|
|
315
|
+
writtenAt = entryTimestamp(entry) ?? (payload.ts > 0 ? payload.ts : writtenAt);
|
|
316
|
+
}
|
|
274
317
|
}
|
|
275
318
|
}
|
|
276
|
-
return found;
|
|
319
|
+
return found ? { items: found, writtenAt } : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** foldLatestTodosMeta 的条目-only 视图(兼容既有调用方)。 */
|
|
323
|
+
export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
|
|
324
|
+
return foldLatestTodosMeta(entries)?.items ?? null;
|
|
277
325
|
}
|
|
278
326
|
|
|
279
327
|
export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
|
|
@@ -298,7 +346,7 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
|
|
|
298
346
|
return substr.map((it) => it.content);
|
|
299
347
|
}
|
|
300
348
|
|
|
301
|
-
/** D39:有界视图选择——先丢 completed
|
|
349
|
+
/** D39:有界视图选择——先丢 completed、再截 open;两端都超额时保最新(尾部切片)。 */
|
|
302
350
|
export interface BoundedView {
|
|
303
351
|
visible: TodoItem[];
|
|
304
352
|
/** 被隐藏的数量按状态计数(completed 单独计,其余合并)。 */
|
|
@@ -312,15 +360,18 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
|
|
|
312
360
|
}
|
|
313
361
|
const completed = items.filter((it) => it.status === 'completed');
|
|
314
362
|
const open = items.filter((it) => it.status !== 'completed');
|
|
315
|
-
|
|
363
|
+
// 修正(用户实证):widget 是头部可见窗口——超预算必须隐最老(丢头部),保最新可见;
|
|
364
|
+
// 旧实现 slice(0, budget) 保最老,最新任务永远落在窗口外。
|
|
365
|
+
const visible = open.length > budget ? open.slice(-budget) : open;
|
|
316
366
|
const hiddenOpen = open.length - visible.length;
|
|
317
367
|
let hiddenCompleted = completed.length;
|
|
368
|
+
let final = visible;
|
|
318
369
|
if (hiddenOpen === 0 && visible.length < budget) {
|
|
319
370
|
const room = budget - visible.length;
|
|
320
|
-
|
|
371
|
+
final = [...visible, ...completed.slice(-room)];
|
|
321
372
|
hiddenCompleted = completed.length - room;
|
|
322
373
|
}
|
|
323
|
-
return { visible, hiddenCompleted, hiddenOpen };
|
|
374
|
+
return { visible: final, hiddenCompleted, hiddenOpen };
|
|
324
375
|
}
|
|
325
376
|
|
|
326
377
|
/** 当前 in_progress 中的第一条(上报 agent 状态时作为 message,供侧边栏显示"正在做什么")。 */
|