pi-pier 0.3.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.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
@@ -3,6 +3,7 @@
3
3
  * decisions, task-id resolution, and the outbound port types. Pure: planners decide, adapters do I/O.
4
4
  */
5
5
  import { normalizeEntryKind } from './history-store.ts';
6
+ import { Type } from 'typebox';
6
7
 
7
8
  export function sleep(ms: number): Promise<void> {
8
9
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -24,6 +25,113 @@ export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platf
24
25
  return (win ? '& ' : '') + quoted.join(' ');
25
26
  }
26
27
 
28
+ /** Shells that mean a freshly split pane is at its prompt and can accept a launch line. */
29
+ const SHELL_PROCESS_NAMES: ReadonlySet<string> = new Set([
30
+ 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'login',
31
+ 'pwsh', 'powershell', 'cmd',
32
+ ]);
33
+
34
+ /** Normalize `name` / `argv0` (`/bin/zsh`, `C:\\Windows\\System32\\cmd.exe`, `-zsh`) to a shell key. */
35
+ export function shellProcessName(value: string | null | undefined): string {
36
+ const base = String(value ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
37
+ const stripped = base.replace(/^-/, '').replace(/\.exe$/, '');
38
+ return stripped;
39
+ }
40
+
41
+ /**
42
+ * Decide whether a pane's foreground set means its shell is ready for input. `null`/`undefined`
43
+ * means the probe was unavailable: return ready so an older herdr never adds spawn latency.
44
+ * An empty set is not proof (the shell may still be starting), so callers wait for the timeout.
45
+ */
46
+ export function planShellReady(
47
+ foreground: ReadonlyArray<{ name?: string | null; argv0?: string | null }> | null | undefined,
48
+ ): 'ready' | 'wait' {
49
+ if (foreground == null) return 'ready';
50
+ if (foreground.length === 0) return 'wait';
51
+ return foreground.some((p) => SHELL_PROCESS_NAMES.has(shellProcessName(p.name)) || SHELL_PROCESS_NAMES.has(shellProcessName(p.argv0)))
52
+ ? 'ready'
53
+ : 'wait';
54
+ }
55
+
56
+ /** Flags pier passes at spawn (buildLaunchParts) that a herdr 0.9.2+ resume command must replay. */
57
+ const RESUME_FLAG_ARITY: Record<string, number> = {
58
+ '-a': 0,
59
+ '-e': 1,
60
+ '--extension': 1,
61
+ '--tui-mode': 1,
62
+ '--provider': 1,
63
+ '--model': 1,
64
+ '--session': 1,
65
+ };
66
+
67
+ const RESUME_MAX_ARGS = 64;
68
+ const RESUME_MAX_BYTES = 8 * 1024;
69
+
70
+ /**
71
+ * herdr 0.9.2 self-report (docs "Add Herdr support to your agent"): rebuild this worker's launch
72
+ * line as a `resume_argv` herdr runs after a server restart. The worker's own process.argv is the
73
+ * source of truth — interpreter and cli.js paths are dropped, whitelisted spawn flags are mirrored
74
+ * (`--flag=value` splits), argv[0] becomes a plain PATH command name. herdr rules: argv[0] plain,
75
+ * no apostrophes/control characters, ≤64 args, ≤8 KiB; violations (or a missing `-e`, which could
76
+ * not restore the extension) return null and the caller skips the report — restore stays best-effort.
77
+ */
78
+ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): string[] | null {
79
+ if (!/^[A-Za-z0-9._-]+$/.test(piBin)) return null;
80
+ const collected: string[] = [];
81
+ let index = 0;
82
+ // Skip interpreter + cli script (everything before the first flag).
83
+ while (index < workerArgv.length && !workerArgv[index]!.startsWith('-')) index++;
84
+ for (; index < workerArgv.length; index++) {
85
+ const raw = workerArgv[index]!;
86
+ if (!raw.startsWith('-')) continue; // positional (unknown flag's value) — not pier spawn shape
87
+ const eq = raw.indexOf('=');
88
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
89
+ const inlineValue = eq > 0 ? raw.slice(eq + 1) : null;
90
+ const arity = RESUME_FLAG_ARITY[flag];
91
+ if (arity === undefined) continue; // pi-internal flags are not replayed
92
+ if (arity === 0) {
93
+ collected.push(flag);
94
+ } else if (inlineValue !== null) {
95
+ collected.push(flag, inlineValue);
96
+ } else if (index + 1 < workerArgv.length) {
97
+ collected.push(flag, workerArgv[++index]!);
98
+ }
99
+ }
100
+ if (!collected.some((f, i) => (f === '-e' || f === '--extension') && i + 1 < collected.length)) return null;
101
+ const argv = [piBin, ...collected];
102
+ if (argv.length > RESUME_MAX_ARGS) return null;
103
+ if (argv.some((a) => /['\u0000-\u001f\u007f]/.test(a))) return null;
104
+ if (argv.reduce((sum, a) => sum + a.length + 1, 0) > RESUME_MAX_BYTES) return null;
105
+ return argv;
106
+ }
107
+
108
+ /**
109
+ * pi ≥ 0.99 outputSchema for the subagent tool: the `details` each action already produces, stamped
110
+ * with the normalized action. Codemode scripts (and any structured consumer) read this instead of
111
+ * parsing the prose reply; the text content stays authoritative for the transcript. Hard failures
112
+ * throw (toolError), so every returned value is a success and always carries structuredContent.
113
+ */
114
+ export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
115
+ action: Type.Union([
116
+ Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
117
+ Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'), Type.Literal('help'),
118
+ ]),
119
+ paneId: Type.Optional(Type.String()),
120
+ taskId: Type.Optional(Type.String()),
121
+ role: Type.Optional(Type.String()),
122
+ background: Type.Optional(Type.Boolean()),
123
+ alreadyRunning: Type.Optional(Type.Boolean()),
124
+ }, { additionalProperties: true });
125
+
126
+ export function attachSubagentStructuredContent(action: string, result: unknown): unknown {
127
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
128
+ const record = result as Record<string, unknown>;
129
+ const details = record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details)
130
+ ? record.details as Record<string, unknown>
131
+ : {};
132
+ return { ...record, structuredContent: { action, ...details } };
133
+ }
134
+
27
135
  /** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
28
136
  export class Semaphore {
29
137
  private active = 0;
@@ -87,6 +195,14 @@ export interface AliveProbe {
87
195
  foregroundCwd?: string | null;
88
196
  }
89
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
+
90
206
  /**
91
207
  * A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
92
208
  * age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
@@ -116,7 +232,7 @@ export function buildAliveNotice(
116
232
  return [
117
233
  `Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
118
234
  'The foreground wait has moved it to background so this call returns now.',
119
- '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.',
120
236
  'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
121
237
  ].join(' ');
122
238
  }
@@ -147,6 +263,9 @@ export interface SubEntry {
147
263
  /** 'task' or a role name; only a display label for GC. */
148
264
  kind: string;
149
265
  paneId: string;
266
+ /** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
267
+ * accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
268
+ slug?: string;
150
269
  tabId: string;
151
270
  tabName: string;
152
271
  cwd: string;
@@ -163,6 +282,9 @@ export interface SubEntry {
163
282
  userTakeover?: boolean;
164
283
  observationStartedAt?: number | null;
165
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;
166
288
  /** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
167
289
  isolate?: {
168
290
  worktreePath: string;
@@ -173,6 +295,64 @@ export interface SubEntry {
173
295
  };
174
296
  }
175
297
 
298
+ /* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
299
+
300
+ /** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
301
+ const SLUG_MAX = 24;
302
+
303
+ /** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
304
+ * then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
305
+ export function slugifyTask(description: string): string {
306
+ return description
307
+ .toLowerCase()
308
+ .replace(/[''']/g, '')
309
+ .replace(/[^a-z0-9]+/g, '-')
310
+ .replace(/^-+|-+$/g, '')
311
+ .slice(0, SLUG_MAX)
312
+ .replace(/-+$/g, '');
313
+ }
314
+
315
+ /** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
316
+ * sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
317
+ export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
318
+ const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
319
+ if (!taken.has(base)) return base;
320
+ for (let n = 2; ; n++) {
321
+ const next = `${base}-${n}`;
322
+ if (!taken.has(next)) return next;
323
+ }
324
+ }
325
+
326
+ /** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
327
+ export function subRef(sub: { paneId: string; slug?: string }): string {
328
+ return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
329
+ }
330
+
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
+ }
355
+
176
356
  export interface SubsRegistry {
177
357
  version: 2;
178
358
  subs: SubEntry[];
@@ -198,6 +378,7 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
198
378
  taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
199
379
  kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
200
380
  paneId: s.paneId,
381
+ slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
201
382
  tabId: typeof s.tabId === 'string' ? s.tabId : '',
202
383
  tabName: typeof s.tabName === 'string' ? s.tabName : '',
203
384
  cwd: typeof s.cwd === 'string' ? s.cwd : '',
@@ -225,6 +406,10 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
225
406
  retainNotified: s.isolate.retainNotified === true,
226
407
  }
227
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,
228
413
  }));
229
414
  found = { version: 2, subs };
230
415
  }
@@ -395,6 +580,12 @@ export function buildLaunchParts(
395
580
 
396
581
  const ISOLATE_SLUG_MAX = 40;
397
582
 
583
+ /**
584
+ * D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
585
+ * top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
586
+ * placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
587
+ * an option only for workflows that accept a separate workspace.
588
+ */
398
589
  export function planIsolateWorktree(opts: {
399
590
  description: string;
400
591
  taskHex: string;
@@ -553,7 +744,7 @@ export function planForegroundTick(input: {
553
744
 
554
745
  /* ── task-id resolution ─────────────────────────────────────────── */
555
746
 
556
- type TaskIdResolutionResult =
747
+ export type TaskIdResolutionResult =
557
748
  | { kind: 'resolved'; taskId: string }
558
749
  | { kind: 'ambiguous'; query: string; candidates: string[] }
559
750
  | { kind: 'too_short'; query: string }
@@ -583,13 +774,28 @@ export function resolveTaskIdPrefix(
583
774
  return { kind: 'not_found', query: trimmed };
584
775
  }
585
776
 
777
+ /** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
778
+ * its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
779
+ * the caller's original string in the message even though matching works on the trimmed form. */
780
+ export function taskIdResolutionError(
781
+ res: TaskIdResolutionResult,
782
+ label: 'task id' | 'subagent id',
783
+ rawQuery: string,
784
+ ): string | null {
785
+ if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
786
+ if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
787
+ return null;
788
+ }
789
+
586
790
  /* ── outbound port (composition root → plugin) ──────────────────── */
587
791
 
588
792
  /** Bound atomically at session_start: consumers read `current` and must tolerate null. */
589
793
  export interface SubagentPort {
590
794
  applyReplySession(paneId: string, sessionFile: string | null): void;
591
795
  reconcileOnReply(paneId: string): string[];
592
- listRunningSubs(): Array<{ paneId: string; description: string }>;
796
+ listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
797
+ /** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
798
+ listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
593
799
  settleStatLine(paneId: string): Promise<string | null>;
594
800
  /** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
595
801
  consumeReply(paneId: string, outcome: string | null): void;
@@ -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();
@@ -375,7 +389,23 @@ export function createPoller(h: PollerHost): Poller {
375
389
  if (tail && settleAskIsSafe(tail)) {
376
390
  const res = await h.jev.ask(
377
391
  settleVerdictRequest({ description: current.description, tail }),
378
- { questionId: 'settle-attribution', timeoutMs: 2500 },
392
+ {
393
+ questionId: 'settle-attribution',
394
+ timeoutMs: 2500,
395
+ // Calibration fields (D115): noul answers carry no confidence, so the
396
+ // probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
397
+ // tuning reads from jev.jsonl.
398
+ enrich: ({ ok, answers }) => {
399
+ if (!ok || !answers) return {};
400
+ const match = answers.tail_matches_task;
401
+ const fin = answers.tail_has_final_answer;
402
+ return {
403
+ tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
404
+ tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
405
+ settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
406
+ };
407
+ },
408
+ },
379
409
  );
380
410
  if (res.ok) {
381
411
  const verdict = evaluateSettleVerdict(res.answers);
@@ -393,19 +423,39 @@ export function createPoller(h: PollerHost): Poller {
393
423
  await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
394
424
  );
395
425
  }
396
- 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
+ }
397
438
  const notes = h.reconcileOnSettlement(current.description, 'settled');
398
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;
399
444
  const notice = h.withReconcileNotes(
400
- buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
445
+ buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason, delivered),
401
446
  notes,
402
447
  );
403
- if (h.claimSettleNotice(`${paneId}:${current.requestId}`)) {
404
- try {
405
- await h.injectNotice(notice);
406
- } catch {
407
- }
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;
408
457
  }
458
+ consume(closing, 'poll-settle', now());
409
459
  return;
410
460
  }
411
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
  }