pi-pier 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +113 -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
@@ -1,44 +1,43 @@
1
1
  /**
2
- * 档2 Week1:role manifest 类型 + 校验器(D76 v2;2026-08-18)。
2
+ * Layer 2 Week 1: role manifest types and validator (D76 v2; 2026-08-18).
3
3
  *
4
- * 契约镜像 `schemas/role-manifest.schema.json`(JSON Schema 文件是合同文档,
5
- * 运行时用零依赖手写校验——不引 ajv,worker 侧不背包袱)。
4
+ * Mirrors the contract in `schemas/role-manifest.schema.json` (the JSON Schema is the contract document;
5
+ * runtime validation is hand-written with zero dependencies so workers carry no ajv overhead).
6
6
  *
7
- * 严格契约:未知顶层键报 issue(防 "rulez" 类 typo 静默失效);
8
- * 错误收集式(一次报全,便于修档案)。
7
+ * Strict contract: report unknown top-level keys to prevent typos such as "rulez" from silently failing;
8
+ * collect all errors in one pass so manifests can be repaired efficiently.
9
9
  */
10
10
  export type PermissionAction = 'allow' | 'ask' | 'deny';
11
11
  export type TodosMode = 'serial' | 'parallel';
12
- /** D82:未知工具姿态——可见性=信任关系(用户→master 凭 install;master→被委派凭镜像)。 */
12
+ /** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
13
13
  export type UnknownToolStance = 'allow' | 'deny';
14
14
 
15
15
  export interface RoleManifest {
16
16
  role: string;
17
- /** 语义版本 x.y.z(档案演化追溯;P2)。 */
17
+ /** Semantic version x.y.z (for tracing manifest evolution; P2). */
18
18
  version: string;
19
19
  /**
20
- * WS-D10:按角色路由模型——`provider/model` 形态(advisor = 高级别第三方判断)。
21
- * 可省略 = 跟随进程默认(master/worker 不设)。spawn 时注入 `--provider/--model`。
20
+ * WS-D10: route models by role using `provider/model` (advisor delegates higher-level third-party judgment).
21
+ * Omitted means follow the process default (master/worker leave it unset); spawn injects `--provider/--model`.
22
22
  */
23
23
  model?: string;
24
24
  description?: string;
25
25
  manifest: {
26
- /** 基线工具(非空,必含 todo_write + ask_user_question——协调工具)。 */
26
+ /** Baseline tools (non-empty; must include todo_write + ask_user_question for coordination). */
27
27
  tools: string[];
28
- /** 三态权限;`*` 通配符默认;可省略 = `{"*":"allow"}`。 */
28
+ /** Three-state permissions; `*` supplies the default; omitted means `{"*":"allow"}`. */
29
29
  rules?: Record<string, PermissionAction>;
30
30
  /**
31
- * D82 unknownTools:清单外工具的姿态(缺省 deny = 安全默认)。
32
- * master/worker = allow(用户安装的扩展默认可见可用——install 即授予);
33
- * 自定义角色缺省 deny。与 rules 的 `*` 通配分属两轴:可见性 vs 强制力。
34
- * 排除族(如 worker 的 subagent/terminal)须显式 deny 才能在 allow 姿态下持禁令。
31
+ * D82 unknownTools: stance for tools outside the manifest (default deny is the safe default).
32
+ * master/worker = allow (user-installed extensions are visible and usable by default—install grants access);
33
+ * custom roles default to deny. `*` in rules and this stance cover separate axes: visibility vs enforcement.
34
+ * Excluded families (such as worker's subagent/terminal) need explicit deny to remain blocked under allow.
35
35
  */
36
36
  unknownTools?: UnknownToolStance;
37
37
  };
38
38
  services?: {
39
39
  todos?: {
40
40
  mode?: TodosMode;
41
- reminderLimit?: number;
42
41
  };
43
42
  };
44
43
  }
@@ -47,7 +46,7 @@ export type ValidateResult =
47
46
  | { ok: true; value: RoleManifest }
48
47
  | { ok: false; code: 'INVALID_ROLE_CONFIG'; issues: string[] };
49
48
 
50
- /** 所有角色必需的协调工具(C7 边界约束 1)。 */
49
+ /** Coordination tools required by every role (C7 boundary constraint 1). */
51
50
  export const COORDINATION_TOOLS = ['todo_write', 'ask_user_question'] as const;
52
51
 
53
52
  const ROLE_NAME_RE = /^[a-z0-9-]+$/;
@@ -69,30 +68,30 @@ export function validateRoleManifest(input: unknown): ValidateResult {
69
68
  return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
70
69
  }
71
70
 
72
- /* ── 顶层键 ── */
71
+ /* ── Top-level keys ── */
73
72
  const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
74
73
  for (const k of Object.keys(input)) {
75
74
  if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
76
75
  }
77
76
 
78
- /* ── role 名 ── */
77
+ /* ── role name ── */
79
78
  if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
80
79
  issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
81
80
  }
82
81
 
83
- /* ── version(P2 semver) ── */
82
+ /* ── version (P2 semver) ── */
84
83
  if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
85
84
  issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
86
85
  }
87
86
 
88
- /* ── model(WS-D10:provider/model 按角色路由;可选) ── */
87
+ /* ── model (WS-D10: provider/model role routing; optional) ── */
89
88
  if (input.model !== undefined) {
90
89
  if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
91
90
  issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
92
91
  }
93
92
  }
94
93
 
95
- /* ── description 可选 ── */
94
+ /* ── optional description ── */
96
95
  if (input.description !== undefined && typeof input.description !== 'string') {
97
96
  issues.push('description 必须是字符串');
98
97
  }
@@ -107,12 +106,12 @@ export function validateRoleManifest(input: unknown): ValidateResult {
107
106
  if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
108
107
  }
109
108
 
110
- // D82 unknownTools:enum 校验,缺省 = deny(安全默认)
109
+ // D82 unknownTools: enum validation; omitted means deny (safe default).
111
110
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
112
111
  issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
113
112
  }
114
113
 
115
- // tools:字符串数组、无重复、非空、含协调工具(空数组也要报缺协调工具)
114
+ // tools must be a unique, non-empty string array containing coordination tools; an empty array also reports the omissions.
116
115
  const tools = m.tools;
117
116
  if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
118
117
  issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
@@ -127,7 +126,7 @@ export function validateRoleManifest(input: unknown): ValidateResult {
127
126
  }
128
127
  }
129
128
 
130
- // rules:三态值;键 = 工具名或 *
129
+ // rules accept three-state values; each key must be a tool name or *.
131
130
  if (m.rules !== undefined) {
132
131
  if (!isPlainObject(m.rules)) {
133
132
  issues.push('manifest.rules 必须是对象');
@@ -154,9 +153,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
154
153
  if (t.mode !== undefined && t.mode !== 'serial' && t.mode !== 'parallel') {
155
154
  issues.push(`services.todos.mode 必须是 serial/parallel,收到 ${JSON.stringify(t.mode)}`);
156
155
  }
157
- if (t.reminderLimit !== undefined && !isPosInt(t.reminderLimit)) {
158
- issues.push(`services.todos.reminderLimit 必须是正整数,收到 ${JSON.stringify(t.reminderLimit)}`);
159
- }
160
156
  }
161
157
  }
162
158
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "role": "master",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "主控角色(WS-D7 自应用;D83 镜像源):自研核心全量(todos/协调/subagent 族/终端族)+ pi 内建检视执行 + web_search;D82 unknownTools=allow——用户安装的任意扩展默认可见可用(install 即授予)",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
@@ -16,6 +16,6 @@
16
16
  }
17
17
  },
18
18
  "services": {
19
- "todos": { "mode": "parallel", "reminderLimit": 5 }
19
+ "todos": { "mode": "parallel" }
20
20
  }
21
21
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "role": "worker-default",
3
- "version": "1.3.0",
4
- "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial/3",
3
+ "version": "1.3.1",
4
+ "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
@@ -25,6 +25,6 @@
25
25
  }
26
26
  },
27
27
  "services": {
28
- "todos": { "mode": "serial", "reminderLimit": 3 }
28
+ "todos": { "mode": "serial" }
29
29
  }
30
30
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Centralized runtime policy and timeouts.
3
+ *
4
+ * Why: Prevents drift between hardcoded literals and environment overrides;
5
+ * makes timing behavior explicit and testable.
6
+ */
7
+
8
+ export interface RuntimePolicy {
9
+ /** Subagent overall timeout (ms) */
10
+ readonly subagentTimeoutMs: number
11
+ /** Subagent GC tick interval (ms) */
12
+ readonly gcTickMs: number
13
+ /** Poll interval for subagent state observation (ms) */
14
+ readonly pollIntervalMs: number
15
+ /** Stale check cadence (turns) */
16
+ readonly staleCadenceTurns: number
17
+ /** Settlement notice window / machine-inject grace / takeover idle (ms) */
18
+ readonly settlementWindowMs: number
19
+ /** Post-settle observation window before auto-consume (ms) */
20
+ readonly observationWindowMs: number
21
+ /** Foreground patience before background promotion (ms) */
22
+ readonly foregroundPatienceMs: number
23
+ /** Default session TTL (seconds) */
24
+ readonly sessionTtlSeconds: number
25
+ /** Terminal read maximum bytes */
26
+ readonly terminalReadMaxBytes: number
27
+ /** Git operation timeout (ms) */
28
+ readonly gitTimeoutMs: number
29
+ /** Subagent pane pipe readiness wait (ms) */
30
+ readonly readinessTimeoutMs: number
31
+ }
32
+
33
+ function parseEnvInt(key: string, defaultValue: number, min: number = 0): number {
34
+ const raw = process.env[key]
35
+ if (!raw) return defaultValue
36
+
37
+ const parsed = Number.parseInt(raw, 10)
38
+ if (!Number.isFinite(parsed) || parsed < min) {
39
+ console.warn(`Invalid ${key}="${raw}", using default ${defaultValue}`)
40
+ return defaultValue
41
+ }
42
+
43
+ return parsed
44
+ }
45
+
46
+ export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): RuntimePolicy {
47
+ return {
48
+ subagentTimeoutMs: overrides?.subagentTimeoutMs ?? parseEnvInt('PIER_SUBAGENT_TIMEOUT_MS', 600_000, 1000),
49
+ gcTickMs: overrides?.gcTickMs ?? parseEnvInt('PIER_GC_TICK_MS', 30_000, 1000),
50
+ pollIntervalMs: overrides?.pollIntervalMs ?? parseEnvInt('PIER_POLL_INTERVAL_MS', 30_000, 1000),
51
+ staleCadenceTurns: overrides?.staleCadenceTurns ?? parseEnvInt('PIER_STALE_CADENCE_TURNS', 3, 1),
52
+ settlementWindowMs: overrides?.settlementWindowMs ?? parseEnvInt('PIER_SETTLEMENT_WINDOW_MS', 60_000, 0),
53
+ observationWindowMs: overrides?.observationWindowMs ?? parseEnvInt('PIER_OBSERVATION_WINDOW_MS', 30_000, 0),
54
+ foregroundPatienceMs: overrides?.foregroundPatienceMs ?? parseEnvInt('PIER_FOREGROUND_PATIENCE_MS', 300_000, 0),
55
+ sessionTtlSeconds: overrides?.sessionTtlSeconds ?? parseEnvInt('PIER_SESSION_TTL_SECONDS', 600, 0),
56
+ terminalReadMaxBytes: overrides?.terminalReadMaxBytes ?? parseEnvInt('TERM_READ_MAX', 4096, 1),
57
+ gitTimeoutMs: overrides?.gitTimeoutMs ?? parseEnvInt('PIER_GIT_TIMEOUT_MS', 10_000, 1),
58
+ readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 30_000, 1000),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Singleton runtime policy for production use.
64
+ * Tests should inject via createRuntimePolicy().
65
+ */
66
+ export const runtimePolicy = createRuntimePolicy()
@@ -1,13 +1,17 @@
1
1
  /**
2
- * pi 会话 JSONL 尾部解析(纯函数库,v1.1 结果通道)。
2
+ * Tail parsing for pi session JSONL (pure library, v1.1 result channel).
3
3
  *
4
- * v1.1(DESIGN.md §12)起,子代理的结果不再从 pane 文本解析,而是读子代理的
5
- * 会话文件(pi 会话 JSONL,权威、机器精确、无回声污染/滚动盲区)。
6
- * 本模块只依赖 pi 会话格式(session-format 文档实测形状):
7
- * 行 = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
4
+ * Since v1.1 (DESIGN.md §12), child-agent results come from the child session file
5
+ * rather than pane text, keeping the authoritative machine-readable source free of
6
+ * echo contamination and scrollback blind spots. This module depends only on the
7
+ * empirically observed pi session format (session-format documentation):
8
+ * line = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
8
9
  */
9
10
  import * as fs from 'node:fs';
10
11
  import * as path from 'node:path';
12
+ import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
13
+
14
+ export { sessionDirName };
11
15
 
12
16
  export interface SessionMessageLike {
13
17
  role?: string;
@@ -21,8 +25,7 @@ export interface SessionEntryLike {
21
25
  message?: SessionMessageLike | unknown;
22
26
  [k: string]: unknown;
23
27
  }
24
-
25
- /** 容忍式逐行解析(损坏行/非 JSON 行跳过)。 */
28
+ /** Parse line by line tolerantly, skipping malformed or non-JSON records. */
26
29
  export function parseSessionEntries(text: string): SessionEntryLike[] {
27
30
  const entries: SessionEntryLike[] = [];
28
31
  for (const line of (text ?? '').split(/\r?\n/)) {
@@ -32,7 +35,7 @@ export function parseSessionEntries(text: string): SessionEntryLike[] {
32
35
  const obj = JSON.parse(t);
33
36
  if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
34
37
  } catch {
35
- /* 截断/损坏行跳过 */
38
+ /* Skip truncated or malformed records. */
36
39
  }
37
40
  }
38
41
  return entries;
@@ -58,10 +61,9 @@ function textOf(m: SessionMessageLike): string {
58
61
  .join('\n')
59
62
  .trim();
60
63
  }
61
-
62
64
  /**
63
- * 取最后一次"已定稿"的 assistant 文本(stopReason === 'stop',排除 toolUse 中间态)。
64
- * sinceTs 用于过滤注入时间点之前的旧消息。
65
+ * Return the latest finalized assistant text (stopReason === 'stop'), excluding toolUse intermediates.
66
+ * sinceTs filters out messages that predate the injection point.
65
67
  */
66
68
  export function lastAssistantText(
67
69
  entries: readonly SessionEntryLike[],
@@ -78,8 +80,7 @@ export function lastAssistantText(
78
80
  }
79
81
  return best;
80
82
  }
81
-
82
- /** 会话是否出现注入时间点之后的 assistant 消息(任意 stopReason,探测活跃)。 */
83
+ /** Return whether any assistant message occurs after the injection point, regardless of stopReason. */
83
84
  export function hasAssistantAfter(
84
85
  entries: readonly SessionEntryLike[],
85
86
  sinceTs: number,
@@ -89,10 +90,10 @@ export function hasAssistantAfter(
89
90
  return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
90
91
  });
91
92
  }
92
-
93
93
  /**
94
- * 注入后是否存在"已发起但尚无结果"的 toolCall(工具执行中,如 ask_user_question
95
- * 等人类输入)→ 未真正结算(v1.3 M8 结算竞态修复)。并行调用按深度计数。
94
+ * Return whether an initiated toolCall still lacks a result after injection (for example,
95
+ * ask_user_question waiting on human input), meaning settlement has not actually completed
96
+ * (v1.3 M8 settlement-race fix). Parallel calls are tracked by depth.
96
97
  */
97
98
  export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
98
99
  let depth = 0;
@@ -112,29 +113,27 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
112
113
  return depth > 0;
113
114
  }
114
115
 
115
- /** pi 会话目录名:cwd → `--F--herdr-pi--` 形态(实测命名约定)。 */
116
- export function sessionDirName(cwd: string): string {
117
- const flat = cwd.replace(/[\\/]/g, '-').replace(/:/g, '-');
118
- return `--${flat}--`;
119
- }
120
116
 
121
- /** cwd 会话目录下按 mtime 倒序取最新 limit 个会话文件(v1.3 M7 候选定位)。 */
117
+
118
+ /** Newest `limit` session files under cwd's session dir (new encoding, then legacy). */
122
119
  export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
123
- const dir = path.join(agentDir, sessionDirName(cwd));
124
- let names: string[];
125
- try {
126
- names = fs.readdirSync(dir);
127
- } catch {
128
- return [];
129
- }
130
120
  const files: Array<{ file: string; mtime: number }> = [];
131
- for (const name of names) {
132
- if (!name.endsWith('.jsonl')) continue;
133
- const full = path.join(dir, name);
121
+ for (const name of sessionDirCandidates(cwd)) {
122
+ const dir = path.join(agentDir, name);
123
+ let names: string[];
134
124
  try {
135
- files.push({ file: full, mtime: fs.statSync(full).mtimeMs });
125
+ names = fs.readdirSync(dir);
136
126
  } catch {
137
- /* 跳过消失文件 */
127
+ continue;
128
+ }
129
+ for (const fileName of names) {
130
+ if (!fileName.endsWith('.jsonl')) continue;
131
+ const full = path.join(dir, fileName);
132
+ try {
133
+ files.push({ file: full, mtime: fs.statSync(full).mtimeMs });
134
+ } catch {
135
+ /* vanished between readdir and stat */
136
+ }
138
137
  }
139
138
  }
140
139
  files.sort((a, b) => b.mtime - a.mtime);
@@ -142,20 +141,22 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
142
141
  }
143
142
 
144
143
  /**
145
- * 按 session id 定位会话文件(文件名形如 `<ts>_<id>.jsonl`,pi 实测命名)。
146
- * 官方 herdr 集成上报 agent_session 为 kind 'id' 时用它还原路径。
144
+ * Locate a session file by id (`<ts>_<id>.jsonl`).
145
+ * Searches the new encoding dir first, then the legacy flattened dir.
147
146
  */
148
147
  export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
149
- const dir = path.join(agentDir, sessionDirName(cwd));
150
148
  const suffix = `_${id}.jsonl`;
151
- let names: string[];
152
- try {
153
- names = fs.readdirSync(dir);
154
- } catch {
155
- return null;
156
- }
157
- for (const name of names) {
158
- if (name.endsWith(suffix)) return path.join(dir, name);
149
+ for (const name of sessionDirCandidates(cwd)) {
150
+ const dir = path.join(agentDir, name);
151
+ let names: string[];
152
+ try {
153
+ names = fs.readdirSync(dir);
154
+ } catch {
155
+ continue;
156
+ }
157
+ for (const fileName of names) {
158
+ if (fileName.endsWith(suffix)) return path.join(dir, fileName);
159
+ }
159
160
  }
160
161
  return null;
161
162
  }
@@ -1,40 +1,41 @@
1
1
  /**
2
- * settled 唤醒决策纯核心(反唤醒风暴;session 01a03bf0 实证)。
2
+ * Pure core for the settled-wake decision (prevents wake storms; evidenced by session 01a03bf0).
3
3
  *
4
- * 实证链:ESC 中止 → agent_settled → D96「仍有后台 subagent」提醒
5
- * sendUserMessage(followUp)(空闲时必触发新 run)→ master 重启 → 用户再 ESC → …
6
- * 30 连 abort,ESC 永远打不断,Ctrl+C 杀进程才停。
7
- * 且自然 settle 也有自激励环:settle → 提醒 → 新 run → settle → 提醒……
4
+ * Evidence chain: ESC abort → agent_settled → D96 "background subagents still running" notice
5
+ * → sendUserMessage(followUp) (always starts a new run while idle) → master restarts → user presses ESC again → …
6
+ * After 30 aborts, ESC never breaks the cycle; only Ctrl+C stops the process.
7
+ * Natural settlement also forms a self-triggering loop: settle → notice → new run → settle → notice …
8
8
  *
9
- * 规则:
10
- * - abort 抑制:上次 assistant stopReason === 'aborted' → 本次 settled 不做任何
11
- * 唤醒型注入(用户显式叫停;结算缓冲保留待下次自然 turn 投递);
12
- * - D96 去重:同一组 running paneId 只提醒一次;集合变化(新 sub / 结算)重置;
13
- * - D96 冷却:同集合重提醒至少间隔 REPEAT_NOTICE_MS(集合没变却再提醒 = 噪音)。
9
+ * Rules:
10
+ * - Abort suppression: when the previous assistant stopReason === 'aborted', this settled event injects no
11
+ * wake-up message (the user explicitly stopped it; settlement-buffered content waits for the next natural turn);
12
+ * - D96 deduplication: notify once for a given running paneId set; any set change (new subagent/settlement) resets it;
13
+ * - D96 cooldown: repeat a notice for the same set only after REPEAT_NOTICE_MS (otherwise the unchanged set is noise).
14
14
  */
15
+
15
16
  export const ABORT_STOP_REASON = 'aborted';
16
17
 
17
- /** D96:同一 running 集合的重提醒冷却。 */
18
+ /** D96: cooldown before repeating a notice for the same running set. */
18
19
  export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
19
20
 
20
21
  export interface SettleWakeInput {
21
- /** 本次 settled 前最后一次 assistant turn 的 stopReason(未知 = null,视作自然结束)。 */
22
+ /** stopReason of the last assistant turn before this settlement (unknown = null, treated as natural completion). */
22
23
  lastStopReason: string | null;
23
- /** 仍在 running 的后台 subagent(空数组 = 无)。 */
24
+ /** Background subagents still running (empty means none). */
24
25
  running: ReadonlyArray<{ paneId: string }>;
25
- /** 上次 D96 提醒时的集合键(无 = null)。 */
26
+ /** Set key recorded for the previous D96 notice (none = null). */
26
27
  lastNoticeKey: string | null;
27
- /** 上次 D96 提醒时间(epoch ms)。 */
28
+ /** Timestamp of the previous D96 notice (epoch ms). */
28
29
  lastNoticeAt: number;
29
30
  now: number;
30
31
  }
31
32
 
32
33
  export interface SettleWakePlan {
33
- /** false = 本次 settled 静默:不投任何唤醒型消息。 */
34
+ /** false means this settlement stays silent and emits no wake-up message. */
34
35
  wake: boolean;
35
- /** 是否注入 D96「仍在运行」提醒。 */
36
+ /** Whether to inject the D96 "still running" notice. */
36
37
  notice: boolean;
37
- /** 新的集合键(提醒时更新;running 空 → null)。 */
38
+ /** New set key (updated when notifying; empty running set → null). */
38
39
  noticeKey: string | null;
39
40
  noticeAt: number;
40
41
  }
package/src/slim-frame.ts CHANGED
@@ -1,47 +1,48 @@
1
1
  /**
2
- * D97 窄格静帧(slim frame):pane 长宽放不下最小可用 TUI 时,用非捕获
3
- * 全屏 overlay 盖一张 pane title 同源静帧。
2
+ * D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
3
+ * cover it with a non-capturing full-screen overlay containing a static frame sourced from the pane title.
4
4
  *
5
- * 动机(用户实证):热力把非焦点格压窄后,worker 的流式 thinking 在窄格
6
- * 里持续整屏重绘 → 闪烁。静帧只在 todo_write / 状态上报时更新(与
7
- * formatPaneTitle 同参同源),thinking token 碰不到它 → alt-screen 行级
8
- * 差分下 PTY 零输出,闪烁物理消失。
5
+ * Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
6
+ * continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
7
+ * todo_write/status reports (sharing arguments and source with formatPaneTitle), so thinking tokens cannot reach it;
8
+ * alt-screen row-level diffing then produces zero PTY output and physically eliminates the flicker.
9
9
  *
10
- * 前提:worker/master 以 --tui-mode fullscreen 运行(buildLaunchParts 默认
11
- * 注入)。regular 主屏的整屏 dump 路径(tui-main-screen firstChanged <
12
- * viewportTop → 全量重倒)overlay 盖不住——composite 在文档之上,dump 在
13
- * 文档之内。
10
+ * Prerequisite: worker/master run with --tui-mode fullscreen (injected by default by buildLaunchParts).
11
+ * The regular main-screen full dump path (tui-main-screen firstChanged < viewportTop → full redraw) cannot be
12
+ * covered by the overlay: composite is above the document, while the dump is inside the document.
14
13
  *
15
- * 注册闸:仅 herdr pane 内(index.ts 以 env 判定)——普通小终端窗口里盖住
16
- * 交互面是事故;herdr 窄格点击聚焦即被热力放大,visible 谓词随 SIGWINCH
17
- * 每帧重估,静帧自动消失、真 TUI 原样在底下(agent/会话/widget 从未停)。
14
+ * Registration gate: only inside a herdr pane (index.ts checks the environment), because covering an interactive
15
+ * normal terminal window would be hazardous. Clicking a narrow herdr pane focuses it and the heat view expands it;
16
+ * the visible predicate re-evaluates on every frame as SIGWINCH arrives, so the frame disappears automatically and
17
+ * the real TUI remains underneath (agent/session/widget execution never stops).
18
18
  *
19
- * 生命周期:进程内单例,session_start(非 resume)注册一次;overlay 常驻
20
- * (done() 永不调用 → Promise 永挂,吞 rejection,绝不 await)。pi 会话
21
- * 切换若拆掉 overlay(resetExtensionUI)则静帧自然退场,不影响主流程。
22
- * PI_HERDR_SLIM_FRAME=0 逃生口。
19
+ * Lifecycle: one process-local singleton, registered once on session_start (not resume); the overlay stays resident
20
+ * (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
21
+ * pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
22
+ * flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
23
23
  */
24
24
 
25
- /** TUI 可用下限(pi interactive-mode 布局:editor 3 行 + footer 1 + transcript ≥3 + 间距)。
26
- * 任一轴低于此值 → 该格读不了 TUI,画 title 静帧。 */
25
+ /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
26
+ * Below either threshold the pane cannot display a readable TUI, so it renders the title as a static frame. */
27
+
27
28
  export const SLIM_MIN_COLS = 24;
28
29
  export const SLIM_MIN_ROWS = 12;
29
30
 
30
- /** 静帧可见性谓词(overlayOptions.visible 每渲染帧回调)。 */
31
+ /** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
31
32
  export function isSlimFrame(cols: number, rows: number): boolean {
32
33
  return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
33
34
  }
34
35
 
35
- /* ── 宽度感知折行(保守 East Asian 宽度;▶○■✓ 等歧义宽字符按 1 记,仅影响折点位置) ── */
36
+ /* ── Width-aware wrapping (conservative East Asian width; ambiguous glyphs such as ▶○■✓ count as 1, affecting only break positions) ── */
36
37
 
37
38
  function isWide(cp: number): boolean {
38
39
  return (
39
- (cp >= 0x1100 && cp <= 0x115f) // 谚文兼容字母
40
- || (cp >= 0x2e80 && cp <= 0xa4cf) // CJK 部首~彝文(含假名)
41
- || (cp >= 0xac00 && cp <= 0xd7a3) // 谚文音节
42
- || (cp >= 0xf900 && cp <= 0xfaff) // CJK 兼容表意
43
- || (cp >= 0xfe30 && cp <= 0xfe4f) // CJK 兼容形式
44
- || (cp >= 0xff00 && cp <= 0xff60) // 全角形式
40
+ (cp >= 0x1100 && cp <= 0x115f) // Hangul compatibility jamo
41
+ || (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals through Yi (including kana)
42
+ || (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
43
+ || (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
44
+ || (cp >= 0xfe30 && cp <= 0xfe4f) // CJK compatibility forms
45
+ || (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
45
46
  || (cp >= 0xffe0 && cp <= 0xffe6)
46
47
  );
47
48
  }
@@ -53,8 +54,8 @@ export function displayWidth(s: string): number {
53
54
  }
54
55
 
55
56
  /**
56
- * 按可见宽度折行:词折行(行内有空格则退到最后一个空格断,英文短语不腰斩;
57
- * CJK 无空格自然硬折)。折行处吞掉断点空格。
57
+ * Wrap at visible width: prefer word breaks (retreat to the last space instead of cutting an English phrase);
58
+ * CJK text without spaces hard-wraps naturally. Consume the space at the break point.
58
59
  */
59
60
  export function wrapByWidth(text: string, width: number): string[] {
60
61
  const w = Math.max(1, Math.floor(width));
@@ -89,9 +90,9 @@ export function wrapByWidth(text: string, width: number): string[] {
89
90
  }
90
91
 
91
92
  /**
92
- * 静帧行:title 折行 → 垂直居中 → 钳到 rows。每行都补齐到满宽(可见宽度):
93
- * overlay 的合成按行覆盖,空行会让底下的 TUI 透出来(漏 flicker),
94
- * 满宽空格行才是真正的不透明冻结。无 title → 居中一枚 `·`(活着、没计划)。
93
+ * Frame lines: wrap the title, center it vertically, and clamp to rows. Pad every line to full visible width:
94
+ * row-wise overlay compositing would let the TUI underneath show through blank lines (leaking flicker),
95
+ * while full-width spaces make the freeze genuinely opaque. Without a title, center one `·` (alive, no plan).
95
96
  */
96
97
  export function frameLines(
97
98
  text: string,
@@ -114,7 +115,7 @@ export function frameLines(
114
115
  return out;
115
116
  }
116
117
 
117
- /* ── overlay 组件 + 注册/更新(进程内单例) ─────────────────────────── */
118
+ /* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
118
119
 
119
120
  interface FrameTui {
120
121
  requestRender(): void;
@@ -132,11 +133,11 @@ class SlimFrameComponent {
132
133
  this.colorize = colorize;
133
134
  }
134
135
 
135
- /** visible 谓词每帧回调:顺手记录行数(render(width) 拿不到高度,居中布局靠它)。 */
136
+ /** The visible predicate runs each frame; record row count because render(width) lacks height for centered layout. */
136
137
  onViewport(cols: number, rows: number): boolean {
137
138
  if (this.lastRows !== rows) {
138
139
  this.lastRows = rows;
139
- this.cache = null; // 行数变了 → 居中重排
140
+ this.cache = null; // A row-count change requires re-centering.
140
141
  }
141
142
  return isSlimFrame(cols, rows);
142
143
  }
@@ -159,14 +160,14 @@ class SlimFrameComponent {
159
160
  this.cache = null;
160
161
  }
161
162
 
162
- /** pi 拆 overlay(会话切换/退出)时回收模块单例,重注册不被 no-op。 */
163
+ /** Reclaim the module singleton when pi tears down the overlay (session switch/exit), allowing re-registration. */
163
164
  dispose(): void {
164
165
  if (active === this) active = null;
165
166
  }
166
167
  }
167
168
 
168
169
 
169
- /** 测试注入点(module state 重置)。 */
170
+ /** Test injection point (reset module state). */
170
171
  export function resetForTest(): void {
171
172
  active = null;
172
173
  }
@@ -174,8 +175,8 @@ export function resetForTest(): void {
174
175
  let active: SlimFrameComponent | null = null;
175
176
 
176
177
  /**
177
- * 注册静帧 overlay(幂等:进程一次)。eventCtx = pi 生命周期事件上下文
178
- * (session_start 的 ctx,与 todo widget 的 widgetUi 同来源)。
178
+ * Register the static-frame overlay (idempotent: once per process). eventCtx is the pi lifecycle event context
179
+ * (the session_start ctx, sourced from the same place as the todo widget's widgetUi).
179
180
  */
180
181
  export function registerSlimFrame(eventCtx: unknown): void {
181
182
  if (active) return;
@@ -187,8 +188,8 @@ export function registerSlimFrame(eventCtx: unknown): void {
187
188
  ? ui.custom
188
189
  : undefined;
189
190
  try {
190
- // overlay: true = 浮层不清屏(pi ctx.ui.custom 默认替换编辑器区,必须显式盖);
191
- // nonCapturing = 键盘仍归编辑器(人点进窄格也能打字,热力放大后静帧自动消失)。
191
+ // overlay: true keeps the floating layer from clearing the screen (pi ctx.ui.custom replaces the editor area by default, so cover it explicitly);
192
+ // nonCapturing leaves the keyboard with the editor (typing in a focused narrow pane remains possible, and the expanded heat view hides the frame).
192
193
  const pending = custom(
193
194
  (tui, theme) => {
194
195
  const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
@@ -206,14 +207,14 @@ export function registerSlimFrame(eventCtx: unknown): void {
206
207
  },
207
208
  },
208
209
  );
209
- // done() 永不调用 → Promise 永挂(overlay 常驻);吞 rejection,绝不 await。
210
+ // Never call done(), leaving the Promise pending so the overlay stays resident; swallow rejection and never await it.
210
211
  Promise.resolve(pending).catch(() => {});
211
212
  } catch {
212
- /* 无 overlay 能力的 pi 版本静默降级 */
213
+ /* Silently degrade on pi versions without overlay support. */
213
214
  }
214
215
  }
215
216
 
216
- /** 静帧内容更新(title 与 pane 标题投影同参同源;null = 无计划)。 */
217
+ /** Update static-frame content (same arguments/source as the pane-title projection; null means no plan). */
217
218
  export function updateSlimFrame(title: string | null): void {
218
219
  active?.setText(title ?? '');
219
220
  }