pi-pier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
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
+ */
13
+ import { normalizeEntryKind } from './history-store.ts';
14
+
15
+ export interface SubagentSpec {
16
+ description: string;
17
+ prompt: string;
18
+ }
19
+
20
+ /** PowerShell 单引号转义('')。 */
21
+ export function psQuote(s: string): string {
22
+ return `'${String(s).replace(/'/g, "''")}'`;
23
+ }
24
+
25
+ /** POSIX sh 单引号转义('\'')。 */
26
+ export function shQuote(s: string): string {
27
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
28
+ }
29
+
30
+ /**
31
+ * 子代理启动命令行(注入子 pane 的 shell,经 pane.send_text + CR 提交)。
32
+ * 片段 = 裸 argv 词(如 ['pi', '--session', '<file>']);win32 输出 PowerShell
33
+ * 调用语法(`&` 引导 + `''` 转义),其余输出 POSIX sh 语法(单引号 + `'\''` 转义)。
34
+ */
35
+ export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platform = process.platform): string {
36
+ const quoted = platform === 'win32'
37
+ ? parts.map((s) => psQuote(s))
38
+ : parts.map((s) => shQuote(s));
39
+ return (platform === 'win32' ? '& ' : '') + quoted.join(' ');
40
+ }
41
+
42
+ /** 计数信号量:并发上限内的 acquire 立即执行,超出排队(FIFO)。 */
43
+ export class Semaphore {
44
+ private active = 0;
45
+ private queue: Array<() => void> = [];
46
+ private readonly cap: number;
47
+
48
+ constructor(cap: number) {
49
+ if (cap < 1) throw new Error('semaphore cap must be >= 1');
50
+ this.cap = cap;
51
+ }
52
+
53
+ get activeCount(): number {
54
+ return this.active;
55
+ }
56
+
57
+ acquire(): Promise<() => void> {
58
+ if (this.active < this.cap) {
59
+ this.active++;
60
+ return Promise.resolve(this.release.bind(this));
61
+ }
62
+ return new Promise((resolve) => {
63
+ this.queue.push(() => {
64
+ this.active++;
65
+ resolve(this.release.bind(this));
66
+ });
67
+ });
68
+ }
69
+
70
+ private release(): void {
71
+ this.active--;
72
+ const next = this.queue.shift();
73
+ if (next) next();
74
+ }
75
+ }
76
+
77
+ export interface SubagentOutcome {
78
+ kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
79
+ /** 最终文本(completed 时为子代理最终回答;timeout/blocked 时为部分输出)。 */
80
+ text: string;
81
+ }
82
+
83
+ /** 把提取结果规范为工具返回文本。 */
84
+ export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
85
+ switch (outcome.kind) {
86
+ case 'completed': {
87
+ const text = outcome.text;
88
+ return text ? text : 'Subagent finished but produced no output.';
89
+ }
90
+ case 'timeout':
91
+ return `Error: subagent "${description}" timed out. Partial output:\n${outcome.text || '(none)'}`;
92
+ case 'blocked':
93
+ return `Subagent "${description}" is blocked in its pane and needs a human decision. Partial output:\n${outcome.text || '(none)'}`;
94
+ case 'no-output':
95
+ return `Error: subagent "${description}" produced no readable output.`;
96
+ case 'spawn-failed':
97
+ return `Error: failed to spawn subagent "${description}": ${outcome.text}`;
98
+ }
99
+ }
100
+
101
+ /* ── 子代理注册表(custom 条目持久化) ────────────────────────────── */
102
+
103
+ export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
104
+
105
+ export interface SubEntry {
106
+ taskId: string;
107
+ /** 'task' 或 role 名;旧 short/resident 读盘归一为 task,不参与 GC。 */
108
+ kind: string;
109
+ paneId: string;
110
+ tabId: string;
111
+ /** v1.3:任务 tab 名(D25/D26;tab.rename 后可能漂移,仅为显示/复活参考)。 */
112
+ tabName: string;
113
+ cwd: string;
114
+ description: string;
115
+ background: boolean;
116
+ status: 'running' | 'settled' | 'consumed' | 'closed';
117
+ /** 消费时间(GC 宽限期判据)。 */
118
+ consumedAt?: number | null;
119
+ sessionFile: string | null;
120
+ launchCommand: string[];
121
+ createdAt: number;
122
+ revivedFrom?: string | null;
123
+ /** D94:用户是否接管控制(暂停 master 管理)。 */
124
+ userTakeover?: boolean;
125
+ /** D94:接管检测时间(ms);settled 后进入观察期开始计时。 */
126
+ observationStartedAt?: number | null;
127
+ /** D94:上次 agent 状态(用于检测 idle→working 的用户介入)。 */
128
+ lastAgentStatus?: string | null;
129
+ }
130
+
131
+ export interface SubsRegistry {
132
+ version: 2;
133
+ subs: SubEntry[];
134
+ }
135
+
136
+ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
137
+ return { version: 2, subs };
138
+ }
139
+
140
+ /**
141
+ * 从会话分支条目折叠注册表(last-wins,与 todo 折叠同构)。
142
+ * custom 条目形状(session-format 实测):{type:'custom', customType, data}。
143
+ * 兼容 v1 条目(缺字段按默认补齐:taskId=paneId、kind=task、tabId=''、launchCommand=[])。
144
+ */
145
+ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegistry {
146
+ let found = makeRegistry();
147
+ for (const entry of entries) {
148
+ if (entry.type !== 'custom' || entry.customType !== SUBS_CUSTOM_TYPE) continue;
149
+ const data = entry.data as {
150
+ subs?: Array<Partial<SubEntry> & { paneId: string }>;
151
+ } | undefined;
152
+ if (!data || !Array.isArray(data.subs)) continue;
153
+ const subs: SubEntry[] = data.subs
154
+ .filter((s) => typeof s?.paneId === 'string')
155
+ .map((s) => ({
156
+ taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
157
+ kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
158
+ paneId: s.paneId,
159
+ tabId: typeof s.tabId === 'string' ? s.tabId : '',
160
+ tabName: typeof s.tabName === 'string' ? s.tabName : '',
161
+ cwd: typeof s.cwd === 'string' ? s.cwd : '',
162
+ description: typeof s.description === 'string' ? s.description : '',
163
+ background: s.background === true,
164
+ status: s.status === 'closed' ? 'closed' : s.status === 'consumed' ? 'consumed' : s.status === 'settled' ? 'settled' : 'running',
165
+ consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
166
+ sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
167
+ launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
168
+ createdAt: typeof s.createdAt === 'number' ? s.createdAt : 0,
169
+ revivedFrom: typeof s.revivedFrom === 'string' ? s.revivedFrom : null,
170
+ userTakeover: s.userTakeover === true ? true : undefined,
171
+ observationStartedAt: typeof s.observationStartedAt === 'number' ? s.observationStartedAt : null,
172
+ lastAgentStatus: typeof s.lastAgentStatus === 'string' ? s.lastAgentStatus : null,
173
+ }));
174
+ found = { version: 2, subs };
175
+ }
176
+ return found;
177
+ }
178
+
179
+ export interface BranchEntryLike2 {
180
+ type?: string;
181
+ customType?: string;
182
+ data?: unknown;
183
+ }
184
+
185
+ /* ── 工具进度回调 ─────────────────────────────────────────────────── */
186
+
187
+ export interface ProgressUpdate {
188
+ content: Array<{ type: 'text'; text: string }>;
189
+ details: Record<string, never>;
190
+ }
191
+
192
+ /**
193
+ * 构造 onUpdate 的合法负载。
194
+ *
195
+ * ⚠️ pi 0.84.2 实测:AgentToolUpdateCallback 期待 AgentToolResult({content, details}),
196
+ * 传字符串会让交互式 TUI 在 getTextOutput 处崩溃(undefined.filter)并整个退出 pi。
197
+ * 所有进度回调必须经本助手构造。
198
+ */
199
+ export function makeProgressUpdate(msg: string): ProgressUpdate {
200
+ return { content: [{ type: 'text', text: msg }], details: {} };
201
+ }
202
+
203
+ /* ── v1.3 任务 tab 放置(D25/D26 纯逻辑,可单测) ─────────────────── */
204
+
205
+ export const TAB_NAME_MAX = 20;
206
+
207
+ /** tab 名规范化:折叠空白、截断 ≤20 字符;空 → 'task'。 */
208
+ export function tabNameForTask(description: string): string {
209
+ const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
210
+ const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
211
+ return truncated || 'task';
212
+ }
213
+
214
+ /** 缺省放置撞名加序号后缀(-2、-3…),并保持总长 ≤20(D26:缺省永远开新 tab)。 */
215
+ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
216
+ let name = base;
217
+ let n = 2;
218
+ while (existingNames.has(name)) {
219
+ const suffix = `-${n}`;
220
+ name = base.slice(0, TAB_NAME_MAX - suffix.length) + suffix;
221
+ n++;
222
+ }
223
+ return name;
224
+ }
225
+
226
+ export interface TabPlacementPlan {
227
+ /** append = 追加进既有 tab(锚 pane split);new = 新命名 tab。 */
228
+ mode: 'append' | 'new';
229
+ tabName: string;
230
+ /** append 时的既有 tabId(须调用方再校验存活);new 时为 null。 */
231
+ tabId: string | null;
232
+ }
233
+
234
+ /* ── D86:worktree 分组键(纯逻辑) ─────────────────────────────── */
235
+
236
+ /** 路径包含判定(大小写不敏感 + 斜杠统一;cwd === wt 或 cwd 在 wt 之下)。D86 信任旗标也用它。 */
237
+ export function isPathUnder(cwd: string, wt: string): boolean {
238
+ const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
239
+ const a = n(cwd);
240
+ const b = n(wt);
241
+ return a === b || a.startsWith(`${b}/`);
242
+ }
243
+
244
+ export interface WorktreeZone {
245
+ /** main = 主检出(split 进 main tab);worktree = 命名 worktree tab;非 git/无关目录也兜底 main。 */
246
+ zone: 'main' | 'worktree';
247
+ /** zone=worktree 时的 tab 名(目录名原样,撞名后缀由 nextTaskTabName 处理)。 */
248
+ tabName: string | null;
249
+ }
250
+
251
+ /**
252
+ * D86 分类器:cwd 落在哪个 worktree 分组。
253
+ * `worktrees` = git porcelain 顺序(主检出在前,仅用于列表本身;归属判定按包含);
254
+ * 非 git / 不属任何 worktree → main(「无新 worktree → 同 tab」的兜底)。
255
+ */
256
+ export function classifyWorktreeZone(opts: {
257
+ cwd: string;
258
+ masterCwd: string;
259
+ worktrees: readonly string[];
260
+ }): WorktreeZone {
261
+ for (const wt of opts.worktrees) {
262
+ if (!isPathUnder(opts.cwd, wt)) continue;
263
+ if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null }; // 主检出 → main tab
264
+ const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
265
+ return { zone: 'worktree', tabName: base };
266
+ }
267
+ return { zone: 'main', tabName: null };
268
+ }
269
+
270
+ /**
271
+ * 放置决策(D26 显式层 + D86 缺省层):
272
+ * - 显式 `tab` 参数:规范名 → 已知 tab 同名则 append(用其 tabId),否则 new;
273
+ * - 缺省(D86):`zone` 给出分组——main → append 进 mainTabId;worktree →
274
+ * join-or-create 该目录名 tab(已知同名 append,否则 new)。
275
+ * `knownTabs` = 调用时刻的存活 tab(label, id)。
276
+ */
277
+ export function planTabPlacement(opts: {
278
+ desiredTab?: string | null;
279
+ description: string;
280
+ knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
281
+ /** D86 缺省分组(classifyWorktreeZone 输出;不传 = 旧 description 派生路径,兼容用)。 */
282
+ zone?: WorktreeZone;
283
+ /** D86 main 分组的目标 tab(master 所在 tab)。 */
284
+ mainTabId?: string | null;
285
+ }): TabPlacementPlan {
286
+ const known = new Map<string, string>();
287
+ for (const t of opts.knownTabs) if (t?.tabName && t?.tabId) known.set(t.tabName, t.tabId);
288
+ if (opts.desiredTab != null && String(opts.desiredTab).trim() !== '') {
289
+ const tabName = tabNameForTask(String(opts.desiredTab));
290
+ const tabId = known.get(tabName) ?? null;
291
+ return tabId ? { mode: 'append', tabName, tabId } : { mode: 'new', tabName, tabId: null };
292
+ }
293
+ const zone = opts.zone;
294
+ if (zone?.zone === 'main' && opts.mainTabId) {
295
+ // D86 R1:主检出 → split 进 main tab(锚 pane 由调用方选)
296
+ return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
297
+ }
298
+ if (zone?.zone === 'worktree' && zone.tabName) {
299
+ // D86 R1:worktree → join-or-create 目录名 tab(原样 label,大小写不敏感撞名 join)
300
+ const base = zone.tabName;
301
+ const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
302
+ if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
303
+ const tabName = nextTaskTabName(base, new Set(known.keys()));
304
+ return { mode: 'new', tabName, tabId: null };
305
+ }
306
+ // 兜底(无 zone 信息):由 description 派生名 + 撞名加序号后缀 → 永远 new。
307
+ const base = tabNameForTask(opts.description);
308
+ const tabName = nextTaskTabName(base, new Set(known.keys()));
309
+ return { mode: 'new', tabName, tabId: null };
310
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 档 0:master 进程内每个 subagent 一个 cordis fiber。
3
+ * pipe / poller / history 的清理走 ctx.effect,dispose LIFO。
4
+ * C3:worker 进程不引入本模块。
5
+ */
6
+ import { Context } from '@deepseek-ai/cordis';
7
+
8
+ export interface ScopeHooks {
9
+ onDispose?: () => void;
10
+ }
11
+
12
+ export function createSessionRoot(hooks: ScopeHooks = {}): Context {
13
+ const root = new Context();
14
+ if (hooks.onDispose) {
15
+ root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
16
+ }
17
+ return root;
18
+ }
19
+
20
+ export async function mountSubagentScope(
21
+ root: Context,
22
+ paneId: string,
23
+ hooks: ScopeHooks = {},
24
+ ) {
25
+ return root.plugin({
26
+ name: `subagent:${paneId}`,
27
+ apply(ctx: Context) {
28
+ ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
29
+ },
30
+ }, { paneId });
31
+ }
32
+
33
+ export async function disposeSessionRoot(root: Context): Promise<void> {
34
+ try {
35
+ await root.fiber.dispose();
36
+ } catch {
37
+ /* 二次 dispose 是 no-op */
38
+ }
39
+ }
@@ -0,0 +1,328 @@
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
+ */
18
+ import { basename } from 'node:path';
19
+
20
+ /* ── 常量(v1 默认;env 可调点在 adapter 读) ───────────────────── */
21
+
22
+ export const TERMINALS_CUSTOM_TYPE = 'pi-herdr.terminals';
23
+ export const MAX_TERMINALS = 8;
24
+ export const READ_MAX_CHARS = 8000;
25
+ /** 读返回的硬截断长度(含截断标记自身的预算)。 */
26
+ export const READ_HARD_CAP_CHARS = 200;
27
+ export const READINESS_TIMEOUT_MS = 30_000;
28
+ export const SILENCE_IDLE_MS = 2000;
29
+ /** T3 PS1 尾匹配:PowerShell `>` / POSIX `$` `#` / Nushell `❯` 结尾(允许尾空白)。 */
30
+ export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
31
+ /** T6 signal 白名单(Herdr key-combo 字符串;ctrl+c 中断 / ctrl+d EOF / ctrl+z 挂起 / esc / enter)。 */
32
+ export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
33
+ export type SignalKey = (typeof SIGNAL_KEYS)[number];
34
+
35
+ const FULLSCREEN_SEQUENCES = [
36
+ '\x1b[?1049h', // 进入 alternate screen(xterm)
37
+ '\x1b[?47h', // 老式进入 alternate screen
38
+ '\x1b[2J\x1b[H', // 清屏 + 光标归位(vim/less 常见模式)
39
+ ] as const;
40
+
41
+ export const RESET_MARKER = '…[buffer reset — earlier output scrolled away or cleared]…';
42
+ export const TRUNCATE_MARKER = '…[truncated]…';
43
+
44
+ /* ── 类型 ─────────────────────────────────────────────────────────── */
45
+
46
+ export interface TerminalEntry {
47
+ terminalId: string;
48
+ paneId: string;
49
+ tabId: string;
50
+ cwd: string;
51
+ label: string | null;
52
+ createdAt: number;
53
+ lastActivityAt: number;
54
+ status: 'open' | 'closed';
55
+ closedAt: number | null;
56
+ /** pane.read 游标:revision(展示)+ 稳定区长度 + 尾段锚 + 缓冲尾探测器(增量判定用)。 */
57
+ readRevision: number | null;
58
+ readLen: number;
59
+ readTail: string;
60
+ readEoTail: string;
61
+ }
62
+
63
+ export interface TerminalsRegistry {
64
+ version: 1;
65
+ terminals: TerminalEntry[];
66
+ }
67
+
68
+ /* ── 注册表 ───────────────────────────────────────────────────────── */
69
+
70
+ export function nextTerminalId(existingIds: readonly string[]): string {
71
+ let max = 0;
72
+ for (const id of existingIds) {
73
+ const m = /^term-(\d+)$/.exec(id);
74
+ if (m) max = Math.max(max, Number(m[1]));
75
+ }
76
+ return `term-${max + 1}`;
77
+ }
78
+
79
+ export function registerTerminal(
80
+ entries: readonly TerminalEntry[],
81
+ opts: {
82
+ paneId: string;
83
+ tabId: string;
84
+ cwd: string;
85
+ label?: string | null;
86
+ createdAt: number;
87
+ },
88
+ ): { ok: true; entries: TerminalEntry[]; entry: TerminalEntry } | { ok: false; error: string } {
89
+ const openCount = entries.filter((e) => e.status === 'open').length;
90
+ if (openCount >= MAX_TERMINALS) {
91
+ return {
92
+ ok: false,
93
+ error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal_close before opening another`,
94
+ };
95
+ }
96
+ const entry: TerminalEntry = {
97
+ terminalId: nextTerminalId(entries.map((e) => e.terminalId)),
98
+ paneId: opts.paneId,
99
+ tabId: opts.tabId,
100
+ cwd: opts.cwd,
101
+ label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
102
+ createdAt: opts.createdAt,
103
+ lastActivityAt: opts.createdAt,
104
+ status: 'open',
105
+ closedAt: null,
106
+ readRevision: null,
107
+ readLen: 0,
108
+ readTail: '',
109
+ readEoTail: '',
110
+ };
111
+ return { ok: true, entries: [...entries, entry], entry };
112
+ }
113
+
114
+ /** 幂等关闭(closedAt 不被二次覆盖);不存在的 id 原样返回。 */
115
+ export function closeTerminal(
116
+ entries: readonly TerminalEntry[],
117
+ terminalId: string,
118
+ now: number,
119
+ ): { entries: TerminalEntry[] } {
120
+ return {
121
+ entries: entries.map((e) =>
122
+ e.terminalId === terminalId && e.status === 'open'
123
+ ? { ...e, status: 'closed' as const, closedAt: now }
124
+ : e,
125
+ ),
126
+ };
127
+ }
128
+
129
+ /** 活跃(open)终端的 paneId 集合——GC 豁免判据(D71)。 */
130
+ export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
131
+ return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
132
+ }
133
+
134
+ /* ── T6:send 文本校验 / signal 白名单 ───────────────────────────── */
135
+
136
+ export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
137
+ if (text.includes('\x1b')) {
138
+ return {
139
+ ok: false,
140
+ error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal_signal for control keys, or plain commands only',
141
+ };
142
+ }
143
+ // 允许 \t;拒绝其余 C0 控制字符(\r\n 由发送方统一补 Enter,先剥尾部)
144
+ const stripped = text.replace(/[\r\n]+$/, '');
145
+ const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
146
+ if (bad) {
147
+ return {
148
+ ok: false,
149
+ error: `unsupported input: control character 0x${bad[0].charCodeAt(0).toString(16)}; terminal_send accepts plain text commands only (use terminal_signal for ctrl+c etc.)`,
150
+ };
151
+ }
152
+ return { ok: true, text: stripped };
153
+ }
154
+
155
+ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok: false; error: string } {
156
+ const k = (SIGNAL_KEYS as readonly string[]).includes(key) ? (key as SignalKey) : null;
157
+ if (!k) {
158
+ return {
159
+ ok: false,
160
+ error: `unsupported signal "${key}"; allowed: ${SIGNAL_KEYS.join(', ')} (complex key sequences are outside the terminal seam — use herdr's native pane for interactive programs)`,
161
+ };
162
+ }
163
+ return { ok: true, key: k };
164
+ }
165
+
166
+ /* ── T6:全屏 TUI 检测 + ANSI 剥离 ───────────────────────────────── */
167
+
168
+ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
169
+ for (const seq of FULLSCREEN_SEQUENCES) {
170
+ if (raw.includes(seq)) return { detected: true, sequence: seq };
171
+ }
172
+ return { detected: false, sequence: null };
173
+ }
174
+
175
+ export function stripAnsi(raw: string): string {
176
+ return raw
177
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI 序列
178
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC 序列
179
+ .replace(/\x1b[@-Z\\-_]/g, '') // 其余两字符转义
180
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); // 残余控制字符
181
+ }
182
+
183
+ /* ── 读增量(环形缓冲语义) ───────────────────────────────────────── */
184
+
185
+ export interface ReadCursor {
186
+ revision: number;
187
+ /** 已消费长度(落在最后完整行边界:lastIndexOf('\n')+1;活动行不占)。 */
188
+ len: number;
189
+ /** 连续性锚:稳定区尾段(≤64 字符;与下次读按 trimEnd 比较——活动行行尾 \n 会变空格,实测)。 */
190
+ tail: string;
191
+ /** 无变化探测器:上次整个缓冲的尾段(trimEnd;相同 → none)。 */
192
+ eoTail: string;
193
+ }
194
+
195
+ export interface ReadIncrement {
196
+ mode: 'none' | 'append' | 'reset';
197
+ text: string;
198
+ cursor: ReadCursor;
199
+ /** 达到硬上限被截断(调用方应提示分页)。 */
200
+ hardCapped: boolean;
201
+ }
202
+
203
+ /** 游标指纹长度(tail 采样上限)。 */
204
+ const CURSOR_TAIL_CHARS = 64;
205
+
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
+ export function computeIncrement(
213
+ prev: ReadCursor | null,
214
+ next: { text: string; revision: number },
215
+ maxChars: number,
216
+ ): ReadIncrement {
217
+ const stableLen = next.text.lastIndexOf('\n') + 1;
218
+ const cursor: ReadCursor = {
219
+ revision: next.revision,
220
+ len: stableLen,
221
+ tail: next.text.slice(Math.max(0, stableLen - CURSOR_TAIL_CHARS), stableLen),
222
+ eoTail: next.text.slice(-CURSOR_TAIL_CHARS).trimEnd(),
223
+ };
224
+ if (prev && next.text.slice(-CURSOR_TAIL_CHARS).trimEnd() === prev.eoTail) {
225
+ return { mode: 'none', text: '', cursor: prev, hardCapped: false };
226
+ }
227
+ if (
228
+ prev &&
229
+ next.text.length >= prev.len &&
230
+ next.text.slice(Math.max(0, prev.len - prev.tail.length), prev.len).trimEnd() === prev.tail.trimEnd()
231
+ ) {
232
+ const suffix = next.text.slice(prev.len);
233
+ const bounded = boundText(suffix, maxChars);
234
+ return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
235
+ }
236
+ // 首读(prev=null)= 有界全量;缓冲回卷/清屏 = 有界全量 + reset 标记
237
+ const bounded = boundText(next.text, maxChars);
238
+ const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
239
+ return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
240
+ }
241
+
242
+ function boundText(text: string, maxChars: number): { text: string; capped: boolean } {
243
+ if (text.length <= maxChars) return { text, capped: false };
244
+ return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
245
+ }
246
+
247
+ /* ── T3 readiness(两档 + busy;超时兜底在 adapter) ─────────────── */
248
+
249
+ export type ReadinessTier = 'prompt' | 'silent' | 'busy';
250
+
251
+ export function classifyReadiness(
252
+ rawOrStripped: string,
253
+ opts: { silentMs: number; silenceThresholdMs?: number },
254
+ ): ReadinessTier {
255
+ const tail = rawOrStripped.trimEnd();
256
+ if (tail.length > 0 && PROMPT_TAIL_RE.test(tail)) return 'prompt';
257
+ if (opts.silentMs >= (opts.silenceThresholdMs ?? SILENCE_IDLE_MS)) return 'silent';
258
+ return 'busy';
259
+ }
260
+
261
+ /* ── 会话汇总(T6 跨重启边界) ───────────────────────────────────── */
262
+
263
+ export interface TerminalSummary {
264
+ terminalId: string;
265
+ paneId: string;
266
+ cwd: string;
267
+ label: string | null;
268
+ status: 'open' | 'closed';
269
+ live: boolean;
270
+ lastActivityAt: number;
271
+ }
272
+
273
+ export function summarizeSessions(
274
+ entries: readonly TerminalEntry[],
275
+ livePaneIds: readonly string[],
276
+ ): { terminals: TerminalSummary[]; stalePaneIds: string[] } {
277
+ const live = new Set(livePaneIds);
278
+ const terminals: TerminalSummary[] = entries.map((e) => ({
279
+ terminalId: e.terminalId,
280
+ paneId: e.paneId,
281
+ cwd: e.cwd,
282
+ label: e.label,
283
+ status: e.status,
284
+ live: e.status === 'open' && live.has(e.paneId),
285
+ lastActivityAt: e.lastActivityAt,
286
+ }));
287
+ const stalePaneIds = terminals.filter((t) => t.status === 'open' && !t.live).map((t) => t.paneId);
288
+ return { terminals, stalePaneIds };
289
+ }
290
+
291
+ /* ── 持久化(custom 条目 last-wins,与 subs 同构) ────────────────── */
292
+
293
+ export interface BranchEntryLike3 {
294
+ type?: string;
295
+ customType?: string;
296
+ data?: unknown;
297
+ }
298
+
299
+ export function makeTerminalsRegistry(terminals: TerminalEntry[] = []): TerminalsRegistry {
300
+ return { version: 1, terminals };
301
+ }
302
+
303
+ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): TerminalEntry[] {
304
+ let found: TerminalEntry[] = [];
305
+ for (const entry of entries) {
306
+ if (entry.type !== 'custom' || entry.customType !== TERMINALS_CUSTOM_TYPE) continue;
307
+ const data = entry.data as { terminals?: Array<Partial<TerminalEntry> & { paneId: string }> } | undefined;
308
+ if (!data || !Array.isArray(data.terminals)) continue;
309
+ found = data.terminals
310
+ .filter((t) => typeof t?.paneId === 'string')
311
+ .map((t) => ({
312
+ terminalId: typeof t.terminalId === 'string' ? t.terminalId : `term-${t.paneId}`,
313
+ paneId: t.paneId,
314
+ tabId: typeof t.tabId === 'string' ? t.tabId : '',
315
+ cwd: typeof t.cwd === 'string' ? t.cwd : '',
316
+ label: typeof t.label === 'string' ? t.label : null,
317
+ createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
318
+ lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
319
+ status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
320
+ closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
321
+ readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
322
+ readLen: typeof t.readLen === 'number' ? t.readLen : 0,
323
+ readTail: typeof t.readTail === 'string' ? t.readTail : '',
324
+ readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
325
+ }));
326
+ }
327
+ return found;
328
+ }