pi-pier 0.1.5 → 0.2.1
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 +116 -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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-tab pane spawn, launch-line construction, and trust-flag decisions.
|
|
3
|
+
*
|
|
4
|
+
* Why: spawn mixed herdr layout I/O with the subagent tool body. Placement
|
|
5
|
+
* races (D26 mutex) and D86 worktree grouping belong in one adapter.
|
|
6
|
+
*/
|
|
7
|
+
import type { HerdrClientLike } from './herdr-client.ts';
|
|
8
|
+
import { pingUntilReady, pipeNameCandidates } from './pipe-channel.ts';
|
|
9
|
+
import { runtimePolicy } from './runtime-policy.ts';
|
|
10
|
+
import {
|
|
11
|
+
Semaphore,
|
|
12
|
+
buildLaunchLine,
|
|
13
|
+
buildLaunchParts,
|
|
14
|
+
classifyWorktreeZone,
|
|
15
|
+
isPathUnder,
|
|
16
|
+
planTabPlacement,
|
|
17
|
+
type TabPlacementPlan,
|
|
18
|
+
type WorktreeZone,
|
|
19
|
+
} from './subagent-core.ts';
|
|
20
|
+
import { parseShapeTree, pickGridSplit } from './core/grid-shape.ts';
|
|
21
|
+
import type { GitIo } from './subagent-git-io.ts';
|
|
22
|
+
|
|
23
|
+
export interface SpawnEnv {
|
|
24
|
+
paneId: string;
|
|
25
|
+
tabId: string;
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SpawnRuntime {
|
|
30
|
+
nodePath: string;
|
|
31
|
+
cliPath: string;
|
|
32
|
+
extPath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SpawnerHost {
|
|
36
|
+
client: HerdrClientLike;
|
|
37
|
+
env: SpawnEnv | null;
|
|
38
|
+
runtime: SpawnRuntime;
|
|
39
|
+
git: GitIo;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Spawner {
|
|
43
|
+
spawnPaneInTaskTab(
|
|
44
|
+
placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
|
|
45
|
+
cwd: string,
|
|
46
|
+
envOver: Record<string, string>,
|
|
47
|
+
launch: string,
|
|
48
|
+
): Promise<{ tabId: string; paneId: string; tabName: string }>;
|
|
49
|
+
launchLine(resumeFile?: string | null, roleModel?: string | null, approve?: boolean): string;
|
|
50
|
+
approveFor(cwd: string, masterCwd: string): Promise<boolean>;
|
|
51
|
+
waitSubReady(cwd: string, paneId: string): Promise<boolean>;
|
|
52
|
+
liveTabs(): Promise<Array<{ tabName: string; tabId: string }>>;
|
|
53
|
+
findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSpawner(h: SpawnerHost): Spawner {
|
|
57
|
+
const tabMutex = new Semaphore(1);
|
|
58
|
+
const readyTimeoutMs = runtimePolicy.readinessTimeoutMs;
|
|
59
|
+
|
|
60
|
+
async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
|
|
61
|
+
try {
|
|
62
|
+
const ws = h.env?.workspaceId ?? '';
|
|
63
|
+
return (await h.client.tabList())
|
|
64
|
+
.filter((t) => !ws || t.workspaceId === ws)
|
|
65
|
+
.map((t) => ({ tabName: t.label, tabId: t.tabId }));
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
|
|
72
|
+
return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
76
|
+
if (isPathUnder(cwd, masterCwd)) return true;
|
|
77
|
+
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
|
|
78
|
+
return zone.zone === 'worktree';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
|
|
82
|
+
return pingUntilReady(pipeNameCandidates(cwd, paneId), readyTimeoutMs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function spawnPaneInTaskTab(
|
|
86
|
+
placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
|
|
87
|
+
cwd: string,
|
|
88
|
+
envOver: Record<string, string>,
|
|
89
|
+
launch: string,
|
|
90
|
+
): Promise<{ tabId: string; paneId: string; tabName: string }> {
|
|
91
|
+
const release = await tabMutex.acquire();
|
|
92
|
+
try {
|
|
93
|
+
const allPanes = await h.client.listPanes();
|
|
94
|
+
const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId
|
|
95
|
+
?? (h.env?.tabId ? h.env.tabId : null);
|
|
96
|
+
let plan: TabPlacementPlan = planTabPlacement({
|
|
97
|
+
desiredTab: placement.desiredTab,
|
|
98
|
+
description: placement.description,
|
|
99
|
+
knownTabs: await liveTabs(),
|
|
100
|
+
zone: placement.zone,
|
|
101
|
+
mainTabId,
|
|
102
|
+
});
|
|
103
|
+
if (plan.mode === 'append' && plan.tabId) {
|
|
104
|
+
const exclude = new Set(
|
|
105
|
+
allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
|
|
106
|
+
);
|
|
107
|
+
let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
|
|
108
|
+
try {
|
|
109
|
+
const snapshot = await h.client.exportLayout({ tabId: plan.tabId });
|
|
110
|
+
const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
|
|
111
|
+
if (tree) pick = pickGridSplit(tree, { exclude });
|
|
112
|
+
} catch { /* layout export failure → legacy anchor split */ }
|
|
113
|
+
const anchorPaneId = pick?.targetPaneId
|
|
114
|
+
?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
|
|
115
|
+
?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
|
|
116
|
+
if (anchorPaneId) {
|
|
117
|
+
const paneId = await h.client.splitPane({
|
|
118
|
+
direction: pick?.direction ?? 'down',
|
|
119
|
+
cwd,
|
|
120
|
+
env: envOver,
|
|
121
|
+
targetPaneId: anchorPaneId,
|
|
122
|
+
});
|
|
123
|
+
await h.client.sendPaneText(paneId, launch);
|
|
124
|
+
return { tabId: plan.tabId, paneId, tabName: plan.tabName };
|
|
125
|
+
}
|
|
126
|
+
plan = { mode: 'new', tabName: plan.tabName, tabId: null };
|
|
127
|
+
}
|
|
128
|
+
const created = await h.client.createTab({
|
|
129
|
+
workspaceId: h.env?.workspaceId ?? '',
|
|
130
|
+
label: plan.tabName,
|
|
131
|
+
cwd,
|
|
132
|
+
env: envOver,
|
|
133
|
+
});
|
|
134
|
+
await h.client.sendPaneText(created.paneId, launch);
|
|
135
|
+
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
136
|
+
} finally {
|
|
137
|
+
release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
|
|
142
|
+
if (!sessionFile) return null;
|
|
143
|
+
try {
|
|
144
|
+
const agents = await h.client.listAgents();
|
|
145
|
+
const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
|
|
146
|
+
if (!match) return null;
|
|
147
|
+
const panes = await h.client.listPanes();
|
|
148
|
+
const pane = panes.find((p) => p.paneId === match.paneId);
|
|
149
|
+
return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, liveTabs, findExistingPane };
|
|
156
|
+
}
|
package/src/terminal-core.ts
CHANGED
|
@@ -1,47 +1,53 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* M14 常驻交互终端纯核心(D71 / T1–T6,2026-08-18)。
|
|
3
|
-
*
|
|
4
|
-
* 六工具的判定逻辑全部在此(无 pi/herdr 依赖 → 可单测):
|
|
5
|
-
* - 注册表:terminal_id 稳定(term-<n>)、上限、幂等关闭;
|
|
6
|
-
* - T6 检测:send 文本校验(拒 ANSI/控制字符)、signal 白名单、
|
|
7
|
-
* 全屏 TUI(alternate screen 序列)识别;
|
|
8
|
-
* - 读增量:环形缓冲语义(append / reset / none)+ 有界输出;
|
|
9
|
-
* - T3 readiness:PS1 尾匹配 + 静默期两档(超时兜底在 adapter);
|
|
10
|
-
* - 会话汇总:stale pane 检测(跨重启边界注记);
|
|
11
|
-
* - 持久化:custom 条目 last-wins(与 subs 注册表同构)。
|
|
12
|
-
*
|
|
13
|
-
* 后端映射(adapter = index.ts + herdr-client):
|
|
14
|
-
* open=pane.split 常驻 shell / send=pane.send_text+\r /
|
|
15
|
-
* read=pane.read(recent, strip_ansi:false)+本地 strip /
|
|
16
|
-
* signal=pane.send_keys / close=pane.close。
|
|
17
|
-
*/
|
|
1
|
+
/** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
|
|
18
2
|
import { basename } from 'node:path';
|
|
19
3
|
|
|
20
|
-
|
|
4
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
21
5
|
|
|
22
6
|
export const TERMINALS_CUSTOM_TYPE = 'pi-herdr.terminals';
|
|
23
7
|
export const MAX_TERMINALS = 8;
|
|
24
8
|
export const READ_MAX_CHARS = 8000;
|
|
25
|
-
/**
|
|
9
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
26
10
|
export const READ_HARD_CAP_CHARS = 200;
|
|
27
11
|
export const READINESS_TIMEOUT_MS = 30_000;
|
|
28
12
|
export const SILENCE_IDLE_MS = 2000;
|
|
29
|
-
/**
|
|
13
|
+
/** POSIX / PowerShell / Nushell prompt tail. Why: herdr waitForOutput and local classify must share one pattern. */
|
|
30
14
|
export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
|
|
31
|
-
|
|
15
|
+
|
|
16
|
+
export interface PromptStrategy {
|
|
17
|
+
/** herdr waitForOutput regex source. */
|
|
18
|
+
readonly waitPattern: string;
|
|
19
|
+
readonly tailRe: RegExp;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const POSIX_PROMPT: PromptStrategy = {
|
|
23
|
+
waitPattern: '[$>#❯]\\s*$',
|
|
24
|
+
tailRe: PROMPT_TAIL_RE,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** cmd.exe / PowerShell: `>` still matches POSIX; this also accepts `PS C:\\>` without a trailing `$`. */
|
|
28
|
+
export const POWERSHELL_PROMPT: PromptStrategy = {
|
|
29
|
+
waitPattern: '(PS [^\\n>]+|>)\\s*$',
|
|
30
|
+
tailRe: /(PS [^\n>]+|>)\s*$/,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** `PIER_TERMINAL_PROMPT=powershell` selects the Windows strategy; default stays POSIX so existing panes do not flip. */
|
|
34
|
+
export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
|
|
35
|
+
return env.PIER_TERMINAL_PROMPT === 'powershell' ? POWERSHELL_PROMPT : POSIX_PROMPT;
|
|
36
|
+
}
|
|
37
|
+
/** Why: Preserve the established compatibility and safety behavior (T6). */
|
|
32
38
|
export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
|
|
33
39
|
export type SignalKey = (typeof SIGNAL_KEYS)[number];
|
|
34
40
|
|
|
35
41
|
const FULLSCREEN_SEQUENCES = [
|
|
36
|
-
'\x1b[?1049h', //
|
|
37
|
-
'\x1b[?47h', //
|
|
38
|
-
'\x1b[2J\x1b[H', //
|
|
42
|
+
'\x1b[?1049h', // Why: Preserve the established compatibility and safety behavior.
|
|
43
|
+
'\x1b[?47h', // Why: Preserve the established compatibility and safety behavior.
|
|
44
|
+
'\x1b[2J\x1b[H', // Why: Preserve the established compatibility and safety behavior.
|
|
39
45
|
] as const;
|
|
40
46
|
|
|
41
47
|
export const RESET_MARKER = '…[buffer reset — earlier output scrolled away or cleared]…';
|
|
42
48
|
export const TRUNCATE_MARKER = '…[truncated]…';
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
45
51
|
|
|
46
52
|
export interface TerminalEntry {
|
|
47
53
|
terminalId: string;
|
|
@@ -53,7 +59,7 @@ export interface TerminalEntry {
|
|
|
53
59
|
lastActivityAt: number;
|
|
54
60
|
status: 'open' | 'closed';
|
|
55
61
|
closedAt: number | null;
|
|
56
|
-
/**
|
|
62
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
57
63
|
readRevision: number | null;
|
|
58
64
|
readLen: number;
|
|
59
65
|
readTail: string;
|
|
@@ -65,7 +71,7 @@ export interface TerminalsRegistry {
|
|
|
65
71
|
terminals: TerminalEntry[];
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
69
75
|
|
|
70
76
|
export function nextTerminalId(existingIds: readonly string[]): string {
|
|
71
77
|
let max = 0;
|
|
@@ -111,7 +117,7 @@ export function registerTerminal(
|
|
|
111
117
|
return { ok: true, entries: [...entries, entry], entry };
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
/**
|
|
120
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
115
121
|
export function closeTerminal(
|
|
116
122
|
entries: readonly TerminalEntry[],
|
|
117
123
|
terminalId: string,
|
|
@@ -126,12 +132,12 @@ export function closeTerminal(
|
|
|
126
132
|
};
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
/**
|
|
135
|
+
/** Why: Preserve the established compatibility and safety behavior (D71). */
|
|
130
136
|
export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
|
|
131
137
|
return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
/** Why: Preserve the established compatibility and safety behavior (T6). */
|
|
135
141
|
|
|
136
142
|
export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
|
|
137
143
|
if (text.includes('\x1b')) {
|
|
@@ -140,7 +146,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
|
|
|
140
146
|
error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal_signal for control keys, or plain commands only',
|
|
141
147
|
};
|
|
142
148
|
}
|
|
143
|
-
//
|
|
149
|
+
// Why: Preserve the established compatibility and safety behavior (C0).
|
|
144
150
|
const stripped = text.replace(/[\r\n]+$/, '');
|
|
145
151
|
const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
|
|
146
152
|
if (bad) {
|
|
@@ -163,7 +169,7 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
|
|
|
163
169
|
return { ok: true, key: k };
|
|
164
170
|
}
|
|
165
171
|
|
|
166
|
-
|
|
172
|
+
/** Why: Preserve the established compatibility and safety behavior (T6). */
|
|
167
173
|
|
|
168
174
|
export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
|
|
169
175
|
for (const seq of FULLSCREEN_SEQUENCES) {
|
|
@@ -174,21 +180,21 @@ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence:
|
|
|
174
180
|
|
|
175
181
|
export function stripAnsi(raw: string): string {
|
|
176
182
|
return raw
|
|
177
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') //
|
|
178
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') //
|
|
179
|
-
.replace(/\x1b[@-Z\\-_]/g, '') //
|
|
180
|
-
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); //
|
|
183
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // Why: Preserve the established compatibility and safety behavior.
|
|
184
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // Why: Preserve the established compatibility and safety behavior.
|
|
185
|
+
.replace(/\x1b[@-Z\\-_]/g, '') // Why: Preserve the established compatibility and safety behavior.
|
|
186
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); // Why: Preserve the established compatibility and safety behavior.
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
184
190
|
|
|
185
191
|
export interface ReadCursor {
|
|
186
192
|
revision: number;
|
|
187
|
-
/**
|
|
193
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
188
194
|
len: number;
|
|
189
|
-
/**
|
|
195
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
190
196
|
tail: string;
|
|
191
|
-
/**
|
|
197
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
192
198
|
eoTail: string;
|
|
193
199
|
}
|
|
194
200
|
|
|
@@ -196,19 +202,14 @@ export interface ReadIncrement {
|
|
|
196
202
|
mode: 'none' | 'append' | 'reset';
|
|
197
203
|
text: string;
|
|
198
204
|
cursor: ReadCursor;
|
|
199
|
-
/**
|
|
205
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
200
206
|
hardCapped: boolean;
|
|
201
207
|
}
|
|
202
208
|
|
|
203
|
-
/**
|
|
209
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
204
210
|
const CURSOR_TAIL_CHARS = 64;
|
|
205
211
|
|
|
206
|
-
/**
|
|
207
|
-
* 实测(M14 live)两条屏幕缓冲语义,决定了本实现:
|
|
208
|
-
* 1) pane.read 的 revision 不随输出增长(恒 0)→ 变化探测用 eoTail,不用 revision;
|
|
209
|
-
* 2) `recent` 是屏幕重建而非追加日志:活动行行尾 \n 在续写时变空格
|
|
210
|
-
* ("PS>x\n" → "PS>x echo")→ 连续性锚按 trimEnd 比较,len 落在最后完整行边界。
|
|
211
|
-
*/
|
|
212
|
+
/** Why: Preserve the established compatibility and safety behavior (M14). */
|
|
212
213
|
export function computeIncrement(
|
|
213
214
|
prev: ReadCursor | null,
|
|
214
215
|
next: { text: string; revision: number },
|
|
@@ -233,7 +234,7 @@ export function computeIncrement(
|
|
|
233
234
|
const bounded = boundText(suffix, maxChars);
|
|
234
235
|
return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
|
|
235
236
|
}
|
|
236
|
-
//
|
|
237
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
237
238
|
const bounded = boundText(next.text, maxChars);
|
|
238
239
|
const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
|
|
239
240
|
return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
|
|
@@ -244,21 +245,22 @@ function boundText(text: string, maxChars: number): { text: string; capped: bool
|
|
|
244
245
|
return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
|
|
248
|
+
/** Why: Preserve the established compatibility and safety behavior (T3). */
|
|
248
249
|
|
|
249
250
|
export type ReadinessTier = 'prompt' | 'silent' | 'busy';
|
|
250
251
|
|
|
251
252
|
export function classifyReadiness(
|
|
252
253
|
rawOrStripped: string,
|
|
253
|
-
opts: { silentMs: number; silenceThresholdMs?: number },
|
|
254
|
+
opts: { silentMs: number; silenceThresholdMs?: number; prompt?: PromptStrategy },
|
|
254
255
|
): ReadinessTier {
|
|
255
256
|
const tail = rawOrStripped.trimEnd();
|
|
256
|
-
|
|
257
|
+
const re = opts.prompt?.tailRe ?? PROMPT_TAIL_RE;
|
|
258
|
+
if (tail.length > 0 && re.test(tail)) return 'prompt';
|
|
257
259
|
if (opts.silentMs >= (opts.silenceThresholdMs ?? SILENCE_IDLE_MS)) return 'silent';
|
|
258
260
|
return 'busy';
|
|
259
261
|
}
|
|
260
262
|
|
|
261
|
-
|
|
263
|
+
/** Why: Preserve the established compatibility and safety behavior (T6). */
|
|
262
264
|
|
|
263
265
|
export interface TerminalSummary {
|
|
264
266
|
terminalId: string;
|
|
@@ -288,7 +290,7 @@ export function summarizeSessions(
|
|
|
288
290
|
return { terminals, stalePaneIds };
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
|
|
293
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
292
294
|
|
|
293
295
|
export interface BranchEntryLike3 {
|
|
294
296
|
type?: string;
|
package/src/todo-core.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* todo 纯逻辑:校验 / 折叠 / M10 增量(D34–D43)。
|
|
3
|
-
*
|
|
4
|
-
* 全部无副作用、无 pi API 依赖 → 可单测(node --test 直接跑 TS)。
|
|
5
|
-
* 语义 = DESIGN.md §5.1.1 + §15:全量替换(last-wins),五态 + blocker + phase。
|
|
6
|
-
*/
|
|
1
|
+
/** Why: Preserve the established compatibility and safety behavior (D34–D43, M10). */
|
|
7
2
|
import {
|
|
8
3
|
TODO_DETAILS_KEY,
|
|
9
4
|
TODO_STATUSES,
|
|
@@ -17,13 +12,13 @@ import { countTodos } from './vocab.ts';
|
|
|
17
12
|
|
|
18
13
|
export type { TodoItem, TodoSnapshot, TodoStatus, TodoCounts };
|
|
19
14
|
|
|
20
|
-
/** D43
|
|
15
|
+
/** Why: Preserve the established compatibility and safety behavior (D43). */
|
|
21
16
|
export const PHASE_MAX_LEN = 30;
|
|
22
17
|
|
|
23
|
-
/**
|
|
18
|
+
/** Why: Preserve the established compatibility and safety behavior (D38). */
|
|
24
19
|
export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
|
|
25
20
|
|
|
26
|
-
/**
|
|
21
|
+
/** Why: Preserve the established compatibility and safety behavior (D38, M17). */
|
|
27
22
|
export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
|
|
28
23
|
|
|
29
24
|
export interface TodoEdit {
|
|
@@ -43,16 +38,7 @@ export interface TodoValidationResult {
|
|
|
43
38
|
error?: string;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
/**
|
|
47
|
-
* 校验一次 todo_write 的入参并规范化为条目数组(D34/D43 扩展)。
|
|
48
|
-
* 规则:
|
|
49
|
-
* - todos 必须是数组(空数组合法 = 清空列表);
|
|
50
|
-
* - 每条允许键 = content/status/blocker/phase,不允许其他键;
|
|
51
|
-
* - content 非空且全局唯一;status ∈ 五态;
|
|
52
|
-
* - blocker 仅 status==='blocked' 时允许(非空字符串),其他状态携带即拒绝;
|
|
53
|
-
* - phase 可选:非空、≤30 字符;
|
|
54
|
-
* - allowParallelInProgress=false 时,至多一条 in_progress(B2 严格模式的第一道闸)。
|
|
55
|
-
*/
|
|
41
|
+
/** Why: Preserve the established compatibility and safety behavior (B2, D34, D43). */
|
|
56
42
|
export function validateTodos(
|
|
57
43
|
todos: unknown,
|
|
58
44
|
allowParallelInProgress = true,
|
|
@@ -93,9 +79,9 @@ export function validateTodos(
|
|
|
93
79
|
const item: TodoItem = { content, status: status as TodoStatus };
|
|
94
80
|
if ('blocker' in entry) {
|
|
95
81
|
const blocker = entry.blocker;
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
//
|
|
82
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
83
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
84
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
99
85
|
if (blocker != null && typeof blocker !== 'string') {
|
|
100
86
|
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
101
87
|
}
|
|
@@ -111,7 +97,7 @@ export function validateTodos(
|
|
|
111
97
|
if (phase != null && typeof phase !== 'string') {
|
|
112
98
|
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
113
99
|
}
|
|
114
|
-
//
|
|
100
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
115
101
|
if (typeof phase === 'string') {
|
|
116
102
|
if (phase.length > PHASE_MAX_LEN) {
|
|
117
103
|
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
@@ -133,13 +119,7 @@ export function validateTodos(
|
|
|
133
119
|
return { ok: true, items };
|
|
134
120
|
}
|
|
135
121
|
|
|
136
|
-
/**
|
|
137
|
-
* D42 严格模式归一化(OMP normalizeInProgressTask 语义,按 pane 角色自动推导):
|
|
138
|
-
* - 多余 in_progress(列表顺序第二条起)退回 pending;
|
|
139
|
-
* - 无 in_progress 且有 pending → 晋升列表顺序第一条 pending;
|
|
140
|
-
* - blocked/abandoned 不参与晋升。
|
|
141
|
-
* 返回新数组(不改入参)。
|
|
142
|
-
*/
|
|
122
|
+
/** Why: Preserve the established compatibility and safety behavior (D42). */
|
|
143
123
|
export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
|
|
144
124
|
if (items.length === 0) return [];
|
|
145
125
|
const out = items.map((it) => ({ ...it }));
|
|
@@ -157,7 +137,7 @@ export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
|
|
|
157
137
|
return out;
|
|
158
138
|
}
|
|
159
139
|
|
|
160
|
-
/**
|
|
140
|
+
/** Why: Preserve the established compatibility and safety behavior (D35). */
|
|
161
141
|
export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
|
|
162
142
|
if (a.length !== b.length) return false;
|
|
163
143
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -168,7 +148,7 @@ export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): bool
|
|
|
168
148
|
return true;
|
|
169
149
|
}
|
|
170
150
|
|
|
171
|
-
/**
|
|
151
|
+
/** Why: Preserve the established compatibility and safety behavior (D36). */
|
|
172
152
|
export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
173
153
|
const prevMap = new Map(prev.map((it) => [it.content, it.status]));
|
|
174
154
|
const out: string[] = [];
|
|
@@ -178,7 +158,7 @@ export function completionTransitions(prev: readonly TodoItem[], next: readonly
|
|
|
178
158
|
return out;
|
|
179
159
|
}
|
|
180
160
|
|
|
181
|
-
/**
|
|
161
|
+
/** Why: Preserve the established compatibility and safety behavior (D37). */
|
|
182
162
|
export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
183
163
|
const nextMap = new Map(next.map((it) => [it.content, it.status]));
|
|
184
164
|
const out: string[] = [];
|
|
@@ -190,15 +170,15 @@ export function revertedCompleted(prev: readonly TodoItem[], next: readonly Todo
|
|
|
190
170
|
return out;
|
|
191
171
|
}
|
|
192
172
|
|
|
193
|
-
/**
|
|
173
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
194
174
|
export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
|
|
195
175
|
return { version: 1, items };
|
|
196
176
|
}
|
|
197
177
|
|
|
198
178
|
export interface BranchEntryLike {
|
|
199
|
-
/**
|
|
179
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
200
180
|
type?: string;
|
|
201
|
-
/**
|
|
181
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
202
182
|
timestamp?: unknown;
|
|
203
183
|
message?: {
|
|
204
184
|
role?: string;
|
|
@@ -209,13 +189,7 @@ export interface BranchEntryLike {
|
|
|
209
189
|
data?: unknown;
|
|
210
190
|
}
|
|
211
191
|
|
|
212
|
-
/**
|
|
213
|
-
* D38:在列表上应用人类编辑(幂等:目标不存在则 no-op)。
|
|
214
|
-
* - done:pending/in_progress/blocked → completed;completed 保持(completed 不可逆,D37);
|
|
215
|
-
* - drop:非 completed/abandoned → abandoned;
|
|
216
|
-
* - rm:移除(任意状态);
|
|
217
|
-
* - unblock(M17):blocked → pending(清 blocker);非 blocked 为 no-op。
|
|
218
|
-
*/
|
|
192
|
+
/** Why: Preserve the established compatibility and safety behavior (D37, D38, M17). */
|
|
219
193
|
export function applyTodoEdits(items: readonly TodoItem[], edits: readonly TodoEdit[]): TodoItem[] {
|
|
220
194
|
let out = items.map((it) => ({ ...it }));
|
|
221
195
|
for (const edit of edits) {
|
|
@@ -266,15 +240,10 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
|
|
|
266
240
|
return { version: 1, edits, ts: typeof raw.ts === 'number' ? raw.ts : 0 };
|
|
267
241
|
}
|
|
268
242
|
|
|
269
|
-
/**
|
|
270
|
-
* 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
|
|
271
|
-
* - toolResult(todo_write) → 快照替换(last-wins);
|
|
272
|
-
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
273
|
-
* 分支正确性 = 沿分支路径顺序折叠。
|
|
274
|
-
*/
|
|
243
|
+
/** Why: Preserve the established compatibility and safety behavior (D38). */
|
|
275
244
|
export interface FoldedTodos {
|
|
276
245
|
items: TodoItem[];
|
|
277
|
-
/**
|
|
246
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
278
247
|
writtenAt: number | null;
|
|
279
248
|
}
|
|
280
249
|
|
|
@@ -288,13 +257,7 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
|
|
|
288
257
|
return null;
|
|
289
258
|
}
|
|
290
259
|
|
|
291
|
-
/**
|
|
292
|
-
* 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
|
|
293
|
-
* - toolResult(todo_write) → 快照替换(last-wins);
|
|
294
|
-
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
295
|
-
* 分支正确性 = 沿分支路径顺序折叠。writtenAt = 最后一次触碰该列表的条目时间戳
|
|
296
|
-
* (无时间戳的旧条目 → null,陈旧度判定保守判 fresh)。
|
|
297
|
-
*/
|
|
260
|
+
/** Why: Preserve the established compatibility and safety behavior (D38). */
|
|
298
261
|
export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
|
|
299
262
|
let found: TodoItem[] | null = null;
|
|
300
263
|
let writtenAt: number | null = null;
|
|
@@ -319,7 +282,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
|
|
|
319
282
|
return found ? { items: found, writtenAt } : null;
|
|
320
283
|
}
|
|
321
284
|
|
|
322
|
-
/**
|
|
285
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
323
286
|
export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
|
|
324
287
|
return foldLatestTodosMeta(entries)?.items ?? null;
|
|
325
288
|
}
|
|
@@ -333,7 +296,7 @@ export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | nul
|
|
|
333
296
|
return { version: 1, items: snap.items as TodoItem[] };
|
|
334
297
|
}
|
|
335
298
|
|
|
336
|
-
/**
|
|
299
|
+
/** Why: Preserve the established compatibility and safety behavior (D38). */
|
|
337
300
|
export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
|
|
338
301
|
const q = query.trim();
|
|
339
302
|
if (!q) return [];
|
|
@@ -346,10 +309,10 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
|
|
|
346
309
|
return substr.map((it) => it.content);
|
|
347
310
|
}
|
|
348
311
|
|
|
349
|
-
/**
|
|
312
|
+
/** Why: Preserve the established compatibility and safety behavior (D39). */
|
|
350
313
|
export interface BoundedView {
|
|
351
314
|
visible: TodoItem[];
|
|
352
|
-
/**
|
|
315
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
353
316
|
hiddenCompleted: number;
|
|
354
317
|
hiddenOpen: number;
|
|
355
318
|
}
|
|
@@ -360,8 +323,8 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
|
|
|
360
323
|
}
|
|
361
324
|
const completed = items.filter((it) => it.status === 'completed');
|
|
362
325
|
const open = items.filter((it) => it.status !== 'completed');
|
|
363
|
-
//
|
|
364
|
-
//
|
|
326
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
327
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
365
328
|
const visible = open.length > budget ? open.slice(-budget) : open;
|
|
366
329
|
const hiddenOpen = open.length - visible.length;
|
|
367
330
|
let hiddenCompleted = completed.length;
|
|
@@ -374,7 +337,7 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
|
|
|
374
337
|
return { visible: final, hiddenCompleted, hiddenOpen };
|
|
375
338
|
}
|
|
376
339
|
|
|
377
|
-
/**
|
|
340
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
378
341
|
export function currentActivity(items: TodoItem[]): string | null {
|
|
379
342
|
return items.find((it) => it.status === 'in_progress')?.content ?? null;
|
|
380
343
|
}
|