pi-pier 0.3.0 → 0.4.1

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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Subagent pollLoop transition planners.
3
- *
4
- * Why: pollLoop mixed herdr I/O with takeover / observation / vacuum decisions.
5
- * Extracting the decisions makes the 30s observation window vs 60s takeover-idle
6
- * window explicit and unit-testable without a live pane.
2
+ * Background settlement poller: takeover, blocked gate, observation window, vacuum — plus its
3
+ * per-subagent Cordis scope. `createPoller` owns the I/O loop (the window values are runtime-policy).
7
4
  */
8
-
9
- import { formatSettlementNotice } from './vocab.ts';
5
+ import { appendFileSync } from 'node:fs';
6
+ import { Context } from '@deepseek-ai/cordis';
7
+ import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
8
+ import { applyReportedSessionFile } from './history-store.ts';
9
+ import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
10
+ import type { JevRuntime } from './jev-client.ts';
11
+ import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
12
+ import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
13
+ import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
14
+ import type { SessionIo } from './subagent-session.ts';
15
+ import type { GitIo } from './subagent-spawn.ts';
10
16
 
11
17
  export const TAKEOVER_RECHECK_MS = 5_000;
12
18
  export const OBSERVATION_TICK_MS = 1_000;
13
19
 
14
- export type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
20
+ type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
15
21
 
16
22
  export type TakeoverTickPlan =
17
23
  | { kind: 'ignore' }
@@ -42,6 +48,7 @@ export type BlockedGatePlan =
42
48
  | { kind: 'clear-gate' }
43
49
  | { kind: 'pass' };
44
50
 
51
+ /** Blocked is sticky (one notice per gate); any other observed state clears the gate. */
45
52
  export function planBlockedGate(state: HerdrWaitState, alreadyNotified: boolean): BlockedGatePlan {
46
53
  if (state === 'blocked') return { kind: 'stay-blocked', notify: !alreadyNotified };
47
54
  if (state !== null) return { kind: 'clear-gate' };
@@ -55,6 +62,7 @@ export type ObservationTickPlan =
55
62
  | { kind: 'wait' }
56
63
  | { kind: 'settle' };
57
64
 
65
+ /** A working pane right after a machine inject is still our own work, not a human takeover. */
58
66
  export function planObservationTick(input: {
59
67
  observationStartedAt: number | null | undefined;
60
68
  now: number;
@@ -72,18 +80,13 @@ export function planObservationTick(input: {
72
80
  return { kind: 'settle' };
73
81
  }
74
82
 
75
- export type VacuumTickPlan = {
76
- refreshActivity: boolean;
77
- action: 'pane-closed' | 'timeout' | 'continue';
78
- };
79
-
80
83
  export function planVacuumTick(input: {
81
84
  waitState: HerdrWaitState;
82
85
  paneAlive: boolean;
83
86
  now: number;
84
87
  lastActivityAt: number;
85
88
  timeoutMs: number;
86
- }): VacuumTickPlan {
89
+ }): { refreshActivity: boolean; action: 'pane-closed' | 'timeout' | 'continue' } {
87
90
  const refreshActivity = input.waitState === null;
88
91
  const lastActivityAt = refreshActivity ? input.now : input.lastActivityAt;
89
92
  if (!input.paneAlive) return { refreshActivity, action: 'pane-closed' };
@@ -92,39 +95,38 @@ export function planVacuumTick(input: {
92
95
  }
93
96
 
94
97
  /**
95
- * Determine whether subSessionState qualifies as an observation candidate.
96
- *
97
- * A16: closing text (only produced by a terminal assistant message) OR an ENDED turn settles.
98
- * Merely having an assistant message — the state a worker is in between tool calls, while the next
99
- * assistant message streams — must NOT qualify: on 2026-09-13 three live workers were announced as
100
- * "finished … left no closing message" and one was closed mid-task by GC afterwards.
98
+ * Closing text (only a terminal assistant message produces it) OR an ENDED turn settles; a mere
99
+ * assistant message — the state between tool calls — must NOT qualify, or live workers get announced
100
+ * as finished. OCC compaction hold: a compacting child is not settleable even with closing text, since
101
+ * that turn was aborted on purpose and the continuation is coming — an early wake would be a false
102
+ * settlement.
101
103
  */
102
104
  export function isSettlementCandidate(input: {
103
105
  text: string | null;
104
106
  pendingTool: boolean;
105
107
  activity: boolean;
106
108
  turnEnded?: boolean;
109
+ compacting?: boolean;
107
110
  }): boolean {
111
+ if (input.compacting) return false;
108
112
  if (input.text) return true;
109
113
  return !input.pendingTool && input.turnEnded === true;
110
114
  }
111
115
 
112
- /** Formats the combined settlement notice with optional git worktree stat. */
113
116
  export function buildSettlementNoticeText(
114
117
  agentLabel: string,
115
118
  closing: string | null,
116
119
  statLine: string | null,
120
+ nullReason: SettlementNullReason = 'silent',
117
121
  ): string {
118
- const base = formatSettlementNotice(agentLabel, closing);
122
+ const base = formatSettlementNotice(agentLabel, closing, nullReason);
119
123
  return statLine ? `${base}\n${statLine}` : base;
120
124
  }
121
125
 
122
- /** Formats notice when background subagent pane was closed prematurely. */
123
126
  export function formatPaneClosedNotice(paneId: string, description: string): string {
124
127
  return `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`;
125
128
  }
126
129
 
127
- /** Formats notice when background subagent exceeded observation timeout. */
128
130
  export function formatObservationTimeoutNotice(input: {
129
131
  paneId: string;
130
132
  description: string;
@@ -134,3 +136,360 @@ export function formatObservationTimeoutNotice(input: {
134
136
  return `Background subagent ${input.paneId} (${input.description}) has shown no progress for ${input.idleSeconds}s (observed since ${input.startedAtIso}). Run subagent(action: "list") to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`;
135
137
  }
136
138
 
139
+ /* ── pane scope ─────────────────────────────────────────────────── */
140
+
141
+ export interface ScopeHooks {
142
+ onDispose?: () => void;
143
+ }
144
+
145
+ export function createSessionRoot(hooks: ScopeHooks = {}): Context {
146
+ const root = new Context();
147
+ if (hooks.onDispose) {
148
+ root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
149
+ }
150
+ return root;
151
+ }
152
+
153
+ export async function mountSubagentScope(
154
+ root: Context,
155
+ paneId: string,
156
+ hooks: ScopeHooks = {},
157
+ ) {
158
+ // One argument only: the pinned cordis version takes just the plugin object; the pane id is the scope name.
159
+ return root.plugin({
160
+ name: `subagent:${paneId}`,
161
+ apply(ctx: Context) {
162
+ ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
163
+ },
164
+ });
165
+ }
166
+
167
+ export async function disposeSessionRoot(root: Context): Promise<void> {
168
+ try {
169
+ await root.fiber.dispose();
170
+ } catch {
171
+ }
172
+ }
173
+
174
+ /* ── poll loop ──────────────────────────────────────────────────── */
175
+
176
+ export interface PollerHost {
177
+ client: HerdrClientLike;
178
+ sessionRoot: Context;
179
+ subs: Map<string, SubEntry>;
180
+ persistSubs(): void;
181
+ writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
182
+ blockedGateNotified: Set<string>;
183
+ lastMachineInjectAt: Map<string, number>;
184
+ session: SessionIo;
185
+ git: GitIo;
186
+ injectNotice(content: string): Promise<void>;
187
+ reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
188
+ withReconcileNotes(base: string, notes: readonly string[]): string;
189
+ claimSettleNotice(key: string): boolean;
190
+ sleep?: (ms: number) => Promise<void>;
191
+ now?: () => number;
192
+ policy?: Partial<RuntimePolicy>;
193
+ /** Optional jev seam for the settle attribution check; absent → legacy wording (fail-open). */
194
+ jev?: { ask: JevRuntime['ask']; getMinConfidence: () => number };
195
+ }
196
+
197
+ export interface Poller {
198
+ startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void>;
199
+ readonly pollers: Set<string>;
200
+ }
201
+
202
+ export function createPoller(h: PollerHost): Poller {
203
+ const pollers = new Set<string>();
204
+ const subScopes = new Map<string, { dispose: () => Promise<void> }>();
205
+ /** Current tracked request per pane. startPoller refreshes it even while a poller runs: a
206
+ * follow_up must judge settlement against its own injectTs, not the original request's. */
207
+ const requestByPane = new Map<string, { injectTs: number; description: string; requestId: string }>();
208
+ const doSleep = h.sleep ?? sleep;
209
+ const now = h.now ?? Date.now;
210
+ const observeWindowMs = h.policy?.observationWindowMs ?? runtimePolicy.observationWindowMs;
211
+ const machineInjectGraceMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
212
+ const takeoverIdleMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
213
+ const timeoutMs = h.policy?.subagentTimeoutMs ?? runtimePolicy.subagentTimeoutMs;
214
+ const pollIntervalMs = h.policy?.pollIntervalMs ?? runtimePolicy.pollIntervalMs;
215
+
216
+ async function pollLoop(
217
+ paneId: string,
218
+ cwd: string,
219
+ injectTs: number,
220
+ description: string,
221
+ requestId: string,
222
+ ): Promise<void> {
223
+ const startedAt = now();
224
+ let lastActivityAt = now();
225
+ /** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
226
+ const consume = (outcome: string | null, via: string, at: number): void => {
227
+ const row = h.subs.get(paneId);
228
+ if (!row) return;
229
+ row.status = 'consumed';
230
+ row.consumedAt = at;
231
+ h.persistSubs();
232
+ h.writeHistory(row, { outcome }, via);
233
+ };
234
+ const pollTrace = process.env.PI_HERDR_TRACE
235
+ ? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
236
+ : null;
237
+ try {
238
+ while (true) {
239
+ const current = requestByPane.get(paneId) ?? { injectTs, description, requestId };
240
+ const entry = h.subs.get(paneId);
241
+ if (!entry || entry.status === 'settled' || entry.status === 'consumed' || entry.status === 'closed') return;
242
+
243
+ if (entry.userTakeover) {
244
+ try {
245
+ const agent = (await h.client.listAgents()).find((a) => a.paneId === paneId);
246
+ const tick = planTakeoverTick({
247
+ currentStatus: agent?.status ?? null,
248
+ previousStatus: entry.lastAgentStatus,
249
+ idleStartedAt: entry.observationStartedAt,
250
+ now: now(),
251
+ idleMs: takeoverIdleMs,
252
+ });
253
+ if (tick.kind === 'start-idle') {
254
+ entry.observationStartedAt = now();
255
+ entry.lastAgentStatus = 'idle';
256
+ h.persistSubs();
257
+ } else if (tick.kind === 'return-control') {
258
+ entry.userTakeover = false;
259
+ entry.observationStartedAt = null;
260
+ entry.lastAgentStatus = null;
261
+ h.persistSubs();
262
+ } else if (tick.kind === 'hold' && tick.clearIdleTimer) {
263
+ entry.lastAgentStatus = tick.lastAgentStatus;
264
+ entry.observationStartedAt = null;
265
+ h.persistSubs();
266
+ }
267
+ } catch {
268
+ }
269
+ if (entry.userTakeover) {
270
+ await doSleep(TAKEOVER_RECHECK_MS);
271
+ continue;
272
+ }
273
+ }
274
+
275
+ const tickStartedAt = now();
276
+ /** waitAgent returns at once for a pane already idle/done/blocked; never re-poll faster than the interval. */
277
+ const pace = async (): Promise<void> => {
278
+ const left = pollIntervalMs - (now() - tickStartedAt);
279
+ if (left > 0) await doSleep(left);
280
+ };
281
+ let state: HerdrAgentState | null;
282
+ try {
283
+ state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], pollIntervalMs);
284
+ } catch {
285
+ state = null;
286
+ }
287
+ const gate = planBlockedGate(state, h.blockedGateNotified.has(paneId));
288
+ if (gate.kind === 'stay-blocked') {
289
+ if (gate.notify) {
290
+ h.blockedGateNotified.add(paneId);
291
+ const question = await h.session.readAskFlag(paneId);
292
+ try {
293
+ await h.injectNotice(buildBlockedGateNotice({ paneId, description: current.description, question }));
294
+ } catch {
295
+ /* list_agents can recover a missed notice */
296
+ }
297
+ }
298
+ await pace();
299
+ continue;
300
+ }
301
+ if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
302
+
303
+ if (state === 'idle' || state === 'done') {
304
+ // Spawn can mis-attribute entry.sessionFile (herdr's report lags; the mtime fallback records
305
+ // the newest PRE-EXISTING session), freezing settlement detection until the vacuum timeout.
306
+ if (entry.sessionFile) {
307
+ const fixed = await h.session.reattributeStaleSessionFile(paneId, cwd, current.injectTs, entry.sessionFile);
308
+ if (fixed && fixed !== entry.sessionFile) {
309
+ entry.sessionFile = fixed;
310
+ h.persistSubs();
311
+ }
312
+ } else {
313
+ entry.sessionFile = applyReportedSessionFile(
314
+ entry.sessionFile,
315
+ await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
316
+ );
317
+ }
318
+ const s = await h.session.subSessionState(paneId, cwd, current.injectTs, entry.sessionFile);
319
+ pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} compact=${String(s.compacting)} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(Boolean(entry.userTakeover))}`);
320
+ // OCC compaction is live child work (pane reports idle, transcript holds the inflight
321
+ // marker): keep the vacuum timer fed so the timeout does not fire mid-summary.
322
+ if (s.compacting) lastActivityAt = now();
323
+
324
+ if (isSettlementCandidate(s)) {
325
+ 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
+ let agentStatus: string | null = null;
334
+ try {
335
+ agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
336
+ } catch {
337
+ }
338
+ const obs = planObservationTick({
339
+ observationStartedAt: entry.observationStartedAt,
340
+ now: now(),
341
+ windowMs: observeWindowMs,
342
+ agentStatus,
343
+ machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
344
+ machineInjectGraceMs,
345
+ });
346
+ if (obs.kind === 'user-takeover') {
347
+ entry.userTakeover = true;
348
+ entry.observationStartedAt = now();
349
+ entry.lastAgentStatus = 'working';
350
+ h.persistSubs();
351
+ continue;
352
+ }
353
+ if (obs.kind === 'machine-inject-reset') {
354
+ entry.observationStartedAt = now();
355
+ h.persistSubs();
356
+ await doSleep(OBSERVATION_TICK_MS);
357
+ continue;
358
+ }
359
+ if (obs.kind === 'wait') {
360
+ await doSleep(OBSERVATION_TICK_MS);
361
+ continue;
362
+ }
363
+ entry.observationStartedAt = null;
364
+
365
+ // Null closing text is ambiguous — a mis-attributed transcript reads like a worker that
366
+ // died silent. Deterministic signal first: no readable candidate at all (activity false)
367
+ // makes "left no closing message" a lie; otherwise ask jev to classify the tail we read.
368
+ let nullReason: SettlementNullReason = 'silent';
369
+ if (closing == null) {
370
+ if (!s.activity) {
371
+ nullReason = 'attribution-suspect';
372
+ } else if (h.jev) {
373
+ try {
374
+ const tail = await h.session.readSettleTail(paneId, cwd, entry.sessionFile);
375
+ if (tail && settleAskIsSafe(tail)) {
376
+ const res = await h.jev.ask(
377
+ settleVerdictRequest({ description: current.description, tail }),
378
+ {
379
+ questionId: 'settle-attribution',
380
+ timeoutMs: 2500,
381
+ // Calibration fields (D115): noul answers carry no confidence, so the
382
+ // probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
383
+ // tuning reads from jev.jsonl.
384
+ enrich: ({ ok, answers }) => {
385
+ if (!ok || !answers) return {};
386
+ const match = answers.tail_matches_task;
387
+ const fin = answers.tail_has_final_answer;
388
+ return {
389
+ tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
390
+ tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
391
+ settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
392
+ };
393
+ },
394
+ },
395
+ );
396
+ if (res.ok) {
397
+ const verdict = evaluateSettleVerdict(res.answers);
398
+ if (verdict) nullReason = verdict;
399
+ }
400
+ }
401
+ } catch {
402
+ /* jev down → legacy wording */
403
+ }
404
+ }
405
+ }
406
+ if (!entry.sessionFile) {
407
+ entry.sessionFile = applyReportedSessionFile(
408
+ entry.sessionFile,
409
+ await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
410
+ );
411
+ }
412
+ consume(closing, 'poll-settle', now());
413
+ const notes = h.reconcileOnSettlement(current.description, 'settled');
414
+ const statLine = await h.git.worktreeStatLine(entry);
415
+ const notice = h.withReconcileNotes(
416
+ buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
417
+ notes,
418
+ );
419
+ if (h.claimSettleNotice(`${paneId}:${current.requestId}`)) {
420
+ try {
421
+ await h.injectNotice(notice);
422
+ } catch {
423
+ }
424
+ }
425
+ return;
426
+ }
427
+ }
428
+
429
+ let alive = false;
430
+ try {
431
+ alive = (await h.client.listPanes()).some((p) => p.paneId === paneId);
432
+ } catch {
433
+ alive = true; // an unreachable socket must not be read as a dead pane
434
+ }
435
+ const vacuum = planVacuumTick({ waitState: state, paneAlive: alive, now: now(), lastActivityAt, timeoutMs });
436
+ if (vacuum.refreshActivity) lastActivityAt = now();
437
+ if (vacuum.action === 'pane-closed') {
438
+ consume('pane closed before settling', 'poll-pane-closed', now());
439
+ const notes = h.reconcileOnSettlement(current.description, 'failed');
440
+ try {
441
+ await h.injectNotice(h.withReconcileNotes(formatPaneClosedNotice(paneId, current.description), notes));
442
+ } catch { /* non-fatal */ }
443
+ return;
444
+ }
445
+ if (vacuum.action === 'timeout') {
446
+ consume('observation timeout', 'poll-timeout', now());
447
+ const notes = h.reconcileOnSettlement(current.description, 'failed');
448
+ const notice = h.withReconcileNotes(
449
+ formatObservationTimeoutNotice({
450
+ paneId,
451
+ description: current.description,
452
+ idleSeconds: Math.round((now() - lastActivityAt) / 1000),
453
+ startedAtIso: new Date(startedAt).toISOString(),
454
+ }),
455
+ notes,
456
+ );
457
+ try {
458
+ await h.injectNotice(notice);
459
+ } catch { /* non-fatal */ }
460
+ return;
461
+ }
462
+ await pace();
463
+ }
464
+ } finally {
465
+ pollers.delete(paneId);
466
+ }
467
+ }
468
+
469
+ function startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void> {
470
+ // Always refresh first: a follow_up arriving while the spawn poller still runs must move
471
+ // settlement judgment to the new request instead of being dropped by the has() guard.
472
+ requestByPane.set(paneId, { injectTs, description, requestId });
473
+ if (pollers.has(paneId)) return Promise.resolve();
474
+ pollers.add(paneId);
475
+ return (async () => {
476
+ try {
477
+ if (!subScopes.has(paneId)) {
478
+ const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
479
+ onDispose: () => { pollers.delete(paneId); },
480
+ });
481
+ subScopes.set(paneId, fiber);
482
+ }
483
+ await pollLoop(paneId, cwd, injectTs, description, requestId);
484
+ const fiber = subScopes.get(paneId);
485
+ subScopes.delete(paneId);
486
+ try { await fiber?.dispose(); } catch { /* already gone */ }
487
+ } catch (err) {
488
+ pollers.delete(paneId);
489
+ console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
490
+ }
491
+ })();
492
+ }
493
+
494
+ return { startPoller, pollers };
495
+ }