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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/schemas/role-manifest.schema.json +1 -2
  3. package/src/bootstrap.ts +18 -22
  4. package/src/core/grid-shape.ts +13 -14
  5. package/src/core/subagent.ts +325 -857
  6. package/src/core/terminal.ts +25 -28
  7. package/src/core/todo.ts +47 -53
  8. package/src/gc-core.ts +15 -15
  9. package/src/git-adapter.ts +104 -0
  10. package/src/herdr-client.ts +76 -68
  11. package/src/history-store.ts +70 -36
  12. package/src/index-locks.ts +105 -0
  13. package/src/index-master.ts +116 -0
  14. package/src/index-notices.ts +43 -0
  15. package/src/index-pipe.ts +73 -0
  16. package/src/index-runtime.ts +23 -0
  17. package/src/index-worker.ts +39 -0
  18. package/src/index.ts +134 -386
  19. package/src/ledger.ts +15 -17
  20. package/src/lock-core.ts +15 -29
  21. package/src/manifest-compose.ts +9 -23
  22. package/src/notice-buffer.ts +9 -14
  23. package/src/pane-title.ts +24 -24
  24. package/src/pi-surface.ts +22 -29
  25. package/src/pipe-channel.ts +67 -34
  26. package/src/platform-paths.ts +72 -0
  27. package/src/progress-core.ts +26 -24
  28. package/src/reconcile-core.ts +25 -24
  29. package/src/role-loader.ts +32 -35
  30. package/src/role-manifest.ts +24 -28
  31. package/src/roles/master.json +2 -2
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/runtime-policy.ts +66 -0
  34. package/src/session-tail.ts +46 -45
  35. package/src/settle-wake-core.ts +19 -18
  36. package/src/slim-frame.ts +45 -44
  37. package/src/stale-core.ts +23 -21
  38. package/src/storage-layout.ts +80 -0
  39. package/src/subagent-core.ts +151 -94
  40. package/src/subagent-gc.ts +237 -0
  41. package/src/subagent-git-io.ts +80 -0
  42. package/src/subagent-launch.ts +110 -0
  43. package/src/subagent-poll-loop.ts +285 -0
  44. package/src/subagent-poller.ts +90 -0
  45. package/src/subagent-port.ts +27 -0
  46. package/src/subagent-scope.ts +4 -4
  47. package/src/subagent-session-io.ts +143 -0
  48. package/src/subagent-spawn.ts +156 -0
  49. package/src/terminal-core.ts +56 -54
  50. package/src/todo-core.ts +27 -64
  51. package/src/todo-read-hook.ts +28 -24
  52. package/src/todo-reminder-core.ts +89 -0
  53. package/src/todos-service.ts +36 -25
  54. package/src/tool-gate.ts +7 -28
  55. package/src/vocab.ts +18 -19
package/src/stale-core.ts CHANGED
@@ -1,48 +1,50 @@
1
1
  /**
2
- * todo 陈旧度纯核心(反冻结:会话 01a03253 实证)。
2
+ * Pure core for todo staleness (anti-freeze behavior, evidenced by session 01a03253).
3
3
  *
4
- * 实证:全完成列表(3✓)冻结 16h / 37 轮,模型持续做新工作但 todo_write 零调用;
5
- * 读钩每轮复读死列表反而背书"无事可跟踪",标题满权重渲染死计数。
6
- * 本模块把"列表是否还反映现实"收敛为纯函数,三层消费:
7
- * - stale(A):open==0 且 ≥ STALE_TURNS 轮未写 → 读钩注入改 stale 警告(限频 + 封顶);
8
- * - archived(B):open==0 且墙钟 ≥ STALE_CLOCK_MS 未写 → 按不存在处理
9
- * (注入/投影不再复读明细;条目保留,/todos 可查;会话 JSONL 权威不动);
10
- * - 标题(D):archived 时窗格/侧栏渲染 `✓N done <age>`,不再冒充当前状态。
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
- * 有 open 项(pending/in_progress/blocked)的列表永不判 stale——未完成工作
13
- * 仍由 agent_settled 提醒兜底;lastWriteAt 未知(旧会话无时间戳)→ 时钟维度
14
- * 保守不判 archived,turns 维度不受影响。
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:turns 维度过期阈值(用户轮次,自上次 todo 写入起算)。 */
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:每次停滞期最多注入的 stale 警告数(防空转噪音;archived 后转归档通知节奏)。 */
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(abandoned 不算未完成)。 */
33
+ /** open = pending + in_progress + blocked (abandoned does not count as unfinished). */
32
34
  open: number;
33
- /** now - lastWriteAt;lastWriteAt 未知 → null。 */
35
+ /** now - lastWriteAt; unknown lastWriteAt → null. */
34
36
  ageMs: number | null;
35
37
  }
36
38
 
37
- /** open 计数:stale/archived 只对 open==0 的全完成(或全放弃)列表生效。 */
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
- * 陈旧度判定(双条件:turns 维度 → stale;墙钟维度 → archived,时钟优先)。
45
- * 空列表 → fresh(空守卫另有归属);lastWriteAt null → 永不 stale(保守)。
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
- /** 标题/镜像路径专用(无 turn 信息):仅墙钟维度的 archived 判定。 */
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
- /** 年龄显示:<60m → `Nm`;<48h → `Nh`(floor);否则 `Nd`。 */
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
+ }
@@ -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
- /** PowerShell 单引号转义('')。 */
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
- /** POSIX sh 单引号转义('\'')。 */
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
- /** 计数信号量:并发上限内的 acquire 立即执行,超出排队(FIFO)。 */
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
- /** 最终文本(completed 时为子代理最终回答;timeout/blocked 时为部分输出)。 */
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
- /* ── 探活 + 存活通知(A2/B1 统一出口;纯逻辑,IO 在 adapter) ────── */
86
+ /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
102
87
 
103
- /** 探活快照:pane 实时状态 + 会话活动时间(毫秒 epoch;null = 未知)。 */
88
+ /** Why: Preserve the established compatibility and safety behavior. */
104
89
  export interface AliveProbe {
105
90
  paneExists: boolean;
106
91
  agentStatus: string | null;
107
- /** 会话文件 mtime(最新候选);无文件/不可读 = null。 */
92
+ /** Why: Preserve the established compatibility and safety behavior. */
108
93
  lastActivityMs: number | null;
109
94
  }
110
95
 
111
- /** 探活判活:pane 在 +(working/blocked 或近期有会话活动)→ 视为还在干活。 */
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
- // idle/unknown:会话 2 分钟内有活动仍算活(写作间隙/刚结算未采集)
100
+ // Why: Preserve the established compatibility and safety behavior.
116
101
  return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
117
102
  }
118
103
 
119
- /** 人话相对时间("12s ago" / "3m ago")。 */
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
- /* ── 子代理注册表(custom 条目持久化) ────────────────────────────── */
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
- /** 'task' 或 role 名;旧 short/resident 读盘归一为 task,不参与 GC。 */
154
+ /** Why: Preserve the established compatibility and safety behavior. */
178
155
  kind: string;
179
156
  paneId: string;
180
157
  tabId: string;
181
- /** v1.3:任务 tab 名(D25/D26;tab.rename 后可能漂移,仅为显示/复活参考)。 */
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
- /** 消费时间(GC 宽限期判据)。 */
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
- /** D94:用户是否接管控制(暂停 master 管理)。 */
170
+ /** Why: Preserve the established compatibility and safety behavior (D94). */
194
171
  userTakeover?: boolean;
195
- /** D94:接管检测时间(ms);settled 后进入观察期开始计时。 */
172
+ /** Why: Preserve the established compatibility and safety behavior (D94). */
196
173
  observationStartedAt?: number | null;
197
- /** D94:上次 agent 状态(用于检测 idle→working 的用户介入)。 */
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
- /* ── v1.3 任务 tab 放置(D25/D26 纯逻辑,可单测) ─────────────────── */
259
+ /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
274
260
 
275
261
  export const TAB_NAME_MAX = 20;
276
262
 
277
- /** tab 名规范化:折叠空白、截断 ≤20 字符;空 → 'task'。 */
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
- /** 缺省放置撞名加序号后缀(-2、-3…),并保持总长 ≤20(D26:缺省永远开新 tab)。 */
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
- /** append = 追加进既有 tab(锚 pane split);new = 新命名 tab。 */
283
+ /** Why: Preserve the established compatibility and safety behavior. */
298
284
  mode: 'append' | 'new';
299
285
  tabName: string;
300
- /** append 时的既有 tabId(须调用方再校验存活);new 时为 null。 */
286
+ /** Why: Preserve the established compatibility and safety behavior. */
301
287
  tabId: string | null;
302
288
  }
303
289
 
304
- /* ── D86:worktree 分组键(纯逻辑) ─────────────────────────────── */
290
+ /** Why: Preserve the established compatibility and safety behavior (D86). */
305
291
 
306
- /** 路径包含判定(大小写不敏感 + 斜杠统一;cwd === wt 或 cwd 在 wt 之下)。D86 信任旗标也用它。 */
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
- /** main = 主检出(split 进 main tab);worktree = 命名 worktree tab;非 git/无关目录也兜底 main。 */
301
+ /** Why: Preserve the established compatibility and safety behavior. */
316
302
  zone: 'main' | 'worktree';
317
- /** zone=worktree 时的 tab 名(目录名原样,撞名后缀由 nextTaskTabName 处理)。 */
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 }; // 主检出 → main tab
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
- /** D86 缺省分组(classifyWorktreeZone 输出;不传 = 旧 description 派生路径,兼容用)。 */
327
+ /** Why: Preserve the established compatibility and safety behavior (D86). */
352
328
  zone?: WorktreeZone;
353
- /** D86 main 分组的目标 tab(master 所在 tab)。 */
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
- // D86 R1:主检出 → split 进 main tab(锚 pane 由调用方选)
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
- // D86 R1:worktree → join-or-create 目录名 tab(原样 label,大小写不敏感撞名 join)
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
- // 兜底(无 zone 信息):由 description 派生名 + 撞名加序号后缀 → 永远 new。
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
- /* ── D97:worker 启动参数(纯逻辑,可单测) ───────────────────────── */
358
+ /** Why: Preserve the established compatibility and safety behavior (D97). */
383
359
 
384
- /** launchLine 的运行时三要素(master 侧探测:node 解释器 / pi cli / 本扩展入口)。 */
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
+ }