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.
- package/README.md +62 -0
- package/package.json +55 -0
- package/schemas/role-manifest.schema.json +53 -0
- package/skills/pi-herdr-workflow/SKILL.md +86 -0
- package/src/bootstrap.ts +106 -0
- package/src/core/grid-shape.ts +86 -0
- package/src/core/subagent.ts +1256 -0
- package/src/core/terminal.ts +359 -0
- package/src/core/todo.ts +313 -0
- package/src/gc-core.ts +56 -0
- package/src/herdr-client.ts +579 -0
- package/src/history-store.ts +115 -0
- package/src/index.ts +751 -0
- package/src/ledger.ts +71 -0
- package/src/lock-core.ts +155 -0
- package/src/manifest-compose.ts +102 -0
- package/src/notice-buffer.ts +28 -0
- package/src/pane-title.ts +84 -0
- package/src/pi-surface.ts +135 -0
- package/src/pipe-channel.ts +181 -0
- package/src/progress-core.ts +109 -0
- package/src/reconcile-core.ts +130 -0
- package/src/role-loader.ts +164 -0
- package/src/role-manifest.ts +166 -0
- package/src/roles/master.json +21 -0
- package/src/roles/worker-default.json +30 -0
- package/src/session-tail.ts +169 -0
- package/src/subagent-core.ts +310 -0
- package/src/subagent-scope.ts +39 -0
- package/src/terminal-core.ts +328 -0
- package/src/todo-core.ts +331 -0
- package/src/todo-read-hook.ts +54 -0
- package/src/todos-service.ts +66 -0
- package/src/tool-gate.ts +106 -0
- package/src/vocab.ts +82 -0
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档1 dispose 账本(D80⑤ + D79)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:hmr partial reload **不跑旧 fiber 的 effect-disposers**(spike A 实证)——
|
|
5
|
+
* 模块持有的进程级资源(setInterval / pipe server / watcher)在热换后泄漏。
|
|
6
|
+
* 补偿:core 模块把进程级资源登记进账本(key = 模块文件);hmr/reload 事件
|
|
7
|
+
* 按文件名触发 disposeKey,拆掉旧版本登记的资源;session_shutdown 走 disposeAll。
|
|
8
|
+
*
|
|
9
|
+
* D79 同构复用:未来 pi-surface proxy 的注册反清理(registerTool/on 的账本)
|
|
10
|
+
* 与本账本同一机制(key 可用 'pi-surface' 等逻辑名,不必是文件)。
|
|
11
|
+
*
|
|
12
|
+
* 纯逻辑零依赖;接缝在 bootstrap(hmr 事件)与 index(session_shutdown)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
/** 规范化 key:file:// URL 与普通路径统一为绝对路径(hmr filename 与 import.meta.url 可互比)。 */
|
|
18
|
+
export function normalizeModuleKey(spec: string): string {
|
|
19
|
+
let s = String(spec);
|
|
20
|
+
try {
|
|
21
|
+
if (s.startsWith('file://')) s = fileURLToPath(s);
|
|
22
|
+
} catch {
|
|
23
|
+
/* 非法 URL 按原样使用 */
|
|
24
|
+
}
|
|
25
|
+
return s.replace(/\\/g, '/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DisposeLedger {
|
|
29
|
+
/** 登记序(LIFO 拆除与 cordis effect 语义一致)。 */
|
|
30
|
+
private order: Array<{ key: string; dispose: () => void }> = [];
|
|
31
|
+
|
|
32
|
+
/** 登记一个待拆资源;返回撤销函数(资源自拆后从账本移除,防二次拆)。 */
|
|
33
|
+
add(spec: string, dispose: () => void): () => void {
|
|
34
|
+
const entry = { key: normalizeModuleKey(spec), dispose };
|
|
35
|
+
this.order.push(entry);
|
|
36
|
+
return () => {
|
|
37
|
+
const i = this.order.indexOf(entry);
|
|
38
|
+
if (i >= 0) this.order.splice(i, 1);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** hmr 补偿(D80⑤):只拆匹配 key 的登记项(LIFO),返回拆除数。 */
|
|
43
|
+
disposeKey(spec: string | string[]): number {
|
|
44
|
+
const keys = new Set((Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey));
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (let i = this.order.length - 1; i >= 0; i--) {
|
|
47
|
+
if (keys.has(this.order[i].key)) {
|
|
48
|
+
const { dispose } = this.order[i];
|
|
49
|
+
this.order.splice(i, 1);
|
|
50
|
+
try { dispose(); } catch { /* 补偿拆除非致命 */ }
|
|
51
|
+
n++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 全量 LIFO(session_shutdown 路径),返回拆除数。 */
|
|
58
|
+
disposeAll(): number {
|
|
59
|
+
let n = 0;
|
|
60
|
+
while (this.order.length > 0) {
|
|
61
|
+
const { dispose } = this.order.pop()!;
|
|
62
|
+
try { dispose(); } catch { /* 同上 */ }
|
|
63
|
+
n++;
|
|
64
|
+
}
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get size(): number {
|
|
69
|
+
return this.order.length;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/lock-core.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M18 文件级写锁纯核心(S2 拍板:默认软 veto;PI_HERDR_WRITE_LOCK=1 硬启;2026-08-18)。
|
|
3
|
+
*
|
|
4
|
+
* 通道(D3 无新协议,schema 实测约束):锁登记 = herdr `pane.report_metadata` tokens
|
|
5
|
+
* ——键必须匹配 `^[A-Za-z0-9_-]{1,32}$`(路径进不了键!)→ **键 = `lock-<FNV-1a 64位哈希>`,
|
|
6
|
+
* 值 = `<paneId>|<归一路径>`**(值无模式限制,路径放值里保可读性);
|
|
7
|
+
* 每次上报 ≤16 键(adapter 分批)。冲突检查 = `agent.list` 读全 pane tokens;
|
|
8
|
+
* pane 关闭 → agent 条目消失 = 锁失效;settled → 显式置 null 释放。
|
|
9
|
+
*
|
|
10
|
+
* 语义:
|
|
11
|
+
* - 参与工具:write / edit(`{path}`;bash 文件目标提取不可靠,v1 声明为 seam);
|
|
12
|
+
* - 同 pane 重入放行;异 pane 持有 → 软 warn(工具照跑,结果附警告)/ 硬 block;
|
|
13
|
+
* - 归一:resolve(cwd) + 反斜杠→斜杠 + 小写(Windows 大小写不敏感)+ 去尾斜杠;
|
|
14
|
+
* - 哈希碰撞(64 位,~10² 文件量级可忽略):软模式多一次无害警告;硬模式极小概率误拦。
|
|
15
|
+
*/
|
|
16
|
+
import { resolve, sep } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** 硬启开关(env 名固定)。 */
|
|
19
|
+
export const WRITE_LOCK_ENV = 'PI_HERDR_WRITE_LOCK';
|
|
20
|
+
/** v1 参与写锁的工具(取 input.path)。 */
|
|
21
|
+
export const WRITE_TOOLS = ['write', 'edit'] as const;
|
|
22
|
+
/** 锁 token 键前缀(schema:^[A-Za-z0-9_-]{1,32}$)。 */
|
|
23
|
+
export const LOCK_TOKEN_PREFIX = 'lock-';
|
|
24
|
+
/** 单次 report_metadata 的 token 键上限(schema maxProperties=16)。 */
|
|
25
|
+
export const LOCK_BATCH_LIMIT = 16;
|
|
26
|
+
/** 锁 token TTL(pane 崩溃兜底;正常路径 settled 即释放)。 */
|
|
27
|
+
export const LOCK_TTL_MS = 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** agent.list 里与本模块相关的视图。 */
|
|
30
|
+
export interface LockAgentView {
|
|
31
|
+
paneId: string;
|
|
32
|
+
tokens: Record<string, string | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ── 路径归一 ─────────────────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
export function normalizeLockPath(p: string, cwd: string): string {
|
|
38
|
+
const abs = resolve(cwd, p);
|
|
39
|
+
const unified = sep === '\\' ? abs.replace(/\\/g, '/') : abs;
|
|
40
|
+
return unified.toLowerCase().replace(/\/+$/, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── token 编解码(key = 哈希;value = paneId|path) ─────────────── */
|
|
44
|
+
|
|
45
|
+
/** FNV-1a 64 位 → 16 位 hex(BigInt 纯实现,无 node:crypto 依赖,可单测)。 */
|
|
46
|
+
export function fnv1a64(s: string): string {
|
|
47
|
+
let h = 0xcbf29ce484222325n;
|
|
48
|
+
for (let i = 0; i < s.length; i++) {
|
|
49
|
+
h ^= BigInt(s.charCodeAt(i));
|
|
50
|
+
h = (h * 0x100000001b3n) & 0xffffffffffffffffn;
|
|
51
|
+
}
|
|
52
|
+
return h.toString(16).padStart(16, '0');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function lockTokenKey(normPath: string): string {
|
|
56
|
+
return `${LOCK_TOKEN_PREFIX}${fnv1a64(normPath)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function lockTokenValue(normPath: string, paneId: string): string {
|
|
60
|
+
return `${paneId}|${normPath}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseLockTokenValue(v: string): { holderPaneId: string; path: string } | null {
|
|
64
|
+
const i = v.indexOf('|');
|
|
65
|
+
if (i <= 0 || i === v.length - 1) return null;
|
|
66
|
+
return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** token 键是否属于锁命名空间。 */
|
|
70
|
+
export function isLockTokenKey(key: string): boolean {
|
|
71
|
+
return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── 工具路径提取 ─────────────────────────────────────────────────── */
|
|
75
|
+
|
|
76
|
+
export function writePathsOfTool(toolName: string, input: unknown): string[] {
|
|
77
|
+
if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
|
|
78
|
+
const p = (input as { path?: unknown } | null | undefined)?.path;
|
|
79
|
+
return typeof p === 'string' && p.trim() ? [p] : [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── 冲突判定(value 解码持有人;同 pane 重入放行) ───────────────── */
|
|
83
|
+
|
|
84
|
+
export function findLockConflict(
|
|
85
|
+
agents: readonly LockAgentView[],
|
|
86
|
+
ownPaneId: string,
|
|
87
|
+
normPath: string,
|
|
88
|
+
): { holderPaneId: string } | null {
|
|
89
|
+
const key = lockTokenKey(normPath);
|
|
90
|
+
for (const a of agents) {
|
|
91
|
+
const v = a.tokens[key];
|
|
92
|
+
if (typeof v !== 'string' || !v) continue;
|
|
93
|
+
const parsed = parseLockTokenValue(v);
|
|
94
|
+
if (parsed && parsed.holderPaneId !== ownPaneId) return { holderPaneId: parsed.holderPaneId };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── 决策 ─────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
export type WriteGuardPlan =
|
|
102
|
+
| { kind: 'skip' }
|
|
103
|
+
| { kind: 'pass'; paths: string[] }
|
|
104
|
+
| { kind: 'warn'; paths: string[]; holderPaneId: string; warning: string }
|
|
105
|
+
| { kind: 'block'; paths: string[]; holderPaneId: string; reason: string };
|
|
106
|
+
|
|
107
|
+
export function planWriteGuard(opts: {
|
|
108
|
+
toolName: string;
|
|
109
|
+
input: unknown;
|
|
110
|
+
agents: readonly LockAgentView[];
|
|
111
|
+
ownPaneId: string;
|
|
112
|
+
cwd: string;
|
|
113
|
+
hard: boolean;
|
|
114
|
+
}): WriteGuardPlan {
|
|
115
|
+
const raw = writePathsOfTool(opts.toolName, opts.input);
|
|
116
|
+
if (raw.length === 0) return { kind: 'skip' };
|
|
117
|
+
const paths = raw.map((p) => normalizeLockPath(p, opts.cwd));
|
|
118
|
+
for (const norm of paths) {
|
|
119
|
+
const conflict = findLockConflict(opts.agents, opts.ownPaneId, norm);
|
|
120
|
+
if (!conflict) continue;
|
|
121
|
+
if (opts.hard) {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'block',
|
|
124
|
+
paths,
|
|
125
|
+
holderPaneId: conflict.holderPaneId,
|
|
126
|
+
reason: `file locked by pane ${conflict.holderPaneId}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
kind: 'warn',
|
|
131
|
+
paths,
|
|
132
|
+
holderPaneId: conflict.holderPaneId,
|
|
133
|
+
warning: formatConflictWarning(norm, conflict.holderPaneId),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { kind: 'pass', paths };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ── token 构造(分批在 adapter) ─────────────────────────────────── */
|
|
140
|
+
|
|
141
|
+
export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
|
|
142
|
+
const out: Record<string, string> = {};
|
|
143
|
+
for (const p of normPaths) out[lockTokenKey(p)] = lockTokenValue(p, paneId);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function releaseTokensFor(normPaths: readonly string[]): Record<string, null> {
|
|
148
|
+
const out: Record<string, null> = {};
|
|
149
|
+
for (const p of normPaths) out[lockTokenKey(p)] = null;
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatConflictWarning(normPath: string, holderPaneId: string): string {
|
|
154
|
+
return `⚠️ write conflict: ${normPath} is locked by pane ${holderPaneId} (it edited this file and has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes.`;
|
|
155
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档2 Week2-3:manifest 三态合成(C7 v2;2026-08-18)。
|
|
3
|
+
*
|
|
4
|
+
* `manifest.tools = (roleBaseline ∪ modelSuggested) \ {tool | rule(tool) = deny}`
|
|
5
|
+
* `manifest.permissions[tool] = rule(tool) ?? rule['*'] ?? 'allow'`
|
|
6
|
+
*
|
|
7
|
+
* 规则最高优先(安全护栏不可被模型建议绕过);deny 排除、ask 标记、allow 放行;
|
|
8
|
+
* 合成后为空 = 配置错误(EMPTY_MANIFEST,防 deny-all trap,照抄 DSH 纪律);
|
|
9
|
+
* 基线为空 = INVALID_ROLE_CONFIG(协调工具强制,档案级校验在 role-manifest.ts)。
|
|
10
|
+
* 排序确定(便于测试与缓存比较)。
|
|
11
|
+
*/
|
|
12
|
+
import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
|
|
13
|
+
import { loadRoleConfig } from './role-loader.ts';
|
|
14
|
+
|
|
15
|
+
export interface ManifestSources {
|
|
16
|
+
roleBaseline: readonly string[];
|
|
17
|
+
modelSuggested: readonly string[];
|
|
18
|
+
rulePermissions: Record<string, PermissionAction>;
|
|
19
|
+
/** D82:未知工具姿态(缺省 deny)。 */
|
|
20
|
+
unknownTools?: UnknownToolStance;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ComposedManifest {
|
|
24
|
+
tools: string[];
|
|
25
|
+
permissions: Record<string, PermissionAction>;
|
|
26
|
+
/** D82:解析后的未知工具姿态(缺省 deny)。 */
|
|
27
|
+
unknownTools: UnknownToolStance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ManifestError extends Error {
|
|
31
|
+
readonly code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG';
|
|
32
|
+
constructor(code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG', message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'ManifestError';
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function composeManifest(sources: ManifestSources): ComposedManifest {
|
|
40
|
+
const baseline = sources.roleBaseline.filter((t): t is string => typeof t === 'string' && t.trim() !== '');
|
|
41
|
+
if (baseline.length === 0) {
|
|
42
|
+
throw new ManifestError(
|
|
43
|
+
'INVALID_ROLE_CONFIG',
|
|
44
|
+
'INVALID_ROLE_CONFIG: 基线工具不能为空,至少需包含 todo_write 和 ask_user_question',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const candidates = new Set<string>([
|
|
48
|
+
...baseline,
|
|
49
|
+
...sources.modelSuggested.filter((t): t is string => typeof t === 'string' && t.trim() !== ''),
|
|
50
|
+
]);
|
|
51
|
+
const rules = sources.rulePermissions ?? {};
|
|
52
|
+
const defaultAction: PermissionAction = rules['*'] ?? 'allow';
|
|
53
|
+
const stance: UnknownToolStance = sources.unknownTools ?? 'deny';
|
|
54
|
+
|
|
55
|
+
const tools: string[] = [];
|
|
56
|
+
const permissions: Record<string, PermissionAction> = {};
|
|
57
|
+
// D82:规则键全量并入 permissions(含 deny 与基线外的排除族)——
|
|
58
|
+
// stance=allow 下闸门/可见层仍能对排除族持禁令(deny 记忆不丢)。
|
|
59
|
+
for (const [k, v] of Object.entries(rules)) {
|
|
60
|
+
if (k === '*') continue; // 通配是默认值不是逐工具记录
|
|
61
|
+
permissions[k] = v;
|
|
62
|
+
}
|
|
63
|
+
for (const tool of candidates) {
|
|
64
|
+
const action = rules[tool] ?? defaultAction;
|
|
65
|
+
permissions[tool] = action;
|
|
66
|
+
if (action === 'deny') continue; // 安全护栏:不进 manifest.tools
|
|
67
|
+
tools.push(tool);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (tools.length === 0) {
|
|
71
|
+
throw new ManifestError(
|
|
72
|
+
'EMPTY_MANIFEST',
|
|
73
|
+
`裁剪后 manifest 为空(deny-all trap?)。基线: ${baseline.join(', ') || '(空)'};` +
|
|
74
|
+
`模型建议: ${sources.modelSuggested.join(', ') || '(无)'};` +
|
|
75
|
+
`规则: ${JSON.stringify(rules)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
tools.sort(); // 确定性
|
|
80
|
+
return { tools, permissions, unknownTools: stance };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 端到端:档案名 → loadRoleConfig → 合成(显式 tools/rules 即全部语义;
|
|
85
|
+
* roleType 自动约束已随 WS-D8 移除——"不配发即禁止"由 tools 列表本身表达)。
|
|
86
|
+
* master 侧 subagent 工具的调用缝;loadRole 可注入(测试替身)。
|
|
87
|
+
*/
|
|
88
|
+
export function composeForRole(
|
|
89
|
+
roleName: string,
|
|
90
|
+
modelSuggested: readonly string[],
|
|
91
|
+
opts?: { loadRole?: typeof loadRoleConfig; loadRoleOpts?: Parameters<typeof loadRoleConfig>[1] },
|
|
92
|
+
): { role: RoleManifest; manifest: ComposedManifest } {
|
|
93
|
+
const load = opts?.loadRole ?? loadRoleConfig;
|
|
94
|
+
const role = load(roleName, opts?.loadRoleOpts);
|
|
95
|
+
const manifest = composeManifest({
|
|
96
|
+
roleBaseline: role.manifest.tools,
|
|
97
|
+
modelSuggested,
|
|
98
|
+
rulePermissions: role.manifest.rules ?? { '*': 'allow' },
|
|
99
|
+
unknownTools: role.manifest.unknownTools,
|
|
100
|
+
});
|
|
101
|
+
return { role, manifest };
|
|
102
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D92 结算通知折叠器(followup 堆积修复的纯函数半)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:结算通知原走 pi.sendUserMessage(deliverAs:'followUp')——pi 语义是
|
|
5
|
+
* 「agent 没有更多 tool call 时才投递」= 整个 run 结束才回填。master 长任务
|
|
6
|
+
* 执行中 N 个 subagent 结算/失败重启,通知全攒在队列里,run 结束瞬间洪水灌入。
|
|
7
|
+
*
|
|
8
|
+
* 修复分两半(另一半在 index.ts 接线):
|
|
9
|
+
* - 忙时入扩展自己的缓冲,turn_end(LLM 迭代边界)以 steer 折叠批量注入;
|
|
10
|
+
* - 单次注入最多展示 max 条全文,其余收进尾行(全量走 history 台账/list_agents)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 折叠展示的最大条数(用户拍板:最多 3 条)。 */
|
|
14
|
+
export const NOTICE_MAX_SHOWN = 3;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 把一批通知折叠成单条消息体。
|
|
18
|
+
* - 空 → null(不注入)
|
|
19
|
+
* - ≤max 条 → 原文逐条(\n\n 连接),不套任何包装(单条时与旧格式逐字节一致)
|
|
20
|
+
* - >max 条 → 前 max 条原文 + 尾行汇总(指向 history 台账 / list_agents 查全量)
|
|
21
|
+
*/
|
|
22
|
+
export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
|
|
23
|
+
if (contents.length === 0) return null;
|
|
24
|
+
if (contents.length <= max) return contents.join('\n\n');
|
|
25
|
+
const hidden = contents.length - max;
|
|
26
|
+
const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 resume_subagent 工具描述);在跑代理用 list_agents 查看。`;
|
|
27
|
+
return [...contents.slice(0, max), tail].join('\n\n');
|
|
28
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pane todo → herdr 窗格头 / 侧栏投影(M22,D62/D68)。
|
|
3
|
+
*
|
|
4
|
+
* 权威仍在 pi 会话 JSONL;这里只把快照收成 title + blocked 徽标。
|
|
5
|
+
* herdr 把 title / state_label 截到 80 字符——本地先裁,避免半截 emoji。
|
|
6
|
+
*/
|
|
7
|
+
import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
|
|
8
|
+
|
|
9
|
+
/** herdr 对 title / state_label 的字符上限(官方文档 + schema 实测)。 */
|
|
10
|
+
export const TITLE_MAX = 80;
|
|
11
|
+
/** 空列表:调用方发 clear_title,不写空串。 */
|
|
12
|
+
export const TITLE_EMPTY = null;
|
|
13
|
+
/**
|
|
14
|
+
* state_labels 合法键只有 idle|working|blocked|done|unknown
|
|
15
|
+
* (herdr socket-api;WIRE 早期 `todo` 被拒 invalid_state_label)。
|
|
16
|
+
*/
|
|
17
|
+
export const BLOCKED_LABEL_KEY = 'blocked';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* D93:todo 摘要的侧边栏 custom token 名(herdr `[ui.sidebar.agents]` 里以 `$pi-todo` 引用)。
|
|
21
|
+
* 值与 pane title 同源同格式(`▶i ○p ■b ✓c (N/M) · activity`)。
|
|
22
|
+
*/
|
|
23
|
+
export const SIDEBAR_TODO_TOKEN = 'pi-todo';
|
|
24
|
+
/** D95:ask_user_question 等待标志 token 名(workbench 热力分级读它区分 ask vs block)。 */
|
|
25
|
+
export const SIDEBAR_ASK_TOKEN = 'pi-ask';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* D93:构造 report_metadata 的 pi-todo token 补丁。
|
|
29
|
+
* 有 title → 值 = title(侧边栏可排 `$pi-todo`);无 todo → 空串(herdr patch 语义删除键,不留旧摘要)。
|
|
30
|
+
*
|
|
31
|
+
* D96 修正:**不再合并 stale**——stale(16 个 null)是一次性清理(reportMetadata 单独发),
|
|
32
|
+
* 合并会使总数 17 > herdr tokens maxProperties=16 → 整个请求被拒 → title/tokens 全丢
|
|
33
|
+
* (用户实机:pane title 消失 + 侧边栏 token 消失,D93 回归根因)。
|
|
34
|
+
*/
|
|
35
|
+
export function sidebarTodoTokens(title: string | null): Record<string, string> {
|
|
36
|
+
return { [SIDEBAR_TODO_TOKEN]: title ?? '' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STALE_CHUNK_COUNT = 15;
|
|
40
|
+
|
|
41
|
+
function clipTitle(s: string): string {
|
|
42
|
+
return s.length <= TITLE_MAX ? s : s.slice(0, TITLE_MAX);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `▶i ○p ■b ✓c (N/M [~eta]) · <activity>`(D91 图标统一:黑白字符四件套)。
|
|
47
|
+
* activity = 第一条 in_progress,否则 fallbackDescription,再否则只报计数。
|
|
48
|
+
* 空列表 → null。progressSuffix(M16)= formatProgressSuffix 产物。
|
|
49
|
+
*/
|
|
50
|
+
export function formatPaneTitle(
|
|
51
|
+
items: readonly TodoItem[],
|
|
52
|
+
fallbackDescription?: string | null,
|
|
53
|
+
opts?: { progressSuffix?: string | null },
|
|
54
|
+
): string | null {
|
|
55
|
+
if (items.length === 0) return TITLE_EMPTY;
|
|
56
|
+
const c = countTodos(items);
|
|
57
|
+
const activity =
|
|
58
|
+
items.find((it) => it.status === 'in_progress')?.content
|
|
59
|
+
?? (fallbackDescription?.trim() ? fallbackDescription.trim() : null);
|
|
60
|
+
const head = `▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`;
|
|
61
|
+
const suffix = opts?.progressSuffix?.trim();
|
|
62
|
+
const withProgress = suffix ? `${head} (${suffix})` : head;
|
|
63
|
+
return clipTitle(activity ? `${withProgress} · ${activity}` : withProgress);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 第一条 blocked 的 blocker(缺则用 content);无 blocked → null。 */
|
|
67
|
+
export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
|
|
68
|
+
const blocked = items.find((it) => it.status === 'blocked');
|
|
69
|
+
if (!blocked) return null;
|
|
70
|
+
const text = blocked.blocker?.trim() || blocked.content;
|
|
71
|
+
return clipTitle(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 升级后首次上报用来清掉旧 16-key 分块(herdr tokens 按 key 合并,
|
|
76
|
+
* 不写 null 会残留——M13b)。
|
|
77
|
+
*/
|
|
78
|
+
export function staleTokenClearance(): Record<string, null> {
|
|
79
|
+
const tokens: Record<string, null> = { [PI_HERDR_META_KEY]: null };
|
|
80
|
+
for (let i = 0; i < STALE_CHUNK_COUNT; i++) {
|
|
81
|
+
tokens[`${PI_HERDR_META_KEY}-${i}`] = null;
|
|
82
|
+
}
|
|
83
|
+
return tokens;
|
|
84
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档1 pi-surface(D79):包住 pi ExtensionAPI 的注册面。
|
|
3
|
+
*
|
|
4
|
+
* 背景(源码实证 pi dist/core/extensions/loader.js):
|
|
5
|
+
* - `registerTool(...): void`、`on(...): void` —— **pi 不提供反注册函数**;
|
|
6
|
+
* - `extension.tools.set(tool.name, …)` —— 工具表按名覆盖:同名重注册 = 覆盖
|
|
7
|
+
* (hmr 热换后新版本自然顶掉旧条目);
|
|
8
|
+
* - 事件监听是列表 —— 重注册会**双触发**(热换泄漏面)。
|
|
9
|
+
*
|
|
10
|
+
* 补偿策略(tombstone,世代化):每个 core 模块经 `forModule(key)` 拿到scoped 面,
|
|
11
|
+
* 其 registerTool/on 都包 alive 旗标。**世代规则**(d87 修,源读 cordis-plugin-hmr):
|
|
12
|
+
* hmr partialReload 真实时序 = 重挂新插件**之后**才 emit('hmr/reload') → bootstrap
|
|
13
|
+
* hook 才 disposeKey——若按「翻当前组」实现,翻掉的是新版本正引用的组(死到货)。
|
|
14
|
+
* 故:挂载即自翻同 key 更旧世代(新插件体执行 = 旧版本确定已死);账本 disposer
|
|
15
|
+
* 只收割「登记世代及之前」,登记后新挂的豁免;disposeModule(显式)翻全部。
|
|
16
|
+
*
|
|
17
|
+
* 与 dispose 账本(D80⑤ 修正后定位)咬合:构造时传入 ledger,forModule
|
|
18
|
+
* 自动登记 `disposeModule(key)`——hmr/reload 按文件名补偿即翻旗。
|
|
19
|
+
*/
|
|
20
|
+
import type { DisposeLedger } from './ledger.ts';
|
|
21
|
+
|
|
22
|
+
/** pi 的 ToolResult 最小形态(inert 返回用)。 */
|
|
23
|
+
const INERT_TOOL_RESULT = {
|
|
24
|
+
content: [{ type: 'text' as const, text: 'Error: tool module disposed (hot-reloaded away); the new version has re-registered it.' }],
|
|
25
|
+
details: {},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** 模块级 scoped 面:registerTool/on 都带 alive 墓碑;registerCommand 走 Map 覆盖(pi 实测语义,无需墓碑)。 */
|
|
29
|
+
export interface ScopedSurface {
|
|
30
|
+
registerTool(def: Record<string, unknown> & { name: string }): void;
|
|
31
|
+
registerCommand(name: string, options: Record<string, unknown>): void;
|
|
32
|
+
on(event: string, handler: (...args: unknown[]) => unknown): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Group {
|
|
36
|
+
alive: boolean;
|
|
37
|
+
/** 挂载世代号(单射递增;账本 disposer 按此判定收割范围)。 */
|
|
38
|
+
epoch: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** pi 注册面代理(D79)。raw 直通非注册方法(append/exec/setActiveTools…)。 */
|
|
42
|
+
export class PiSurface<P extends object> {
|
|
43
|
+
private groups = new Map<string, Group>();
|
|
44
|
+
/** 每 key 的世代列表(挂载即自翻更旧 → 活口至多 1 个,死口待账本回收)。 */
|
|
45
|
+
private generations = new Map<string, Group[]>();
|
|
46
|
+
private epochCounter = 0;
|
|
47
|
+
/** 账本登记世代(disposer 只收割 epoch ≤ 此值;消费即删,下次挂载补登)。 */
|
|
48
|
+
private ledgerEntryEpoch = new Map<string, number>();
|
|
49
|
+
private readonly pi: P;
|
|
50
|
+
private readonly ledger?: DisposeLedger;
|
|
51
|
+
|
|
52
|
+
constructor(pi: P, ledger?: DisposeLedger) {
|
|
53
|
+
this.pi = pi;
|
|
54
|
+
this.ledger = ledger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 原始 pi(不经包装的直通面;已迁移模块不应再用其注册)。 */
|
|
58
|
+
get raw(): P {
|
|
59
|
+
return this.pi;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 拿一个模块的 scoped 注册面。**每次调用 = 新世代**:挂载即自翻同 key 旧世代
|
|
64
|
+
* (新插件体执行意味着旧版本已死——hmr registry.delete 与手动重挂两态统一)。
|
|
65
|
+
* 账本登记按 key 单例(消费后下次挂载补登)。
|
|
66
|
+
*/
|
|
67
|
+
forModule(key: string): ScopedSurface {
|
|
68
|
+
this.epochCounter += 1;
|
|
69
|
+
const epoch = this.epochCounter;
|
|
70
|
+
for (const g of this.generations.get(key) ?? []) g.alive = false;
|
|
71
|
+
const group: Group = { alive: true, epoch };
|
|
72
|
+
this.generations.set(key, [group]);
|
|
73
|
+
this.groups.set(key, group);
|
|
74
|
+
if (this.ledger && !this.ledgerEntryEpoch.has(key)) {
|
|
75
|
+
this.ledgerEntryEpoch.set(key, epoch);
|
|
76
|
+
this.ledger.add(key, () => {
|
|
77
|
+
const bound = this.ledgerEntryEpoch.get(key) ?? epoch;
|
|
78
|
+
this.ledgerEntryEpoch.delete(key);
|
|
79
|
+
this.generations.set(
|
|
80
|
+
key,
|
|
81
|
+
(this.generations.get(key) ?? []).filter((g) => {
|
|
82
|
+
if (g.epoch <= bound) {
|
|
83
|
+
g.alive = false;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
registerTool: (def) => {
|
|
93
|
+
const original = def.execute as ((...a: unknown[]) => unknown) | undefined;
|
|
94
|
+
const wrapped = original
|
|
95
|
+
? async (...a: unknown[]) => {
|
|
96
|
+
if (!group!.alive) return INERT_TOOL_RESULT;
|
|
97
|
+
return original(...a);
|
|
98
|
+
}
|
|
99
|
+
: undefined;
|
|
100
|
+
(this.pi as { registerTool?: (d: unknown) => void }).registerTool?.(
|
|
101
|
+
wrapped ? { ...def, execute: wrapped } : def,
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
registerCommand: (name, options) => {
|
|
105
|
+
// pi 的 commands 是 Map<name>(dist 实测 .set)——同名重注册覆盖,热换安全;
|
|
106
|
+
// handler 包墓碑仅为对称保险(覆盖语义下旧 handler 不会再被调)。
|
|
107
|
+
const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
|
|
108
|
+
const wrapped = handler
|
|
109
|
+
? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
|
|
110
|
+
: undefined;
|
|
111
|
+
(this.pi as { registerCommand?: (n: string, o: Record<string, unknown>) => void })
|
|
112
|
+
.registerCommand?.(name, wrapped ? { ...options, handler: wrapped } : options);
|
|
113
|
+
},
|
|
114
|
+
on: (event, handler) => {
|
|
115
|
+
const wrapped = (...a: unknown[]) => (group!.alive ? handler(...a) : undefined);
|
|
116
|
+
(this.pi as { on?: (e: string, h: (...a: unknown[]) => unknown) => void }).on?.(event, wrapped);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 翻墓碑(显式拆除,hmr 补偿不走这里):翻该 key 全部世代;组不存在返回 false。 */
|
|
122
|
+
disposeModule(key: string): boolean {
|
|
123
|
+
const had = this.groups.has(key);
|
|
124
|
+
for (const g of this.generations.get(key) ?? []) g.alive = false;
|
|
125
|
+
this.generations.delete(key);
|
|
126
|
+
this.groups.delete(key);
|
|
127
|
+
this.ledgerEntryEpoch.delete(key);
|
|
128
|
+
return had;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 组数(测试/诊断)。 */
|
|
132
|
+
get moduleCount(): number {
|
|
133
|
+
return this.groups.size;
|
|
134
|
+
}
|
|
135
|
+
}
|