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
package/src/stale-core.ts
CHANGED
|
@@ -1,48 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* todo
|
|
2
|
+
* Pure core for todo staleness (anti-freeze behavior, evidenced by session 01a03253).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
4
|
+
* Evidence: a fully completed list (3✓) froze for 16h / 37 turns while the model continued new work but made no
|
|
5
|
+
* todo_write calls; rereading the dead list every read hook falsely endorsed "nothing to track," and the title
|
|
6
|
+
* rendered dead counts at full weight. This module reduces whether a list still reflects reality to pure functions
|
|
7
|
+
* consumed in three places:
|
|
8
|
+
* - stale (A): open==0 with ≥ STALE_TURNS turns since the last write → the read hook injects a rate-limited,
|
|
9
|
+
* capped stale warning;
|
|
10
|
+
* - archived (B): open==0 with wall-clock age ≥ STALE_CLOCK_MS since the last write → treat it as absent
|
|
11
|
+
* (injection/projection no longer rereads details; entries remain queryable through /todos; session JSONL stays authoritative);
|
|
12
|
+
* - title (D): when archived, pane/sidebar renders `✓N done <age>` instead of impersonating current state.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* A list with open items (pending/in_progress/blocked) is never stale: agent_settled continues to cover unfinished
|
|
15
|
+
* work. When lastWriteAt is unknown (old sessions lack timestamps), conservatively do not archive on the clock axis;
|
|
16
|
+
* the turns axis remains unaffected.
|
|
15
17
|
*/
|
|
16
18
|
import { countTodos, type TodoItem } from './vocab.ts';
|
|
17
19
|
|
|
18
|
-
/** A
|
|
20
|
+
/** A: turn-based expiry threshold (user turns counted from the last todo write). */
|
|
19
21
|
export const STALE_TURNS = 6;
|
|
20
22
|
|
|
21
|
-
/** B
|
|
23
|
+
/** B: wall-clock archive threshold (age of the last write for a completed list). */
|
|
22
24
|
export const STALE_CLOCK_MS = 60 * 60 * 1000;
|
|
23
25
|
|
|
24
|
-
/** A
|
|
26
|
+
/** A: maximum stale warnings injected during one stalled period (prevent spin noise; archived changes to archive-notice cadence). */
|
|
25
27
|
export const STALE_NOTICE_MAX = 3;
|
|
26
28
|
|
|
27
29
|
export type StalenessKind = 'fresh' | 'stale' | 'archived';
|
|
28
30
|
|
|
29
31
|
export interface Staleness {
|
|
30
32
|
kind: StalenessKind;
|
|
31
|
-
/** open = pending + in_progress + blocked
|
|
33
|
+
/** open = pending + in_progress + blocked (abandoned does not count as unfinished). */
|
|
32
34
|
open: number;
|
|
33
|
-
/** now - lastWriteAt
|
|
35
|
+
/** now - lastWriteAt; unknown lastWriteAt → null. */
|
|
34
36
|
ageMs: number | null;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/** open
|
|
39
|
+
/** Count open items: stale/archived apply only to fully completed (or fully abandoned) lists with open==0. */
|
|
38
40
|
export function openTodos(items: readonly TodoItem[]): number {
|
|
39
41
|
const c = countTodos(items as TodoItem[]);
|
|
40
42
|
return c.pending + c.inProgress + c.blocked;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Determine staleness (two conditions: turns → stale; wall clock → archived, with the clock taking precedence).
|
|
47
|
+
* Empty list → fresh (a separate guard owns empty-list handling); null lastWriteAt → never stale (conservative).
|
|
46
48
|
*/
|
|
47
49
|
export function evaluateStaleness(opts: {
|
|
48
50
|
items: readonly TodoItem[];
|
|
@@ -59,7 +61,7 @@ export function evaluateStaleness(opts: {
|
|
|
59
61
|
return st;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/**
|
|
64
|
+
/** For title/mirror paths without turn information: only evaluate wall-clock archive status. */
|
|
63
65
|
export function isArchived(
|
|
64
66
|
items: readonly TodoItem[],
|
|
65
67
|
lastWriteAt: number | null,
|
|
@@ -68,7 +70,7 @@ export function isArchived(
|
|
|
68
70
|
return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
/**
|
|
73
|
+
/** Age display: <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
|
|
72
74
|
export function formatAge(ms: number): string {
|
|
73
75
|
if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
|
|
74
76
|
if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage path layout (history, session dirs, role dirs).
|
|
3
|
+
*
|
|
4
|
+
* Why: sessionDirName / historyFilePath / userRolesDir were duplicated across
|
|
5
|
+
* history-store, session-tail, and role-loader. One module owns the encoding
|
|
6
|
+
* so path conventions cannot drift.
|
|
7
|
+
*
|
|
8
|
+
* Writes use a collision-resistant encoding (`a/b` ≠ `a-b`). Reads prefer the
|
|
9
|
+
* new name, then the legacy flattened name, so existing history/session dirs
|
|
10
|
+
* stay reachable. If only the legacy dir exists, writes keep appending there
|
|
11
|
+
* instead of splitting the ledger.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Legacy flattening (`--F--herdr-pi--`).
|
|
19
|
+
* Why kept: `a/b` and `a-b` collide, but on-disk dirs already use this form.
|
|
20
|
+
*/
|
|
21
|
+
export function sessionDirNameLegacy(cwd: string): string {
|
|
22
|
+
const flat = cwd.replace(/[\\/]/g, '-').replace(/:/g, '-');
|
|
23
|
+
return `--${flat}--`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Collision-resistant session directory name.
|
|
28
|
+
* `%` is escaped first so `/` `\` `:` round-trip: `a/b` → `--a%2Fb--`, `a-b` → `--a-b--`.
|
|
29
|
+
*/
|
|
30
|
+
export function sessionDirName(cwd: string): string {
|
|
31
|
+
const encoded = cwd
|
|
32
|
+
.replace(/%/g, '%25')
|
|
33
|
+
.replace(/\\/g, '%5C')
|
|
34
|
+
.replace(/\//g, '%2F')
|
|
35
|
+
.replace(/:/g, '%3A');
|
|
36
|
+
return `--${encoded}--`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** New encoding first, then legacy when they differ. */
|
|
40
|
+
export function sessionDirCandidates(cwd: string): readonly string[] {
|
|
41
|
+
const next = sessionDirName(cwd);
|
|
42
|
+
const prev = sessionDirNameLegacy(cwd);
|
|
43
|
+
return next === prev ? [next] : [next, prev];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Directory to read/write under `parent`.
|
|
48
|
+
* Existing legacy dirs win over a missing new dir so history is not split.
|
|
49
|
+
*/
|
|
50
|
+
export function preferredSessionDir(parent: string, cwd: string): string {
|
|
51
|
+
const names = sessionDirCandidates(cwd);
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
const dir = join(parent, name);
|
|
54
|
+
if (existsSync(dir)) return dir;
|
|
55
|
+
}
|
|
56
|
+
return join(parent, names[0]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function historyFilePath(agentRoot: string, cwd: string): string {
|
|
60
|
+
return join(agentRoot, 'herdr-pi', 'history', sessionDirName(cwd), 'history.jsonl');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function historyFilePathLegacy(agentRoot: string, cwd: string): string {
|
|
64
|
+
return join(agentRoot, 'herdr-pi', 'history', sessionDirNameLegacy(cwd), 'history.jsonl');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Dual-read history path: existing ledger (legacy or new) else canonical new file. */
|
|
68
|
+
export function preferredHistoryFile(agentRoot: string, cwd: string): string {
|
|
69
|
+
return join(preferredSessionDir(join(agentRoot, 'herdr-pi', 'history'), cwd), 'history.jsonl');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** User-global roles directory (`~/.pi/agent/herdr-pi/roles/`). */
|
|
73
|
+
export function userRolesDir(): string {
|
|
74
|
+
return join(homedir(), '.pi', 'agent', 'herdr-pi', 'roles');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Workspace-level roles directory (`<base>/.pi-herdr/roles/`). */
|
|
78
|
+
export function workspaceRolesDir(baseDir: string): string {
|
|
79
|
+
return join(baseDir, '.pi-herdr', 'roles');
|
|
80
|
+
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* subagent 纯逻辑:子 pane 命令构造、并发信号量、注册表、进度回调。
|
|
3
|
-
* 无副作用、无 pi/herdr API 依赖 → 可单测。
|
|
4
|
-
*
|
|
5
|
-
* v1.1(DESIGN.md §12,方案 C):子 pane 统一为交互式 pi TUI。
|
|
6
|
-
* - 启动:常驻 shell 里 `node cli.js -e <本扩展>`(Windows 经 powershell 包裹、
|
|
7
|
-
* POSIX 直启;无 prompt 参数,prompt 经扩展管道注入),herdr 自动检测 +
|
|
8
|
-
* 本扩展上报状态;
|
|
9
|
-
* - 并发上限 4(D10):信号量压制并行 spawn;
|
|
10
|
-
* - 结果通道 = 子会话 JSONL(session-tail.ts),不再解析 pane 文本;
|
|
11
|
-
* - 软锁:仅在 herdr agent_status ∈ {idle} 时注入(index.ts 的状态门)。
|
|
12
|
-
*/
|
|
1
|
+
/** Why: Preserve the established compatibility and safety behavior (D10). */
|
|
13
2
|
import { normalizeEntryKind } from './history-store.ts';
|
|
14
3
|
|
|
15
4
|
export interface SubagentSpec {
|
|
@@ -17,21 +6,17 @@ export interface SubagentSpec {
|
|
|
17
6
|
prompt: string;
|
|
18
7
|
}
|
|
19
8
|
|
|
20
|
-
/**
|
|
9
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
21
10
|
export function psQuote(s: string): string {
|
|
22
11
|
return `'${String(s).replace(/'/g, "''")}'`;
|
|
23
12
|
}
|
|
24
13
|
|
|
25
|
-
/**
|
|
14
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
26
15
|
export function shQuote(s: string): string {
|
|
27
16
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
28
17
|
}
|
|
29
18
|
|
|
30
|
-
/**
|
|
31
|
-
* 子代理启动命令行(注入子 pane 的 shell,经 pane.send_text + CR 提交)。
|
|
32
|
-
* 片段 = 裸 argv 词(如 ['pi', '--session', '<file>']);win32 输出 PowerShell
|
|
33
|
-
* 调用语法(`&` 引导 + `''` 转义),其余输出 POSIX sh 语法(单引号 + `'\''` 转义)。
|
|
34
|
-
*/
|
|
19
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
35
20
|
export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platform = process.platform): string {
|
|
36
21
|
const quoted = platform === 'win32'
|
|
37
22
|
? parts.map((s) => psQuote(s))
|
|
@@ -39,7 +24,7 @@ export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platf
|
|
|
39
24
|
return (platform === 'win32' ? '& ' : '') + quoted.join(' ');
|
|
40
25
|
}
|
|
41
26
|
|
|
42
|
-
/**
|
|
27
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
43
28
|
export class Semaphore {
|
|
44
29
|
private active = 0;
|
|
45
30
|
private queue: Array<() => void> = [];
|
|
@@ -76,11 +61,11 @@ export class Semaphore {
|
|
|
76
61
|
|
|
77
62
|
export interface SubagentOutcome {
|
|
78
63
|
kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
|
|
79
|
-
/**
|
|
64
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
80
65
|
text: string;
|
|
81
66
|
}
|
|
82
67
|
|
|
83
|
-
/**
|
|
68
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
84
69
|
export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
|
|
85
70
|
switch (outcome.kind) {
|
|
86
71
|
case 'completed': {
|
|
@@ -98,25 +83,25 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
|
|
|
98
83
|
}
|
|
99
84
|
}
|
|
100
85
|
|
|
101
|
-
|
|
86
|
+
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
102
87
|
|
|
103
|
-
/**
|
|
88
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
104
89
|
export interface AliveProbe {
|
|
105
90
|
paneExists: boolean;
|
|
106
91
|
agentStatus: string | null;
|
|
107
|
-
/**
|
|
92
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
108
93
|
lastActivityMs: number | null;
|
|
109
94
|
}
|
|
110
95
|
|
|
111
|
-
/**
|
|
96
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
112
97
|
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
113
98
|
if (!probe.paneExists) return false;
|
|
114
99
|
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
115
|
-
//
|
|
100
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
116
101
|
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
117
102
|
}
|
|
118
103
|
|
|
119
|
-
/**
|
|
104
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
120
105
|
export function agoText(ms: number, nowMs: number): string {
|
|
121
106
|
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
122
107
|
if (s < 60) return `${s}s ago`;
|
|
@@ -124,11 +109,7 @@ export function agoText(ms: number, nowMs: number): string {
|
|
|
124
109
|
return `${Math.floor(s / 3600)}h ago`;
|
|
125
110
|
}
|
|
126
111
|
|
|
127
|
-
/**
|
|
128
|
-
* 存活通知(A2 转后台返回值 与 B1 tool_result 改写 的统一构造出口):
|
|
129
|
-
* 两场景同一话术——"它在干活 / 别重做 / 转后台了 / 结算会通知 / list_agents 可查"。
|
|
130
|
-
* scenario: 'moved-to-bg'(耐心阈值转后台)| 'error-alive'(工具报错但探活判活,兜底改写)。
|
|
131
|
-
*/
|
|
112
|
+
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
132
113
|
export function buildAliveNotice(
|
|
133
114
|
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
134
115
|
nowMs: number,
|
|
@@ -151,11 +132,7 @@ export function buildAliveNotice(
|
|
|
151
132
|
].join(' ');
|
|
152
133
|
}
|
|
153
134
|
|
|
154
|
-
/**
|
|
155
|
-
* 人类闸门通知(E1/E2 统一出口):subagent blocked(ask_user_question 等人类)时
|
|
156
|
-
* 给 master 的话术——不揽活、引导用户去 pane、授权例外经 send_message 转达。
|
|
157
|
-
* question 来自 herdr tokens['pi-ask'](子扩展 reportAskFlag 上报)。
|
|
158
|
-
*/
|
|
135
|
+
/** Why: Preserve the established compatibility and safety behavior (E1, E2). */
|
|
159
136
|
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
160
137
|
const { paneId, description, question } = opts;
|
|
161
138
|
const q = question ? ` (question: "${question}")` : '';
|
|
@@ -168,34 +145,42 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
|
|
|
168
145
|
].join(' ');
|
|
169
146
|
}
|
|
170
147
|
|
|
171
|
-
|
|
148
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
172
149
|
|
|
173
150
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
|
174
151
|
|
|
175
152
|
export interface SubEntry {
|
|
176
153
|
taskId: string;
|
|
177
|
-
/**
|
|
154
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
178
155
|
kind: string;
|
|
179
156
|
paneId: string;
|
|
180
157
|
tabId: string;
|
|
181
|
-
/**
|
|
158
|
+
/** Why: Preserve the established compatibility and safety behavior (D25, D26). */
|
|
182
159
|
tabName: string;
|
|
183
160
|
cwd: string;
|
|
184
161
|
description: string;
|
|
185
162
|
background: boolean;
|
|
186
163
|
status: 'running' | 'settled' | 'consumed' | 'closed';
|
|
187
|
-
/**
|
|
164
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
188
165
|
consumedAt?: number | null;
|
|
189
166
|
sessionFile: string | null;
|
|
190
167
|
launchCommand: string[];
|
|
191
168
|
createdAt: number;
|
|
192
169
|
revivedFrom?: string | null;
|
|
193
|
-
/**
|
|
170
|
+
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
194
171
|
userTakeover?: boolean;
|
|
195
|
-
/** D94
|
|
172
|
+
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
196
173
|
observationStartedAt?: number | null;
|
|
197
|
-
/**
|
|
174
|
+
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
198
175
|
lastAgentStatus?: string | null;
|
|
176
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
177
|
+
isolate?: {
|
|
178
|
+
worktreePath: string;
|
|
179
|
+
branch: string;
|
|
180
|
+
baseSha: string;
|
|
181
|
+
releasedAt: number | null;
|
|
182
|
+
retainNotified: boolean;
|
|
183
|
+
};
|
|
199
184
|
}
|
|
200
185
|
|
|
201
186
|
export interface SubsRegistry {
|
|
@@ -207,11 +192,7 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
|
|
|
207
192
|
return { version: 2, subs };
|
|
208
193
|
}
|
|
209
194
|
|
|
210
|
-
/**
|
|
211
|
-
* 从会话分支条目折叠注册表(last-wins,与 todo 折叠同构)。
|
|
212
|
-
* custom 条目形状(session-format 实测):{type:'custom', customType, data}。
|
|
213
|
-
* 兼容 v1 条目(缺字段按默认补齐:taskId=paneId、kind=task、tabId=''、launchCommand=[])。
|
|
214
|
-
*/
|
|
195
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
215
196
|
export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegistry {
|
|
216
197
|
let found = makeRegistry();
|
|
217
198
|
for (const entry of entries) {
|
|
@@ -240,6 +221,17 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
240
221
|
userTakeover: s.userTakeover === true ? true : undefined,
|
|
241
222
|
observationStartedAt: typeof s.observationStartedAt === 'number' ? s.observationStartedAt : null,
|
|
242
223
|
lastAgentStatus: typeof s.lastAgentStatus === 'string' ? s.lastAgentStatus : null,
|
|
224
|
+
isolate: s.isolate && typeof s.isolate === 'object'
|
|
225
|
+
&& typeof s.isolate.worktreePath === 'string' && typeof s.isolate.branch === 'string'
|
|
226
|
+
&& typeof s.isolate.baseSha === 'string'
|
|
227
|
+
? {
|
|
228
|
+
worktreePath: s.isolate.worktreePath,
|
|
229
|
+
branch: s.isolate.branch,
|
|
230
|
+
baseSha: s.isolate.baseSha,
|
|
231
|
+
releasedAt: typeof s.isolate.releasedAt === 'number' ? s.isolate.releasedAt : null,
|
|
232
|
+
retainNotified: s.isolate.retainNotified === true,
|
|
233
|
+
}
|
|
234
|
+
: undefined,
|
|
243
235
|
}));
|
|
244
236
|
found = { version: 2, subs };
|
|
245
237
|
}
|
|
@@ -252,36 +244,30 @@ export interface BranchEntryLike2 {
|
|
|
252
244
|
data?: unknown;
|
|
253
245
|
}
|
|
254
246
|
|
|
255
|
-
|
|
247
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
256
248
|
|
|
257
249
|
export interface ProgressUpdate {
|
|
258
250
|
content: Array<{ type: 'text'; text: string }>;
|
|
259
251
|
details: Record<string, never>;
|
|
260
252
|
}
|
|
261
253
|
|
|
262
|
-
/**
|
|
263
|
-
* 构造 onUpdate 的合法负载。
|
|
264
|
-
*
|
|
265
|
-
* ⚠️ pi 0.84.2 实测:AgentToolUpdateCallback 期待 AgentToolResult({content, details}),
|
|
266
|
-
* 传字符串会让交互式 TUI 在 getTextOutput 处崩溃(undefined.filter)并整个退出 pi。
|
|
267
|
-
* 所有进度回调必须经本助手构造。
|
|
268
|
-
*/
|
|
254
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
269
255
|
export function makeProgressUpdate(msg: string): ProgressUpdate {
|
|
270
256
|
return { content: [{ type: 'text', text: msg }], details: {} };
|
|
271
257
|
}
|
|
272
258
|
|
|
273
|
-
|
|
259
|
+
/** Why: Preserve the established compatibility and safety behavior (D25, D26). */
|
|
274
260
|
|
|
275
261
|
export const TAB_NAME_MAX = 20;
|
|
276
262
|
|
|
277
|
-
/**
|
|
263
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
278
264
|
export function tabNameForTask(description: string): string {
|
|
279
265
|
const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
|
|
280
266
|
const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
|
|
281
267
|
return truncated || 'task';
|
|
282
268
|
}
|
|
283
269
|
|
|
284
|
-
/**
|
|
270
|
+
/** Why: Preserve the established compatibility and safety behavior (D26). */
|
|
285
271
|
export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
|
|
286
272
|
let name = base;
|
|
287
273
|
let n = 2;
|
|
@@ -294,16 +280,16 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
|
|
|
294
280
|
}
|
|
295
281
|
|
|
296
282
|
export interface TabPlacementPlan {
|
|
297
|
-
/**
|
|
283
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
298
284
|
mode: 'append' | 'new';
|
|
299
285
|
tabName: string;
|
|
300
|
-
/**
|
|
286
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
301
287
|
tabId: string | null;
|
|
302
288
|
}
|
|
303
289
|
|
|
304
|
-
|
|
290
|
+
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
305
291
|
|
|
306
|
-
/**
|
|
292
|
+
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
307
293
|
export function isPathUnder(cwd: string, wt: string): boolean {
|
|
308
294
|
const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
309
295
|
const a = n(cwd);
|
|
@@ -312,17 +298,13 @@ export function isPathUnder(cwd: string, wt: string): boolean {
|
|
|
312
298
|
}
|
|
313
299
|
|
|
314
300
|
export interface WorktreeZone {
|
|
315
|
-
/**
|
|
301
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
316
302
|
zone: 'main' | 'worktree';
|
|
317
|
-
/**
|
|
303
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
318
304
|
tabName: string | null;
|
|
319
305
|
}
|
|
320
306
|
|
|
321
|
-
/**
|
|
322
|
-
* D86 分类器:cwd 落在哪个 worktree 分组。
|
|
323
|
-
* `worktrees` = git porcelain 顺序(主检出在前,仅用于列表本身;归属判定按包含);
|
|
324
|
-
* 非 git / 不属任何 worktree → main(「无新 worktree → 同 tab」的兜底)。
|
|
325
|
-
*/
|
|
307
|
+
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
326
308
|
export function classifyWorktreeZone(opts: {
|
|
327
309
|
cwd: string;
|
|
328
310
|
masterCwd: string;
|
|
@@ -330,27 +312,21 @@ export function classifyWorktreeZone(opts: {
|
|
|
330
312
|
}): WorktreeZone {
|
|
331
313
|
for (const wt of opts.worktrees) {
|
|
332
314
|
if (!isPathUnder(opts.cwd, wt)) continue;
|
|
333
|
-
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null }; //
|
|
315
|
+
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null }; // Why: Preserve the established compatibility and safety behavior.
|
|
334
316
|
const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
|
|
335
317
|
return { zone: 'worktree', tabName: base };
|
|
336
318
|
}
|
|
337
319
|
return { zone: 'main', tabName: null };
|
|
338
320
|
}
|
|
339
321
|
|
|
340
|
-
/**
|
|
341
|
-
* 放置决策(D26 显式层 + D86 缺省层):
|
|
342
|
-
* - 显式 `tab` 参数:规范名 → 已知 tab 同名则 append(用其 tabId),否则 new;
|
|
343
|
-
* - 缺省(D86):`zone` 给出分组——main → append 进 mainTabId;worktree →
|
|
344
|
-
* join-or-create 该目录名 tab(已知同名 append,否则 new)。
|
|
345
|
-
* `knownTabs` = 调用时刻的存活 tab(label, id)。
|
|
346
|
-
*/
|
|
322
|
+
/** Why: Preserve the established compatibility and safety behavior (D26, D86). */
|
|
347
323
|
export function planTabPlacement(opts: {
|
|
348
324
|
desiredTab?: string | null;
|
|
349
325
|
description: string;
|
|
350
326
|
knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
|
|
351
|
-
/**
|
|
327
|
+
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
352
328
|
zone?: WorktreeZone;
|
|
353
|
-
/**
|
|
329
|
+
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
354
330
|
mainTabId?: string | null;
|
|
355
331
|
}): TabPlacementPlan {
|
|
356
332
|
const known = new Map<string, string>();
|
|
@@ -362,39 +338,33 @@ export function planTabPlacement(opts: {
|
|
|
362
338
|
}
|
|
363
339
|
const zone = opts.zone;
|
|
364
340
|
if (zone?.zone === 'main' && opts.mainTabId) {
|
|
365
|
-
//
|
|
341
|
+
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
366
342
|
return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
|
|
367
343
|
}
|
|
368
344
|
if (zone?.zone === 'worktree' && zone.tabName) {
|
|
369
|
-
//
|
|
345
|
+
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
370
346
|
const base = zone.tabName;
|
|
371
347
|
const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
|
|
372
348
|
if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
|
|
373
349
|
const tabName = nextTaskTabName(base, new Set(known.keys()));
|
|
374
350
|
return { mode: 'new', tabName, tabId: null };
|
|
375
351
|
}
|
|
376
|
-
//
|
|
352
|
+
// Why: Preserve the established compatibility and safety behavior.
|
|
377
353
|
const base = tabNameForTask(opts.description);
|
|
378
354
|
const tabName = nextTaskTabName(base, new Set(known.keys()));
|
|
379
355
|
return { mode: 'new', tabName, tabId: null };
|
|
380
356
|
}
|
|
381
357
|
|
|
382
|
-
|
|
358
|
+
/** Why: Preserve the established compatibility and safety behavior (D97). */
|
|
383
359
|
|
|
384
|
-
/**
|
|
360
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
385
361
|
export interface LaunchRuntime {
|
|
386
362
|
nodePath: string;
|
|
387
363
|
cliPath: string;
|
|
388
364
|
extPath: string;
|
|
389
365
|
}
|
|
390
366
|
|
|
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
|
-
*/
|
|
367
|
+
/** Why: Preserve the established compatibility and safety behavior (D97). */
|
|
398
368
|
export function buildLaunchParts(
|
|
399
369
|
runtime: LaunchRuntime,
|
|
400
370
|
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
@@ -408,3 +378,90 @@ export function buildLaunchParts(
|
|
|
408
378
|
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
409
379
|
return parts;
|
|
410
380
|
}
|
|
381
|
+
|
|
382
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
383
|
+
|
|
384
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
385
|
+
export interface IsolatePlan {
|
|
386
|
+
branch: string;
|
|
387
|
+
slug: string;
|
|
388
|
+
worktreeDirName: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Why: Preserve the established compatibility and safety behavior. */
|
|
392
|
+
export const ISOLATE_SLUG_MAX = 40;
|
|
393
|
+
|
|
394
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
395
|
+
export function planIsolateWorktree(opts: {
|
|
396
|
+
description: string;
|
|
397
|
+
taskHex: string;
|
|
398
|
+
existingPierBranches: ReadonlySet<string>;
|
|
399
|
+
}): IsolatePlan {
|
|
400
|
+
const folded = String(opts.description ?? '')
|
|
401
|
+
.toLowerCase()
|
|
402
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
403
|
+
.replace(/^-+|-+$/g, '')
|
|
404
|
+
.slice(0, ISOLATE_SLUG_MAX)
|
|
405
|
+
.replace(/-+$/g, '');
|
|
406
|
+
const slug = folded || `task-${opts.taskHex}`;
|
|
407
|
+
let name = slug;
|
|
408
|
+
let n = 2;
|
|
409
|
+
while (opts.existingPierBranches.has(name)) {
|
|
410
|
+
name = `${slug}-${n}`;
|
|
411
|
+
n++;
|
|
412
|
+
}
|
|
413
|
+
return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
417
|
+
export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
|
|
418
|
+
return [
|
|
419
|
+
`You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
|
|
420
|
+
'Every file you create or edit must stay inside this worktree.',
|
|
421
|
+
'Commit your work to your branch as you go (git add -A && git commit). NEVER push to any remote.',
|
|
422
|
+
'Do not touch the main checkout or other worktrees; if you find issues outside your task, report them in your final message instead of fixing them.',
|
|
423
|
+
'Before finishing: commit everything, then end with a short summary of what changed and why.',
|
|
424
|
+
].join('\n');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
428
|
+
export function formatWorktreeStat(opts: {
|
|
429
|
+
branch: string | null;
|
|
430
|
+
commits: number | null;
|
|
431
|
+
statLine: string | null;
|
|
432
|
+
dirtyCount: number | null;
|
|
433
|
+
}): string | null {
|
|
434
|
+
if (opts.dirtyCount == null) return null;
|
|
435
|
+
if (opts.branch != null) {
|
|
436
|
+
if (opts.commits == null || !opts.statLine) return null;
|
|
437
|
+
const dirty = opts.dirtyCount > 0
|
|
438
|
+
? `${opts.dirtyCount} file(s) (worker should have committed)`
|
|
439
|
+
: `${opts.dirtyCount} file(s)`;
|
|
440
|
+
return `Worktree ${opts.branch}: ${opts.commits} commit(s) since base; ${opts.statLine}; uncommitted: ${dirty}`;
|
|
441
|
+
}
|
|
442
|
+
if (!opts.statLine) return null;
|
|
443
|
+
return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
447
|
+
export type ReleaseDecision = { action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' };
|
|
448
|
+
|
|
449
|
+
export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }): ReleaseDecision {
|
|
450
|
+
if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
|
|
451
|
+
if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
|
|
452
|
+
if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
|
|
453
|
+
return { action: 'release' };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
457
|
+
export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
458
|
+
const byBranch = new Map<string, string>();
|
|
459
|
+
let curPath: string | null = null;
|
|
460
|
+
for (const line of out.replace(/\r/g, '').split('\n')) {
|
|
461
|
+
const m = /^worktree (.+)$/.exec(line.trim());
|
|
462
|
+
if (m) { curPath = m[1]!; continue; }
|
|
463
|
+
const b = /^branch (.+)$/.exec(line.trim());
|
|
464
|
+
if (b && curPath) byBranch.set(b[1]!.replace(/^refs\/heads\//, ''), curPath);
|
|
465
|
+
}
|
|
466
|
+
return byBranch;
|
|
467
|
+
}
|