pi-pier 0.4.1 → 0.4.2

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.
@@ -15,7 +15,7 @@ import { makeProgressUpdate } from '../subagent-core.ts';
15
15
  import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
16
16
  import { toolError } from '../tool-error.ts';
17
17
  import { formatAge, isArchived } from '../stale-core.ts';
18
- import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
18
+ import { TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, todoListHash, validateTodos, type TodoEditOp } from '../todo-core.ts';
19
19
  import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
20
20
  import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
21
21
  import { swallow } from '../swallow.ts';
@@ -52,13 +52,10 @@ export interface TodoDeps {
52
52
  }
53
53
 
54
54
  const TOOL_DESCRIPTION = [
55
- 'Record and update the complete task list for the current work. Every call REPLACES the entire list (last write wins), so always resend the full list.',
56
- 'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
57
- 'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
58
- 'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
59
- 'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
60
- 'Do not make todo_write the only tool call of a turn — batch it together with real work.',
61
- 'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
55
+ 'Record the complete task list for the current work. Every call REPLACES the whole list, so always resend it in full.',
56
+ 'Entries: `content` (unique short imperative, no numbering), `status`, `blocker` (only when blocked: what it waits for), optional `phase` (group for a multi-stage plan). At most 15 entries, one per meaningful unit of work; several may be in_progress when work truly runs in parallel.',
57
+ 'Do not make todo_write the only tool call of a turn — batch it with real work.',
58
+ 'When you hand an entry to a subagent, append ` <sub>` to its content; it auto-completes when the subagent settles ("Reconciled:" in the note), otherwise update it yourself.',
62
59
  ].join(' ');
63
60
 
64
61
 
@@ -126,7 +123,7 @@ export default function todoPlugin(ctx: Context): void {
126
123
  staleNotices = 0;
127
124
  });
128
125
 
129
- scoped.on('before_agent_start', async (eventCtx?: unknown) => {
126
+ scoped.on('before_agent_start', async (eventCtx?: unknown, ctx?: unknown) => {
130
127
  // pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
131
128
  // or consume the guard cadence (stop reminder, config guide, third-party custom runs).
132
129
  if (!shouldRunTodoRead(eventCtx)) return;
@@ -139,12 +136,28 @@ export default function todoPlugin(ctx: Context): void {
139
136
  now: Date.now(),
140
137
  staleNotices,
141
138
  lastStaleGuardTurn,
139
+ archiveWindowOffered: todos.archiveWindowOffered,
142
140
  });
143
141
  if (plan.inject && plan.effect === 'stale-notice') {
144
142
  staleNotices += 1;
145
143
  lastStaleGuardTurn = todoReadTurn;
146
144
  }
147
- // Empty, archive, and R2 rewrite notices share one cadence so adjacent notices do not each consume the grace window.
145
+ // Persist the one-window offer against this list identity: the plugin closure dies with the
146
+ // process, and on /resume the same dead list must reach the terminal clear instead of re-warning.
147
+ if (plan.inject && plan.offerArchiveWindow) {
148
+ try {
149
+ appendEntry(TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, {
150
+ version: 1,
151
+ itemsHash: todoListHash(todos.items),
152
+ });
153
+ } catch (err) {
154
+ // The in-memory mark still advances escalation in this process.
155
+ swallow('todo.persist-archive-window', err);
156
+ }
157
+ todos.markArchiveWindowOffered();
158
+ }
159
+ // Empty-list guards and the archive rewrite window share one cadence so adjacent notices do not
160
+ // each consume the grace window; the terminal clear is immediate and only marks the tick.
148
161
  if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
149
162
  lastEmptyGuardTurn = todoReadTurn;
150
163
  }
@@ -159,6 +172,11 @@ export default function todoPlugin(ctx: Context): void {
159
172
  swallow('todo.persist-archive', err);
160
173
  }
161
174
  todos.replace([], { source: 'archive' });
175
+ // The clear is the one todo mutation outside the tool/human paths that must refresh the
176
+ // title, sidebar token and widget; the dead list would otherwise stay authoritative. The hook's
177
+ // first argument is the event (no ui), so render through the run ctx; tests pass a ui ctx first.
178
+ mirrorTodos();
179
+ renderWidget(ctx ?? eventCtx);
162
180
  }
163
181
  todoReadTurn += 1;
164
182
  if (!plan.inject) return;
@@ -194,11 +212,18 @@ export default function todoPlugin(ctx: Context): void {
194
212
  const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
195
213
  if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
196
214
  });
197
- for (const ev of ['agent_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
198
- scoped.on('agent_settled', async () => {
215
+ // A retry starts a fresh turn, not necessarily a fresh agent_start: cancel the pending reminder
216
+ // there too so a provider-error retry cannot be interrupted by a stale 30 s timer.
217
+ for (const ev of ['agent_start', 'turn_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
218
+ scoped.on('agent_settled', async (event: unknown) => {
199
219
  cancelTodoReminder();
220
+ // pi ≥ 1.1 carries `aborted` on agent_settled: authoritative for ESC mid-tool, where stopReason
221
+ // tracking sees a stale natural reason and would still schedule a reminder.
222
+ const aborted = event !== null && typeof event === 'object'
223
+ && 'aborted' in event && event.aborted === true;
200
224
  const plan = planStopTodoReminder({
201
225
  lastStopReason: lastAssistantStopReason,
226
+ aborted,
202
227
  intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
203
228
  compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
204
229
  reminders: todoReminders,
@@ -236,23 +261,20 @@ export default function todoPlugin(ctx: Context): void {
236
261
  label: 'Todo',
237
262
  description: TOOL_DESCRIPTION,
238
263
  promptGuidelines: [
239
- 'Before stopping with open todo_write entries, reconcile them: entries waiting on a human decision, approval, or ops action must be marked blocked with a blocker note — never left pending, and never executed just to clear the list.',
264
+ 'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, an ops action you cannot perform) is marked blocked with a blocker note — never left pending, never executed just to clear the list; finished or obsolete entries are completed or removed.',
240
265
  ],
241
266
  parameters: Type.Object({
242
267
  todos: Type.Array(
243
268
  Type.Object(
244
269
  {
245
- content: Type.String({ description: 'What the task is — a short imperative line' }),
246
- status: Type.Union(
247
- TODO_STATUSES.map((status) => Type.Literal(status)),
248
- { description: TODO_STATUSES.join(' | ') },
249
- ),
250
- blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
251
- phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
270
+ content: Type.String({ description: 'Short imperative line' }),
271
+ status: Type.Union(TODO_STATUSES.map((status) => Type.Literal(status))),
272
+ blocker: Type.Optional(Type.String({ description: 'Only for blocked; omit otherwise (never an empty string)' })),
273
+ phase: Type.Optional(Type.String({ description: 'Group name (≤30 chars); omit for a flat list' })),
252
274
  },
253
275
  { additionalProperties: false },
254
276
  ),
255
- { description: 'The COMPLETE task list; this call replaces the previous list' },
277
+ { description: 'The COMPLETE list; replaces the previous one' },
256
278
  ),
257
279
  }),
258
280
  async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
@@ -68,6 +68,8 @@ type ReducerModelOutput = {
68
68
  content?: readonly unknown[];
69
69
  text?: unknown;
70
70
  usage?: UsageLike;
71
+ stopReason?: string;
72
+ errorMessage?: string;
71
73
  };
72
74
 
73
75
  type ReducerModel = NonNullable<ExtensionContext['model']>;
@@ -139,23 +141,23 @@ function resolveCandidate(
139
141
  }
140
142
 
141
143
  /**
142
- * Step 6, jev first (RFC §3 P0-1): a confident jev rejection is authoritative even for regex-listed
143
- * commands; the regex list decides only when jev is absent, fails or answers below the confidence
144
- * gate (credential-shaped command lines stay local).
144
+ * Step 6, regex first: only regex-listed commands are candidates; jev may veto them (a confident
145
+ * rejection is authoritative) but never promotes an unlisted command. Asking jev about every result
146
+ * flipped 0 of 437 decisions over a week while blocking each tool result ~0.8s and uploading every
147
+ * command line (D122). Jev failures, low confidence and credential-shaped commands keep the regex hit.
145
148
  */
146
149
  async function isDiagnosticCandidate(
147
150
  command: string,
148
151
  gate: JevEprGateDependency | undefined,
149
152
  sessionId: string | undefined,
150
153
  ): Promise<boolean> {
151
- const regexHit = isDiagnosticCommand(command);
152
- if (!gate || containsLikelySecret(command)) return regexHit;
154
+ if (!isDiagnosticCommand(command)) return false;
155
+ if (!gate || containsLikelySecret(command)) return true;
153
156
  const result = await gate.ask(diagnosticGateRequest(command), {
154
157
  questionId: 'epr-diagnostic-gate',
155
158
  timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
156
159
  sessionId,
157
- // regex_hit makes the flip observable: how often and in which direction jev overrides the regex list (jev.jsonl).
158
- extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
160
+ extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
159
161
  enrich: ({ answers }) => {
160
162
  if (!answers) return {};
161
163
  const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
@@ -167,10 +169,9 @@ async function isDiagnosticCandidate(
167
169
  };
168
170
  },
169
171
  });
170
- if (!result.ok) return regexHit;
172
+ if (!result.ok) return true;
171
173
  const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
172
- if (verdict.hit) return true;
173
- return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
174
+ return verdict.hit || isDiagnosticGateUnanswered(verdict);
174
175
  }
175
176
 
176
177
  /** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
@@ -317,7 +318,10 @@ export async function handleReducerToolResult(
317
318
  if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
318
319
 
319
320
  const reducer = resolveReducerModel(ctx, config);
320
- if (!reducer) return undefined;
321
+ if (!reducer) {
322
+ await log({ sourceBytes, model: config.model ?? 'default', verificationOk: false, reason: 'no-reducer-model', action: 'fallback_full_text' });
323
+ return undefined;
324
+ }
321
325
  /** Evidence row for a fail-open exit taken after the reducer model was resolved. */
322
326
  const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
323
327
  log({
@@ -354,7 +358,14 @@ export async function handleReducerToolResult(
354
358
  : typeof output.text === 'string'
355
359
  ? output.text
356
360
  : '';
357
- if (!modelOutput.trim()) return undefined;
361
+ if (!modelOutput.trim()) {
362
+ // pi's complete() reports provider failures as an empty `error` message instead of throwing.
363
+ await fallbackRow(output.stopReason === 'error' ? 'invoke-failed' : 'empty-output', durationMs, {
364
+ ...(output.stopReason ? { stopReason: output.stopReason } : {}),
365
+ ...(output.errorMessage ? { error: output.errorMessage } : {}),
366
+ });
367
+ return undefined;
368
+ }
358
369
 
359
370
  const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
360
371
  if (!validated.ok) {
@@ -377,7 +388,10 @@ export async function handleReducerToolResult(
377
388
  totalTokens: output.usage?.totalTokens,
378
389
  });
379
390
  const receiptBytes = Buffer.byteLength(receipt, 'utf8');
380
- if (receiptBytes >= sourceBytes) return undefined;
391
+ if (receiptBytes >= sourceBytes) {
392
+ await fallbackRow('receipt-not-smaller', durationMs, { receiptBytes });
393
+ return undefined;
394
+ }
381
395
 
382
396
  await log({
383
397
  sourceBytes,
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;
@@ -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": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.6.0: 删除 report_progress(一周 0 调用,D125);v1.5.0: Phase 2 显式放行 report_progress;v1.4.0: 工具合并后更新 deny 列表",
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
  }