pi-pier 0.1.5 → 0.2.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/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/ledger.ts
CHANGED
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Disposal ledger shared by HMR and session shutdown (D80⑤ + D79).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* HMR partial reload can skip old fiber effect disposers, so process-level resources
|
|
5
|
+
* such as intervals, pipe servers, and watchers would leak without compensation.
|
|
6
|
+
* Core modules register those resources by module key; reload disposes matching keys,
|
|
7
|
+
* while session_shutdown disposes everything.
|
|
8
8
|
*
|
|
9
|
-
* D79
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 纯逻辑零依赖;接缝在 bootstrap(hmr 事件)与 index(session_shutdown)。
|
|
9
|
+
* D79 reuses this mechanism for pi-surface registration cleanup; logical keys need not
|
|
10
|
+
* be file paths. The ledger stays dependency-free and connects through bootstrap/index.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import { fileURLToPath } from 'node:url';
|
|
16
14
|
|
|
17
|
-
/**
|
|
15
|
+
/** Normalize file URLs and paths to comparable absolute keys for HMR matching. */
|
|
18
16
|
export function normalizeModuleKey(spec: string): string {
|
|
19
17
|
let s = String(spec);
|
|
20
18
|
try {
|
|
21
19
|
if (s.startsWith('file://')) s = fileURLToPath(s);
|
|
22
20
|
} catch {
|
|
23
|
-
/*
|
|
21
|
+
/* Keep malformed URLs unchanged so disposal can still use the caller's key. */
|
|
24
22
|
}
|
|
25
23
|
return s.replace(/\\/g, '/');
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
export class DisposeLedger {
|
|
29
|
-
/**
|
|
27
|
+
/** LIFO order matches Cordis effect disposal semantics. */
|
|
30
28
|
private order: Array<{ key: string; dispose: () => void }> = [];
|
|
31
29
|
|
|
32
|
-
/**
|
|
30
|
+
/** Register a resource and return cancellation so self-disposal cannot run twice. */
|
|
33
31
|
add(spec: string, dispose: () => void): () => void {
|
|
34
32
|
const entry = { key: normalizeModuleKey(spec), dispose };
|
|
35
33
|
this.order.push(entry);
|
|
@@ -39,7 +37,7 @@ export class DisposeLedger {
|
|
|
39
37
|
};
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
/**
|
|
40
|
+
/** HMR compensation disposes only matching keys in LIFO order. */
|
|
43
41
|
disposeKey(spec: string | string[]): number {
|
|
44
42
|
const keys = new Set((Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey));
|
|
45
43
|
let n = 0;
|
|
@@ -47,19 +45,19 @@ export class DisposeLedger {
|
|
|
47
45
|
if (keys.has(this.order[i].key)) {
|
|
48
46
|
const { dispose } = this.order[i];
|
|
49
47
|
this.order.splice(i, 1);
|
|
50
|
-
try { dispose(); } catch { /*
|
|
48
|
+
try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
|
|
51
49
|
n++;
|
|
52
50
|
}
|
|
53
51
|
}
|
|
54
52
|
return n;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
/**
|
|
55
|
+
/** Dispose every entry in LIFO order during session_shutdown. */
|
|
58
56
|
disposeAll(): number {
|
|
59
57
|
let n = 0;
|
|
60
58
|
while (this.order.length > 0) {
|
|
61
59
|
const { dispose } = this.order.pop()!;
|
|
62
|
-
try { dispose(); } catch { /*
|
|
60
|
+
try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
|
|
63
61
|
n++;
|
|
64
62
|
}
|
|
65
63
|
return n;
|
package/src/lock-core.ts
CHANGED
|
@@ -1,38 +1,24 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Why: Preserve the established compatibility and safety behavior (D3, M18, S2). */
|
|
16
2
|
import { resolve, sep } from 'node:path';
|
|
17
3
|
|
|
18
|
-
/**
|
|
4
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
19
5
|
export const WRITE_LOCK_ENV = 'PI_HERDR_WRITE_LOCK';
|
|
20
|
-
/**
|
|
6
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
21
7
|
export const WRITE_TOOLS = ['write', 'edit'] as const;
|
|
22
|
-
/**
|
|
8
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
23
9
|
export const LOCK_TOKEN_PREFIX = 'lock-';
|
|
24
|
-
/**
|
|
10
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
25
11
|
export const LOCK_BATCH_LIMIT = 16;
|
|
26
|
-
/**
|
|
12
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
27
13
|
export const LOCK_TTL_MS = 60 * 60 * 1000;
|
|
28
14
|
|
|
29
|
-
/**
|
|
15
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
30
16
|
export interface LockAgentView {
|
|
31
17
|
paneId: string;
|
|
32
18
|
tokens: Record<string, string | null>;
|
|
33
19
|
}
|
|
34
20
|
|
|
35
|
-
|
|
21
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
36
22
|
|
|
37
23
|
export function normalizeLockPath(p: string, cwd: string): string {
|
|
38
24
|
const abs = resolve(cwd, p);
|
|
@@ -40,9 +26,9 @@ export function normalizeLockPath(p: string, cwd: string): string {
|
|
|
40
26
|
return unified.toLowerCase().replace(/\/+$/, '');
|
|
41
27
|
}
|
|
42
28
|
|
|
43
|
-
|
|
29
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
44
30
|
|
|
45
|
-
/**
|
|
31
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
46
32
|
export function fnv1a64(s: string): string {
|
|
47
33
|
let h = 0xcbf29ce484222325n;
|
|
48
34
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -66,12 +52,12 @@ export function parseLockTokenValue(v: string): { holderPaneId: string; path: st
|
|
|
66
52
|
return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
|
|
67
53
|
}
|
|
68
54
|
|
|
69
|
-
/**
|
|
55
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
70
56
|
export function isLockTokenKey(key: string): boolean {
|
|
71
57
|
return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
|
|
72
58
|
}
|
|
73
59
|
|
|
74
|
-
|
|
60
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
75
61
|
|
|
76
62
|
export function writePathsOfTool(toolName: string, input: unknown): string[] {
|
|
77
63
|
if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
|
|
@@ -79,7 +65,7 @@ export function writePathsOfTool(toolName: string, input: unknown): string[] {
|
|
|
79
65
|
return typeof p === 'string' && p.trim() ? [p] : [];
|
|
80
66
|
}
|
|
81
67
|
|
|
82
|
-
|
|
68
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
83
69
|
|
|
84
70
|
export function findLockConflict(
|
|
85
71
|
agents: readonly LockAgentView[],
|
|
@@ -96,7 +82,7 @@ export function findLockConflict(
|
|
|
96
82
|
return null;
|
|
97
83
|
}
|
|
98
84
|
|
|
99
|
-
|
|
85
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
100
86
|
|
|
101
87
|
export type WriteGuardPlan =
|
|
102
88
|
| { kind: 'skip' }
|
|
@@ -136,7 +122,7 @@ export function planWriteGuard(opts: {
|
|
|
136
122
|
return { kind: 'pass', paths };
|
|
137
123
|
}
|
|
138
124
|
|
|
139
|
-
|
|
125
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
140
126
|
|
|
141
127
|
export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
|
|
142
128
|
const out: Record<string, string> = {};
|
package/src/manifest-compose.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Why: Preserve the established compatibility and safety behavior (C7). */
|
|
12
2
|
import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
|
|
13
3
|
import { loadRoleConfig } from './role-loader.ts';
|
|
14
4
|
|
|
@@ -16,14 +6,14 @@ export interface ManifestSources {
|
|
|
16
6
|
roleBaseline: readonly string[];
|
|
17
7
|
modelSuggested: readonly string[];
|
|
18
8
|
rulePermissions: Record<string, PermissionAction>;
|
|
19
|
-
/** D82
|
|
9
|
+
/** Why: Preserve the established compatibility and safety behavior (D82). */
|
|
20
10
|
unknownTools?: UnknownToolStance;
|
|
21
11
|
}
|
|
22
12
|
|
|
23
13
|
export interface ComposedManifest {
|
|
24
14
|
tools: string[];
|
|
25
15
|
permissions: Record<string, PermissionAction>;
|
|
26
|
-
/** D82
|
|
16
|
+
/** Why: Preserve the established compatibility and safety behavior (D82). */
|
|
27
17
|
unknownTools: UnknownToolStance;
|
|
28
18
|
}
|
|
29
19
|
|
|
@@ -54,16 +44,16 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
|
|
|
54
44
|
|
|
55
45
|
const tools: string[] = [];
|
|
56
46
|
const permissions: Record<string, PermissionAction> = {};
|
|
57
|
-
//
|
|
58
|
-
//
|
|
47
|
+
// Why: Preserve the established compatibility and safety behavior (D82).
|
|
48
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
59
49
|
for (const [k, v] of Object.entries(rules)) {
|
|
60
|
-
if (k === '*') continue; //
|
|
50
|
+
if (k === '*') continue; // Why: Preserve the established compatibility and safety behavior.
|
|
61
51
|
permissions[k] = v;
|
|
62
52
|
}
|
|
63
53
|
for (const tool of candidates) {
|
|
64
54
|
const action = rules[tool] ?? defaultAction;
|
|
65
55
|
permissions[tool] = action;
|
|
66
|
-
if (action === 'deny') continue; //
|
|
56
|
+
if (action === 'deny') continue; // Why: Preserve the established compatibility and safety behavior.
|
|
67
57
|
tools.push(tool);
|
|
68
58
|
}
|
|
69
59
|
|
|
@@ -76,15 +66,11 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
|
|
|
76
66
|
);
|
|
77
67
|
}
|
|
78
68
|
|
|
79
|
-
tools.sort(); //
|
|
69
|
+
tools.sort(); // Why: Preserve the established compatibility and safety behavior.
|
|
80
70
|
return { tools, permissions, unknownTools: stance };
|
|
81
71
|
}
|
|
82
72
|
|
|
83
|
-
/**
|
|
84
|
-
* 端到端:档案名 → loadRoleConfig → 合成(显式 tools/rules 即全部语义;
|
|
85
|
-
* roleType 自动约束已随 WS-D8 移除——"不配发即禁止"由 tools 列表本身表达)。
|
|
86
|
-
* master 侧 subagent 工具的调用缝;loadRole 可注入(测试替身)。
|
|
87
|
-
*/
|
|
73
|
+
/** Why: Preserve the established compatibility and safety behavior (WS-D8). */
|
|
88
74
|
export function composeForRole(
|
|
89
75
|
roleName: string,
|
|
90
76
|
modelSuggested: readonly string[],
|
package/src/notice-buffer.ts
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D92
|
|
2
|
+
* D92 settlement-notice collapser (pure half of the followup-flood fix).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 修复分两半(另一半在 index.ts 接线):
|
|
9
|
-
* - 忙时入扩展自己的缓冲,turn_end(LLM 迭代边界)以 steer 折叠批量注入;
|
|
10
|
-
* - 单次注入最多展示 max 条全文,其余收进尾行(全量走 history 台账/list_agents)。
|
|
4
|
+
* Why: `sendUserMessage(followUp)` only delivers when the agent has no more
|
|
5
|
+
* tool calls, so a long master run queues every settlement and floods at the
|
|
6
|
+
* end. Buffer while busy; collapse at turn_end. The other half is the index
|
|
7
|
+
* notice buffer.
|
|
11
8
|
*/
|
|
12
|
-
|
|
13
|
-
/** 折叠展示的最大条数(用户拍板:最多 3 条)。 */
|
|
14
9
|
export const NOTICE_MAX_SHOWN = 3;
|
|
15
10
|
|
|
16
11
|
/**
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
* - ≤max
|
|
20
|
-
* - >max
|
|
12
|
+
* Collapse a batch into one message body.
|
|
13
|
+
* - empty → null (do not inject)
|
|
14
|
+
* - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
|
|
15
|
+
* - >max → first max originals + a tail pointing at history / list_agents
|
|
21
16
|
*/
|
|
22
17
|
export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
|
|
23
18
|
if (contents.length === 0) return null;
|
package/src/pane-title.ts
CHANGED
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pane todo
|
|
2
|
+
* Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* herdr
|
|
4
|
+
* The pi session JSONL remains authoritative; this layer only condenses the snapshot into a title and blocked badge.
|
|
5
|
+
* herdr truncates title/state_label to 80 characters, so truncate locally to avoid cutting an emoji in half.
|
|
6
6
|
*/
|
|
7
7
|
import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
|
|
8
8
|
import { formatAge, isArchived } from './stale-core.ts';
|
|
9
9
|
|
|
10
|
-
/** herdr
|
|
10
|
+
/** herdr's character limit for title/state_label (from official documentation and schema measurements). */
|
|
11
11
|
export const TITLE_MAX = 80;
|
|
12
|
-
/**
|
|
12
|
+
/** Empty list: callers send clear_title rather than an empty string. */
|
|
13
13
|
export const TITLE_EMPTY = null;
|
|
14
14
|
/**
|
|
15
|
-
* state_labels
|
|
16
|
-
*
|
|
15
|
+
* Valid state_labels keys are only idle|working|blocked|done|unknown
|
|
16
|
+
* (herdr socket-api; the early WIRE `todo` value was rejected as invalid_state_label).
|
|
17
17
|
*/
|
|
18
18
|
export const BLOCKED_LABEL_KEY = 'blocked';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* D93
|
|
22
|
-
*
|
|
21
|
+
* D93: custom sidebar token for the todo summary (referenced as `$pi-todo` by herdr `[ui.sidebar.agents]`).
|
|
22
|
+
* Keep its value in the same format and source as the pane title (`▶i ○p ■b ✓c (N/M) · activity`).
|
|
23
23
|
*/
|
|
24
24
|
export const SIDEBAR_TODO_TOKEN = 'pi-todo';
|
|
25
|
-
/** D95
|
|
25
|
+
/** D95: token marking an ask_user_question wait (the workbench heat scale uses it to distinguish ask from block). */
|
|
26
26
|
export const SIDEBAR_ASK_TOKEN = 'pi-ask';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* D93
|
|
30
|
-
*
|
|
29
|
+
* D93: build the pi-todo token patch for report_metadata.
|
|
30
|
+
* A title becomes the token value so the sidebar can render `$pi-todo`; no todo becomes an empty string so herdr's
|
|
31
|
+
* patch semantics remove the key rather than retaining an old summary.
|
|
31
32
|
*
|
|
32
|
-
* D96
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* D96 correction: **do not merge stale**. Stale (16 nulls) is a one-time cleanup sent separately via reportMetadata;
|
|
34
|
+
* merging would make 17 entries exceed herdr's tokens maxProperties=16, rejecting the entire request and dropping
|
|
35
|
+
* both title and tokens (observed on the user's machine: pane title and sidebar token disappeared; D93 regression root cause).
|
|
35
36
|
*/
|
|
36
37
|
export function sidebarTodoTokens(title: string | null): Record<string, string> {
|
|
37
38
|
return { [SIDEBAR_TODO_TOKEN]: title ?? '' };
|
|
@@ -44,13 +45,13 @@ function clipTitle(s: string): string {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* `▶i ○p ■b ✓c (N/M [~eta]) · <activity
|
|
48
|
-
* activity
|
|
49
|
-
*
|
|
48
|
+
* `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four black-and-white status glyphs).
|
|
49
|
+
* activity is the first in_progress item, then fallbackDescription, then only the counts.
|
|
50
|
+
* Empty list → null. progressSuffix (M16) is produced by formatProgressSuffix.
|
|
50
51
|
*
|
|
51
|
-
*
|
|
52
|
-
* `✓N done <age
|
|
53
|
-
*
|
|
52
|
+
* Anti-freeze behavior (stale-core D): once all work is complete and the wall clock expires (archived), lower the
|
|
53
|
+
* dead list to `✓N done <age>` instead of presenting it as a fully weighted current-state view; report_agent's tool
|
|
54
|
+
* badge/activity line explains what is happening, while the todo token reports only the plan's actual state.
|
|
54
55
|
*/
|
|
55
56
|
export function formatPaneTitle(
|
|
56
57
|
items: readonly TodoItem[],
|
|
@@ -73,7 +74,7 @@ export function formatPaneTitle(
|
|
|
73
74
|
return clipTitle(activity ? `${withProgress} · ${activity}` : withProgress);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
+
/** First blocked item's blocker (fall back to content); no blocked item → null. */
|
|
77
78
|
export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
|
|
78
79
|
const blocked = items.find((it) => it.status === 'blocked');
|
|
79
80
|
if (!blocked) return null;
|
|
@@ -82,8 +83,7 @@ export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
|
-
*
|
|
86
|
-
* 不写 null 会残留——M13b)。
|
|
86
|
+
* The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls remain—M13b).
|
|
87
87
|
*/
|
|
88
88
|
export function staleTokenClearance(): Record<string, null> {
|
|
89
89
|
const tokens: Record<string, null> = { [PI_HERDR_META_KEY]: null };
|
package/src/pi-surface.ts
CHANGED
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Proxy the pi ExtensionAPI registration surface (D79).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (hmr 热换后新版本自然顶掉旧条目);
|
|
8
|
-
* - 事件监听是列表 —— 重注册会**双触发**(热换泄漏面)。
|
|
4
|
+
* pi exposes registration without unregister APIs: tools overwrite by name, but event
|
|
5
|
+
* listeners accumulate. HMR therefore needs tombstoned, generation-scoped wrappers so
|
|
6
|
+
* old listeners become inert while the latest generation remains active.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* hook 才 disposeKey——若按「翻当前组」实现,翻掉的是新版本正引用的组(死到货)。
|
|
14
|
-
* 故:挂载即自翻同 key 更旧世代(新插件体执行 = 旧版本确定已死);账本 disposer
|
|
15
|
-
* 只收割「登记世代及之前」,登记后新挂的豁免;disposeModule(显式)翻全部。
|
|
16
|
-
*
|
|
17
|
-
* 与 dispose 账本(D80⑤ 修正后定位)咬合:构造时传入 ledger,forModule
|
|
18
|
-
* 自动登记 `disposeModule(key)`——hmr/reload 按文件名补偿即翻旗。
|
|
8
|
+
* Because HMR emits reload after mounting the replacement (d87), mounting a key retires
|
|
9
|
+
* older generations immediately. The ledger then collects only generations registered
|
|
10
|
+
* through the reload boundary; explicit disposal retires every generation.
|
|
19
11
|
*/
|
|
20
12
|
import type { DisposeLedger } from './ledger.ts';
|
|
21
13
|
|
|
22
|
-
/** pi
|
|
14
|
+
/** Minimal pi ToolResult shape used when a disposed tool is invoked. */
|
|
23
15
|
const INERT_TOOL_RESULT = {
|
|
24
16
|
content: [{ type: 'text' as const, text: 'Error: tool module disposed (hot-reloaded away); the new version has re-registered it.' }],
|
|
25
17
|
details: {},
|
|
26
18
|
};
|
|
27
19
|
|
|
28
|
-
/**
|
|
20
|
+
/** Module-scoped surface: tombstone tools and events, while command replacement is safe because pi stores commands in a Map. */
|
|
29
21
|
export interface ScopedSurface {
|
|
30
22
|
registerTool(def: Record<string, unknown> & { name: string }): void;
|
|
31
23
|
registerCommand(name: string, options: Record<string, unknown>): void;
|
|
@@ -34,17 +26,17 @@ export interface ScopedSurface {
|
|
|
34
26
|
|
|
35
27
|
interface Group {
|
|
36
28
|
alive: boolean;
|
|
37
|
-
/**
|
|
29
|
+
/** Monotonic mount generation used by the ledger disposer to determine its collection boundary. */
|
|
38
30
|
epoch: number;
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
/** pi
|
|
33
|
+
/** Proxy for pi's registration surface (D79); non-registration methods (append/exec/setActiveTools, etc.) pass through raw. */
|
|
42
34
|
export class PiSurface<P extends object> {
|
|
43
35
|
private groups = new Map<string, Group>();
|
|
44
|
-
/**
|
|
36
|
+
/** Generation history per key; mounting retires older generations so at most one stays alive until ledger collection. */
|
|
45
37
|
private generations = new Map<string, Group[]>();
|
|
46
38
|
private epochCounter = 0;
|
|
47
|
-
/**
|
|
39
|
+
/** Ledger generation boundary; disposal collects epochs up to this value, then the next mount registers again. */
|
|
48
40
|
private ledgerEntryEpoch = new Map<string, number>();
|
|
49
41
|
private readonly pi: P;
|
|
50
42
|
private readonly ledger?: DisposeLedger;
|
|
@@ -54,15 +46,15 @@ export class PiSurface<P extends object> {
|
|
|
54
46
|
this.ledger = ledger;
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
/**
|
|
49
|
+
/** Raw pi surface, bypassing wrappers; migrated modules must not use it for registration. */
|
|
58
50
|
get raw(): P {
|
|
59
51
|
return this.pi;
|
|
60
52
|
}
|
|
61
53
|
|
|
62
54
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
55
|
+
* Get a module-scoped registration surface. Each call creates a new generation, retiring older
|
|
56
|
+
* generations for the same key so HMR replacement and manual remounting share one lifecycle.
|
|
57
|
+
* The ledger registers each key once and registers it again only after its entry is consumed.
|
|
66
58
|
*/
|
|
67
59
|
forModule(key: string): ScopedSurface {
|
|
68
60
|
this.epochCounter += 1;
|
|
@@ -102,8 +94,9 @@ export class PiSurface<P extends object> {
|
|
|
102
94
|
);
|
|
103
95
|
},
|
|
104
96
|
registerCommand: (name, options) => {
|
|
105
|
-
// pi
|
|
106
|
-
//
|
|
97
|
+
// pi stores commands as Map<name> (verified in the dist implementation), so same-name
|
|
98
|
+
// registration replaces safely; the handler tombstone is a symmetry safeguard even though
|
|
99
|
+
// replacement means the old handler will no longer be called.
|
|
107
100
|
const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
|
|
108
101
|
const wrapped = handler
|
|
109
102
|
? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
|
|
@@ -118,7 +111,7 @@ export class PiSurface<P extends object> {
|
|
|
118
111
|
};
|
|
119
112
|
}
|
|
120
113
|
|
|
121
|
-
/**
|
|
114
|
+
/** Retire all generations for a key during explicit disposal; return false when the key is absent. */
|
|
122
115
|
disposeModule(key: string): boolean {
|
|
123
116
|
const had = this.groups.has(key);
|
|
124
117
|
for (const g of this.generations.get(key) ?? []) g.alive = false;
|
|
@@ -128,7 +121,7 @@ export class PiSurface<P extends object> {
|
|
|
128
121
|
return had;
|
|
129
122
|
}
|
|
130
123
|
|
|
131
|
-
/**
|
|
124
|
+
/** Number of live module groups, for tests and diagnostics. */
|
|
132
125
|
get moduleCount(): number {
|
|
133
126
|
return this.groups.size;
|
|
134
127
|
}
|