pi-pier 0.4.1 → 0.4.3

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 (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
package/src/renderers.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * needed and older pi builds degrade to "no renderer".
5
5
  */
6
6
  import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
7
- import { SUBS_CUSTOM_TYPE, PROGRESS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
7
+ import { SUBS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
8
8
  import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
9
9
  import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
10
10
  import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
@@ -159,17 +159,6 @@ function approvalLines(data: unknown, theme: RenderTheme): string[] {
159
159
  return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
160
160
  }
161
161
 
162
- /** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
163
- * pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
164
- function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
165
- const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
166
- const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
167
- const content = typeof rec.content === 'string' ? rec.content : '';
168
- const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
169
- if (!expanded) return [head];
170
- return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
171
- }
172
-
173
162
  /** Reminder custom messages carry the full injected text; restyle without altering it. */
174
163
  function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
175
164
  const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
@@ -215,7 +204,6 @@ export function installRenderers(pi: unknown): string[] {
215
204
  entry(DELEGATION_CUSTOM_TYPE, delegationLines);
216
205
  entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
217
206
  entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
218
- message(PROGRESS_CUSTOM_TYPE, progressLines);
219
207
  message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
220
208
  message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
221
209
  return registered;
@@ -98,7 +98,7 @@ export function listRoleNames(baseDir?: string, userDir?: string): string[] {
98
98
 
99
99
  export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManifest {
100
100
  if (typeof name !== 'string' || !ROLE_NAME_RE.test(name)) {
101
- throw new RoleLoaderError('ROLE_NOT_FOUND', `role 不存在或名字非法([a-z0-9-]+): ${JSON.stringify(name)}`);
101
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role not found or invalid name ([a-z0-9-]+): ${JSON.stringify(name)}`);
102
102
  }
103
103
  const fileName = `${name}.json`;
104
104
 
@@ -108,7 +108,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
108
108
  text = opts.read(fileName);
109
109
  } catch (err) {
110
110
  const code = (err as { code?: unknown })?.code;
111
- throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}" 不存在(读 ${fileName} 失败: ${typeof code === 'string' ? code : 'error'})`);
111
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}" not found (failed to read ${fileName}: ${typeof code === 'string' ? code : 'error'})`);
112
112
  }
113
113
  return parseAndValidate(name, fileName, text, 'builtin (src/roles/)');
114
114
  }
@@ -123,7 +123,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
123
123
  if (layerRead(layer.dir, fileName) != null) {
124
124
  throw new RoleLoaderError(
125
125
  'ROLE_RESERVED',
126
- `role "${name}" 是内置保留名,不能在 ${layer.label} 层定义(内置档案优先;请删除该档案或改名)`,
126
+ `role "${name}" is a reserved built-in name and cannot be defined in the ${layer.label} layer (built-in manifests take precedence; delete that file or rename it)`,
127
127
  );
128
128
  }
129
129
  }
@@ -136,7 +136,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
136
136
  }
137
137
  throw new RoleLoaderError(
138
138
  'ROLE_NOT_FOUND',
139
- `role "${name}" 不存在(查找层:${layers.map((l) => l.label).join(' → ')})`,
139
+ `role "${name}" not found (searched layers: ${layers.map((l) => l.label).join(' → ')})`,
140
140
  );
141
141
  }
142
142
 
@@ -145,22 +145,22 @@ function parseAndValidate(name: string, fileName: string, text: string, layerLab
145
145
  try {
146
146
  parsed = JSON.parse(text);
147
147
  } catch (err) {
148
- throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}"(${layerLabel})不是合法 JSON: ${(err as Error).message}`);
148
+ throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}" (${layerLabel}) is not valid JSON: ${(err as Error).message}`);
149
149
  }
150
150
  const result = validateRoleManifest(parsed);
151
151
  // `ok === false` (instead of `!ok`) is required to narrow without strictNullChecks.
152
152
  if (result.ok === false) {
153
153
  throw new RoleLoaderError(
154
154
  'INVALID_ROLE_CONFIG',
155
- `role "${name}"(${layerLabel})校验失败:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
155
+ `role "${name}" (${layerLabel}) failed validation:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
156
156
  result.issues,
157
157
  );
158
158
  }
159
159
  if (result.value.role !== name) {
160
160
  throw new RoleLoaderError(
161
161
  'INVALID_ROLE_CONFIG',
162
- `role 档案名不符:${layerLabel} 的 ${fileName} 内 role="${result.value.role}"`,
163
- [`role 字段 "${result.value.role}" 与请求的 "${name}" 不一致`],
162
+ `role manifest name mismatch: role="${result.value.role}" in ${fileName} of ${layerLabel}`,
163
+ [`role field "${result.value.role}" does not match the requested "${name}"`],
164
164
  );
165
165
  }
166
166
  return result.value;
@@ -53,79 +53,79 @@ const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
53
53
  export function validateRoleManifest(input: unknown): ValidateResult {
54
54
  const issues: string[] = [];
55
55
  if (!isPlainObject(input)) {
56
- return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
56
+ return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role manifest must be a JSON object'] };
57
57
  }
58
58
 
59
59
  const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'guidelines', 'manifest', 'services']);
60
60
  for (const k of Object.keys(input)) {
61
- if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
61
+ if (!KNOWN_TOP.has(k)) issues.push(`unknown top-level key "${k}" (not in the contract; check spelling)`);
62
62
  }
63
63
 
64
64
  if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
65
- issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
65
+ issues.push(`role must be a [a-z0-9-]+ string, got ${JSON.stringify(input.role)}`);
66
66
  }
67
67
 
68
68
  if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
69
- issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
69
+ issues.push(`version must be three dot-separated numbers x.y.z (e.g. 1.0.0), got ${JSON.stringify(input.version)}`);
70
70
  }
71
71
 
72
72
  if (input.model !== undefined) {
73
73
  if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
74
- issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
74
+ issues.push(`model must be in provider/model form (e.g. opencode-go/muse-spark-1.2-contributor), got ${JSON.stringify(input.model)}`);
75
75
  }
76
76
  }
77
77
 
78
78
  if (input.description !== undefined && typeof input.description !== 'string') {
79
- issues.push('description 必须是字符串');
79
+ issues.push('description must be a string');
80
80
  }
81
81
 
82
82
  if (input.guidelines !== undefined) {
83
83
  if (!Array.isArray(input.guidelines)) {
84
- issues.push('guidelines 必须是字符串数组');
84
+ issues.push('guidelines must be an array of strings');
85
85
  } else if (input.guidelines.length === 0) {
86
- issues.push('guidelines 为空数组时请直接省略该字段');
86
+ issues.push('omit guidelines entirely instead of passing an empty array');
87
87
  } else if (!input.guidelines.every((g) => typeof g === 'string' && g.trim() !== '')) {
88
- issues.push('guidelines 的每一项必须是非空字符串');
88
+ issues.push('every guidelines entry must be a non-empty string');
89
89
  } else if (input.guidelines.length > 20) {
90
- issues.push('guidelines 最多 20 条(prompt 预算保护)');
90
+ issues.push('at most 20 guidelines (prompt budget protection)');
91
91
  }
92
92
  }
93
93
 
94
94
  const m = input.manifest;
95
95
  if (!isPlainObject(m)) {
96
- issues.push('manifest 必须是对象');
96
+ issues.push('manifest must be an object');
97
97
  } else {
98
98
  const KNOWN_MANIFEST = new Set(['tools', 'rules', 'unknownTools']);
99
99
  for (const k of Object.keys(m)) {
100
- if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
100
+ if (!KNOWN_MANIFEST.has(k)) issues.push(`unknown key "${k}" in manifest`);
101
101
  }
102
102
 
103
103
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
104
- issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
104
+ issues.push(`manifest.unknownTools must be allow/deny, got ${JSON.stringify(m.unknownTools)}`);
105
105
  }
106
106
 
107
107
  const tools = m.tools;
108
108
  if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
109
- issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
109
+ issues.push('manifest.tools must be a non-empty array of strings (tool names [a-z0-9_-])');
110
110
  } else {
111
111
  const seen = new Set<string>();
112
112
  for (const t of tools) {
113
- if (seen.has(t)) issues.push(`manifest.tools 重复项 "${t}"`);
113
+ if (seen.has(t)) issues.push(`duplicate entry in manifest.tools "${t}"`);
114
114
  seen.add(t);
115
115
  }
116
116
  for (const need of COORDINATION_TOOLS) {
117
- if (!seen.has(need)) issues.push(`基线非空约束:manifest.tools 必须包含 "${need}"(协调工具)`);
117
+ if (!seen.has(need)) issues.push(`non-empty baseline constraint: manifest.tools must include "${need}" (coordination tool)`);
118
118
  }
119
119
  }
120
120
 
121
121
  if (m.rules !== undefined) {
122
122
  if (!isPlainObject(m.rules)) {
123
- issues.push('manifest.rules 必须是对象');
123
+ issues.push('manifest.rules must be an object');
124
124
  } else {
125
125
  for (const [k, v] of Object.entries(m.rules)) {
126
- if (!TOOL_KEY_RE.test(k)) issues.push(`rules 键 "${k}" 不是合法工具名([a-z0-9_-] 或 *)`);
126
+ if (!TOOL_KEY_RE.test(k)) issues.push(`rules key "${k}" is not a valid tool name ([a-z0-9_-] or *)`);
127
127
  if (!ACTIONS.includes(v as PermissionAction)) {
128
- issues.push(`rules["${k}"] 必须是 ${ACTIONS.join('/')} 之一,收到 ${JSON.stringify(v)}`);
128
+ issues.push(`rules["${k}"] must be one of ${ACTIONS.join('/')}, got ${JSON.stringify(v)}`);
129
129
  }
130
130
  }
131
131
  }
@@ -134,14 +134,14 @@ export function validateRoleManifest(input: unknown): ValidateResult {
134
134
 
135
135
  if (input.services !== undefined) {
136
136
  if (!isPlainObject(input.services)) {
137
- issues.push('services 必须是对象');
137
+ issues.push('services must be an object');
138
138
  } else if (input.services.todos !== undefined) {
139
139
  const t = input.services.todos;
140
140
  if (!isPlainObject(t)) {
141
- issues.push('services.todos 必须是对象');
141
+ issues.push('services.todos must be an object');
142
142
  } else {
143
143
  if (t.mode !== undefined && t.mode !== 'serial' && t.mode !== 'parallel') {
144
- issues.push(`services.todos.mode 必须是 serial/parallel,收到 ${JSON.stringify(t.mode)}`);
144
+ issues.push(`services.todos.mode must be serial/parallel, got ${JSON.stringify(t.mode)}`);
145
145
  }
146
146
  }
147
147
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "role": "master",
3
3
  "version": "1.4.0",
4
- "description": "主控角色(WS-D7 自应用;D83 镜像源):核心工具(todos/协调/subagent/terminal)+ pi 内建 + web_search;D82 unknownTools=allow。v1.4.0: 工具合并 terminal 6→1, subagent 5→1",
4
+ "description": "Master role (WS-D7 self-application; D83 mirror source): core tools (todos/coordination/subagent/terminal) + pi built-ins + web_search; D82 unknownTools=allow. v1.4.0: tool consolidation terminal 6→1, subagent 5→1",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "role": "worker-default",
3
- "version": "1.5.0",
4
- "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.5.0: Phase 2 显式放行 report_progress(worker→master 检查点上报);v1.4.0: 工具合并后更新 deny 列表",
3
+ "version": "1.6.0",
4
+ "description": "Default worker role (D83 inheritance): mirrors master.tools minus subagent/terminal (no nesting, resource ownership); excluded families explicitly denied — still banned under the D82 unknownTools=allow stance; todos serial. v1.6.0: removed report_progress (0 calls in a week, D125); v1.5.0: Phase 2 explicitly allowed report_progress; v1.4.0: refreshed the deny list after tool consolidation",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
8
- "todo_write", "ask_user_question", "report_progress",
8
+ "todo_write", "ask_user_question",
9
9
  "read", "grep", "glob", "bash", "pwsh", "edit", "write",
10
10
  "web_search"
11
11
  ],
@@ -7,6 +7,9 @@
7
7
 
8
8
  export const ABORT_STOP_REASON = 'aborted';
9
9
 
10
+ /** pi/provider failure stop. pi auto-retries these, so the run has not settled for the user. */
11
+ export const ERROR_STOP_REASON = 'error';
12
+
10
13
  /** D96: cooldown before repeating a notice for the same running set. */
11
14
  export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
12
15
 
@@ -80,7 +83,9 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
80
83
  * coming: answering now reports "finished, no closing message" for live work and — worse —
81
84
  * consumes the one-shot request plus the parent's claim latch, so the real closing message can
82
85
  * never be delivered (observed: a worker mid-compaction announced finished; its final report
83
- * never reached the parent). A user abort ('aborted') likewise settles a run that may re-run.
86
+ * never reached the parent). A user abort ('aborted') likewise settles a run that may re-run, and
87
+ * an 'error' stop is an upstream failure pi retries — answering then consumes the latch for a
88
+ * parent's claim that the retry's closing message still has to satisfy.
84
89
  */
85
90
  export function shouldPushSettleReply(input: {
86
91
  intentionalAbort?: boolean;
@@ -92,5 +97,6 @@ export function shouldPushSettleReply(input: {
92
97
  return !input.intentionalAbort
93
98
  && input.aborted !== true
94
99
  && !input.compactionInFlight
95
- && input.lastStopReason !== ABORT_STOP_REASON;
100
+ && input.lastStopReason !== ABORT_STOP_REASON
101
+ && input.lastStopReason !== ERROR_STOP_REASON;
96
102
  }
@@ -114,7 +114,7 @@ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): st
114
114
  export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
115
115
  action: Type.Union([
116
116
  Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
117
- Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
117
+ Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'), Type.Literal('help'),
118
118
  ]),
119
119
  paneId: Type.Optional(Type.String()),
120
120
  taskId: Type.Optional(Type.String()),
@@ -195,6 +195,14 @@ export interface AliveProbe {
195
195
  foregroundCwd?: string | null;
196
196
  }
197
197
 
198
+ /** Trailing line for isolate spawn results: the branch (`pier/<slug>`) reads like a directory and
199
+ * masters have cd'd into `<repo>/pier/<slug>`; only the worktree path is an actual directory. */
200
+ export function formatIsolateLocation(
201
+ isolate: Pick<NonNullable<SubEntry['isolate']>, 'worktreePath' | 'branch'> | null | undefined,
202
+ ): string {
203
+ return isolate ? `\nWorktree: ${isolate.worktreePath} (branch ${isolate.branch})` : '';
204
+ }
205
+
198
206
  /**
199
207
  * A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
200
208
  * age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
@@ -224,7 +232,7 @@ export function buildAliveNotice(
224
232
  return [
225
233
  `Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
226
234
  'The foreground wait has moved it to background so this call returns now.',
227
- 'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
235
+ 'Do NOT redo its work and do NOT sleep or poll: when it settles, its result is delivered automatically as a settlement notice at your next turn boundary.',
228
236
  'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
229
237
  ].join(' ');
230
238
  }
@@ -274,6 +282,9 @@ export interface SubEntry {
274
282
  userTakeover?: boolean;
275
283
  observationStartedAt?: number | null;
276
284
  lastAgentStatus?: string | null;
285
+ /** The final report `subagent output` already returned (tail fingerprint + time): the settlement
286
+ * notice then points at that delivery instead of repeating the report. Durable across resume. */
287
+ reportDelivered?: { fingerprint: string; at: number } | null;
277
288
  /** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
278
289
  isolate?: {
279
290
  worktreePath: string;
@@ -317,7 +328,30 @@ export function subRef(sub: { paneId: string; slug?: string }): string {
317
328
  return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
318
329
  }
319
330
 
320
- export const PROGRESS_CUSTOM_TYPE = 'pi-herdr.progress';
331
+ /** Identity of a final report across the output action and the settlement notice: its tail. */
332
+ export function reportFingerprint(report: string): string {
333
+ return report.slice(-64);
334
+ }
335
+
336
+ /** Shorter sleeps are ordinary polling of builds/logs; from a minute up, they were waits on workers. */
337
+ const SLEEP_NOTE_MIN_SECONDS = 60;
338
+
339
+ /**
340
+ * Note for a master bash call that sleeps ≥ SLEEP_NOTE_MIN_SECONDS while background workers run:
341
+ * masters slept 660-840 s per worker although the settlement notice arrives on its own. Worded so a
342
+ * sleep that waits on something else (a load test, a build) is left alone.
343
+ */
344
+ export function sleepWhileSubsRunningNote(
345
+ command: string,
346
+ running: ReadonlyArray<{ paneId: string; slug?: string }>,
347
+ ): string | null {
348
+ if (running.length === 0) return null;
349
+ const longest = Math.max(0, ...[...command.matchAll(/\bsleep\s+(\d+)s?\b/g)].map((m) => Number(m[1])));
350
+ if (longest < SLEEP_NOTE_MIN_SECONDS) return null;
351
+ return `[pier] ${running.length} background subagent(s) running (${running.map(subRef).join(', ')}): their settlement `
352
+ + 'notice arrives automatically at your next tool boundary, so do not sleep to wait for them — keep working or end '
353
+ + 'your turn. Sleeping is only needed for other work (builds, logs).';
354
+ }
321
355
 
322
356
  export interface SubsRegistry {
323
357
  version: 2;
@@ -372,6 +406,10 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
372
406
  retainNotified: s.isolate.retainNotified === true,
373
407
  }
374
408
  : undefined,
409
+ reportDelivered: s.reportDelivered && typeof s.reportDelivered === 'object'
410
+ && typeof s.reportDelivered.fingerprint === 'string' && typeof s.reportDelivered.at === 'number'
411
+ ? { fingerprint: s.reportDelivered.fingerprint, at: s.reportDelivered.at }
412
+ : undefined,
375
413
  }));
376
414
  found = { version: 2, subs };
377
415
  }
@@ -10,7 +10,7 @@ import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
10
10
  import type { JevRuntime } from './jev-client.ts';
11
11
  import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
12
12
  import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
13
- import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
13
+ import { buildBlockedGateNotice, reportFingerprint, sleep, type SubEntry } from './subagent-core.ts';
14
14
  import type { SessionIo } from './subagent-session.ts';
15
15
  import type { GitIo } from './subagent-spawn.ts';
16
16
 
@@ -70,8 +70,17 @@ export function planObservationTick(input: {
70
70
  agentStatus: string | null;
71
71
  machineInjectAgoMs: number;
72
72
  machineInjectGraceMs: number;
73
+ /** The transcript already holds a finalized answer (stopReason 'stop', no pending tool call). */
74
+ finalized?: boolean;
73
75
  }): ObservationTickPlan {
74
- if (!input.observationStartedAt) return { kind: 'start-observation' };
76
+ if (!input.observationStartedAt) {
77
+ // A finalized answer on a pane that is not working settles on the detecting tick: the window
78
+ // would only delay the result, and the transcript cannot grow under an idle pane. A working or
79
+ // blocked pane keeps the window path — that is the human-takeover guard.
80
+ const paneBusy = input.agentStatus === 'working' || input.agentStatus === 'blocked';
81
+ if (input.finalized && !paneBusy) return { kind: 'settle' };
82
+ return { kind: 'start-observation' };
83
+ }
75
84
  if (input.agentStatus === 'working') {
76
85
  if (input.machineInjectAgoMs > input.machineInjectGraceMs) return { kind: 'user-takeover' };
77
86
  return { kind: 'machine-inject-reset' };
@@ -118,8 +127,9 @@ export function buildSettlementNoticeText(
118
127
  closing: string | null,
119
128
  statLine: string | null,
120
129
  nullReason: SettlementNullReason = 'silent',
130
+ reportDeliveredAt: number | null = null,
121
131
  ): string {
122
- const base = formatSettlementNotice(agentLabel, closing, nullReason);
132
+ const base = formatSettlementNotice(agentLabel, closing, nullReason, reportDeliveredAt);
123
133
  return statLine ? `${base}\n${statLine}` : base;
124
134
  }
125
135
 
@@ -183,7 +193,7 @@ export interface PollerHost {
183
193
  lastMachineInjectAt: Map<string, number>;
184
194
  session: SessionIo;
185
195
  git: GitIo;
186
- injectNotice(content: string): Promise<void>;
196
+ injectNotice(content: string, paneId?: string): Promise<void>;
187
197
  reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
188
198
  withReconcileNotes(base: string, notes: readonly string[]): string;
189
199
  claimSettleNotice(key: string): boolean;
@@ -222,6 +232,9 @@ export function createPoller(h: PollerHost): Poller {
222
232
  ): Promise<void> {
223
233
  const startedAt = now();
224
234
  let lastActivityAt = now();
235
+ /** Claimed settle key: on a rejected injection the claim stays ours, so the next tick retries
236
+ * the notice instead of suppressing it as someone else's. */
237
+ let claimedKey: string | null = null;
225
238
  /** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
226
239
  const consume = (outcome: string | null, via: string, at: number): void => {
227
240
  const row = h.subs.get(paneId);
@@ -323,13 +336,6 @@ export function createPoller(h: PollerHost): Poller {
323
336
 
324
337
  if (isSettlementCandidate(s)) {
325
338
  const closing = s.text;
326
- if (!entry.observationStartedAt) {
327
- entry.observationStartedAt = now();
328
- entry.lastAgentStatus = 'idle';
329
- h.persistSubs();
330
- await doSleep(OBSERVATION_TICK_MS);
331
- continue;
332
- }
333
339
  let agentStatus: string | null = null;
334
340
  try {
335
341
  agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
@@ -342,7 +348,15 @@ export function createPoller(h: PollerHost): Poller {
342
348
  agentStatus,
343
349
  machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
344
350
  machineInjectGraceMs,
351
+ finalized: closing != null && !s.pendingTool,
345
352
  });
353
+ if (obs.kind === 'start-observation') {
354
+ entry.observationStartedAt = now();
355
+ entry.lastAgentStatus = 'idle';
356
+ h.persistSubs();
357
+ await doSleep(OBSERVATION_TICK_MS);
358
+ continue;
359
+ }
346
360
  if (obs.kind === 'user-takeover') {
347
361
  entry.userTakeover = true;
348
362
  entry.observationStartedAt = now();
@@ -409,19 +423,39 @@ export function createPoller(h: PollerHost): Poller {
409
423
  await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
410
424
  );
411
425
  }
412
- consume(closing, 'poll-settle', now());
426
+ // Claim before delivering: the pipe fast-path may already own this settlement's notice.
427
+ // The row is consumed only after the notice is accepted, and a claim we own is retried
428
+ // on the next tick — a rejected injection must not strand a finished worker as 'running'
429
+ // with no notice, nor consume the row while the result is still undelivered.
430
+ const claimKey = `${paneId}:${current.requestId}`;
431
+ if (claimedKey !== claimKey) {
432
+ if (!h.claimSettleNotice(claimKey)) {
433
+ consume(closing, 'poll-settle', now());
434
+ return;
435
+ }
436
+ claimedKey = claimKey;
437
+ }
413
438
  const notes = h.reconcileOnSettlement(current.description, 'settled');
414
439
  const statLine = await h.git.worktreeStatLine(entry);
440
+ // `subagent output` may already have returned this exact report: point there, don't repeat it.
441
+ const delivered = closing && entry.reportDelivered?.fingerprint === reportFingerprint(closing)
442
+ ? entry.reportDelivered.at
443
+ : null;
415
444
  const notice = h.withReconcileNotes(
416
- buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
445
+ buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason, delivered),
417
446
  notes,
418
447
  );
419
- if (h.claimSettleNotice(`${paneId}:${current.requestId}`)) {
420
- try {
421
- await h.injectNotice(notice);
422
- } catch {
423
- }
448
+ try {
449
+ // Tag the pane: a notice buffered during compaction must keep its row GC-exempt
450
+ // until the post-compaction flush actually delivers it.
451
+ await h.injectNotice(notice, paneId);
452
+ } catch {
453
+ // Notification refused (e.g. the master is compacting): leave the row running and
454
+ // re-detect the same closing text on the next tick.
455
+ await pace();
456
+ continue;
424
457
  }
458
+ consume(closing, 'poll-settle', now());
425
459
  return;
426
460
  }
427
461
  }
@@ -16,7 +16,7 @@ import {
16
16
  type SessionEntryLike,
17
17
  type SubSessionState,
18
18
  } from './session-tail.ts';
19
- import { idParam, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
19
+ import { idParam, reportFingerprint, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
20
20
  import { toolError } from './tool-error.ts';
21
21
 
22
22
  /* ── subagent output delta (pure) ───────────────────────────────── */
@@ -26,6 +26,61 @@ export const OUTPUT_HARD_CAP_CHARS = 16000;
26
26
  const OUTPUT_MIN_OVERLAP = 16;
27
27
  const OUTPUT_TAIL_CHARS = 500;
28
28
 
29
+ const BOX_CHARS = '│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═╌╍';
30
+ const FRAME_ONLY_LINE = new RegExp(`^[\\s${BOX_CHARS}]*[${BOX_CHARS}][\\s${BOX_CHARS}]*$`);
31
+ const SIDE_BORDER = /^\s*[│┃║] ?|\s*[│┃║]\s*$/g;
32
+ /** pi's status footer: `↑43k ↓13k R820k CH99.1% $0.017 …`, preceded by a `~/cwd (branch)` line. */
33
+ const PI_FOOTER_LINE = /^↑[\d.]+[kKmM]?\s+↓[\d.]+[kKmM]?\s/;
34
+ const PI_FOOTER_CWD_LINE = /^~?\/\S*( \([^)]*\))?$/;
35
+
36
+ /**
37
+ * Strip TUI chrome from a worker pane read: frame-only box-drawing rows, side borders, right-padding,
38
+ * pi's footer, and blank-line runs. A real read was ~20% whitespace, box glyphs and footer. Applied
39
+ * before the delta, so the cursor tracks the same cleaned text on every call.
40
+ */
41
+ export function cleanPaneChrome(text: string): string {
42
+ // Frame rows are judged before border stripping, which would otherwise leave them as blank lines.
43
+ const lines = text.split('\n')
44
+ .filter((line) => !FRAME_ONLY_LINE.test(line))
45
+ .map((line) => line.replace(SIDE_BORDER, '').trimEnd());
46
+ const kept: string[] = [];
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const line = lines[i]!;
49
+ if (PI_FOOTER_LINE.test(line)) continue;
50
+ if (PI_FOOTER_CWD_LINE.test(line) && PI_FOOTER_LINE.test(lines[i + 1] ?? '')) continue;
51
+ if (line === '' && kept[kept.length - 1] === '') continue;
52
+ kept.push(line);
53
+ }
54
+ return kept.join('\n');
55
+ }
56
+
57
+ /** Normalized-text length of the report opening that identifies the pane's rendered copy. */
58
+ const REPORT_KEY_CHARS = 24;
59
+
60
+ /**
61
+ * The pane shows the final report rendered (markdown stripped, long lines cut at pane width), so it
62
+ * is a lossy copy of the transcript text. When the transcript report is attached, cut the pane delta
63
+ * where the report's opening appears (whitespace and markdown markers ignored). Null: no copy found.
64
+ */
65
+ export function cutPaneReportCopy(delta: string, report: string): string | null {
66
+ const isNoise = (ch: string): boolean => /[\s`*_#>]/.test(ch);
67
+ const key = [...report].filter((ch) => !isNoise(ch)).slice(0, REPORT_KEY_CHARS).join('');
68
+ if (key.length < REPORT_KEY_CHARS) return null;
69
+ let normalized = '';
70
+ const origin: number[] = [];
71
+ for (let i = 0; i < delta.length; i++) {
72
+ if (isNoise(delta[i]!)) continue;
73
+ normalized += delta[i];
74
+ origin.push(i);
75
+ }
76
+ const at = normalized.indexOf(key);
77
+ if (at < 0) return null;
78
+ const lineStart = delta.lastIndexOf('\n', origin[at]!) + 1;
79
+ const before = delta.slice(0, lineStart).trimEnd();
80
+ const marker = '(the pane\'s rendered copy of the final report is omitted — full text in the Subagent Report section below)';
81
+ return before ? `${before}\n${marker}` : marker;
82
+ }
83
+
29
84
  export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
30
85
 
31
86
  export interface SubagentOutputCursor {
@@ -440,6 +495,8 @@ export async function executeSubagentOutput(
440
495
  /** Last finalized assistant text from the worker's transcript: the pane read alone cannot
441
496
  * recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
442
497
  readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
498
+ /** Persists `entry.reportDelivered` so the settlement notice can skip a report already shown. */
499
+ persistSubs?: () => void;
443
500
  },
444
501
  ) {
445
502
  const rawId = idParam(params, 'agentId', 'taskId');
@@ -472,8 +529,9 @@ export async function executeSubagentOutput(
472
529
  const maxChars = typeof params?.max_chars === 'number'
473
530
  ? params.max_chars
474
531
  : typeof params?.maxChars === 'number' ? params.maxChars : undefined;
475
- const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
476
- deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail;
532
+ const deltaResult = computeSubagentOutputDelta(prevCursor, cleanPaneChrome(rawRead.text), { maxChars });
533
+ // A resumed process has no cursor; the durable delivery record keeps the report from repeating.
534
+ deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail ?? entry.reportDelivered?.fingerprint;
477
535
 
478
536
  // Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
479
537
  // report exists only in the session file. Surface it once per distinct report.
@@ -481,11 +539,16 @@ export async function executeSubagentOutput(
481
539
  if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
482
540
  try {
483
541
  const report = await deps.readFinalReport(entry.paneId, entry.cwd);
484
- const fingerprint = report ? report.slice(-64) : '';
542
+ const fingerprint = report ? reportFingerprint(report) : '';
485
543
  if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
486
544
  const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
487
545
  reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
488
546
  deltaResult.nextCursor.reportTail = fingerprint;
547
+ deltaResult.delta = cutPaneReportCopy(deltaResult.delta, report) ?? deltaResult.delta;
548
+ if (!bounded.truncated) {
549
+ entry.reportDelivered = { fingerprint, at: Date.now() };
550
+ deps.persistSubs?.();
551
+ }
489
552
  }
490
553
  } catch {
491
554
  }