pi-pier 0.2.7 → 0.3.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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,15 +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
  */
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';
8
16
 
9
17
  export const TAKEOVER_RECHECK_MS = 5_000;
10
18
  export const OBSERVATION_TICK_MS = 1_000;
11
19
 
12
- export type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
20
+ type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
13
21
 
14
22
  export type TakeoverTickPlan =
15
23
  | { kind: 'ignore' }
@@ -40,6 +48,7 @@ export type BlockedGatePlan =
40
48
  | { kind: 'clear-gate' }
41
49
  | { kind: 'pass' };
42
50
 
51
+ /** Blocked is sticky (one notice per gate); any other observed state clears the gate. */
43
52
  export function planBlockedGate(state: HerdrWaitState, alreadyNotified: boolean): BlockedGatePlan {
44
53
  if (state === 'blocked') return { kind: 'stay-blocked', notify: !alreadyNotified };
45
54
  if (state !== null) return { kind: 'clear-gate' };
@@ -53,6 +62,7 @@ export type ObservationTickPlan =
53
62
  | { kind: 'wait' }
54
63
  | { kind: 'settle' };
55
64
 
65
+ /** A working pane right after a machine inject is still our own work, not a human takeover. */
56
66
  export function planObservationTick(input: {
57
67
  observationStartedAt: number | null | undefined;
58
68
  now: number;
@@ -70,21 +80,400 @@ export function planObservationTick(input: {
70
80
  return { kind: 'settle' };
71
81
  }
72
82
 
73
- export type VacuumTickPlan = {
74
- refreshActivity: boolean;
75
- action: 'pane-closed' | 'timeout' | 'continue';
76
- };
77
-
78
83
  export function planVacuumTick(input: {
79
84
  waitState: HerdrWaitState;
80
85
  paneAlive: boolean;
81
86
  now: number;
82
87
  lastActivityAt: number;
83
88
  timeoutMs: number;
84
- }): VacuumTickPlan {
89
+ }): { refreshActivity: boolean; action: 'pane-closed' | 'timeout' | 'continue' } {
85
90
  const refreshActivity = input.waitState === null;
86
91
  const lastActivityAt = refreshActivity ? input.now : input.lastActivityAt;
87
92
  if (!input.paneAlive) return { refreshActivity, action: 'pane-closed' };
88
93
  if (input.now - lastActivityAt > input.timeoutMs) return { refreshActivity, action: 'timeout' };
89
94
  return { refreshActivity, action: 'continue' };
90
95
  }
96
+
97
+ /**
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.
103
+ */
104
+ export function isSettlementCandidate(input: {
105
+ text: string | null;
106
+ pendingTool: boolean;
107
+ activity: boolean;
108
+ turnEnded?: boolean;
109
+ compacting?: boolean;
110
+ }): boolean {
111
+ if (input.compacting) return false;
112
+ if (input.text) return true;
113
+ return !input.pendingTool && input.turnEnded === true;
114
+ }
115
+
116
+ export function buildSettlementNoticeText(
117
+ agentLabel: string,
118
+ closing: string | null,
119
+ statLine: string | null,
120
+ nullReason: SettlementNullReason = 'silent',
121
+ ): string {
122
+ const base = formatSettlementNotice(agentLabel, closing, nullReason);
123
+ return statLine ? `${base}\n${statLine}` : base;
124
+ }
125
+
126
+ export function formatPaneClosedNotice(paneId: string, description: string): string {
127
+ return `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`;
128
+ }
129
+
130
+ export function formatObservationTimeoutNotice(input: {
131
+ paneId: string;
132
+ description: string;
133
+ idleSeconds: number;
134
+ startedAtIso: string;
135
+ }): string {
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.`;
137
+ }
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
+ { questionId: 'settle-attribution', timeoutMs: 2500 },
379
+ );
380
+ if (res.ok) {
381
+ const verdict = evaluateSettleVerdict(res.answers);
382
+ if (verdict) nullReason = verdict;
383
+ }
384
+ }
385
+ } catch {
386
+ /* jev down → legacy wording */
387
+ }
388
+ }
389
+ }
390
+ if (!entry.sessionFile) {
391
+ entry.sessionFile = applyReportedSessionFile(
392
+ entry.sessionFile,
393
+ await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
394
+ );
395
+ }
396
+ consume(closing, 'poll-settle', now());
397
+ const notes = h.reconcileOnSettlement(current.description, 'settled');
398
+ const statLine = await h.git.worktreeStatLine(entry);
399
+ const notice = h.withReconcileNotes(
400
+ buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
401
+ notes,
402
+ );
403
+ if (h.claimSettleNotice(`${paneId}:${current.requestId}`)) {
404
+ try {
405
+ await h.injectNotice(notice);
406
+ } catch {
407
+ }
408
+ }
409
+ return;
410
+ }
411
+ }
412
+
413
+ let alive = false;
414
+ try {
415
+ alive = (await h.client.listPanes()).some((p) => p.paneId === paneId);
416
+ } catch {
417
+ alive = true; // an unreachable socket must not be read as a dead pane
418
+ }
419
+ const vacuum = planVacuumTick({ waitState: state, paneAlive: alive, now: now(), lastActivityAt, timeoutMs });
420
+ if (vacuum.refreshActivity) lastActivityAt = now();
421
+ if (vacuum.action === 'pane-closed') {
422
+ consume('pane closed before settling', 'poll-pane-closed', now());
423
+ const notes = h.reconcileOnSettlement(current.description, 'failed');
424
+ try {
425
+ await h.injectNotice(h.withReconcileNotes(formatPaneClosedNotice(paneId, current.description), notes));
426
+ } catch { /* non-fatal */ }
427
+ return;
428
+ }
429
+ if (vacuum.action === 'timeout') {
430
+ consume('observation timeout', 'poll-timeout', now());
431
+ const notes = h.reconcileOnSettlement(current.description, 'failed');
432
+ const notice = h.withReconcileNotes(
433
+ formatObservationTimeoutNotice({
434
+ paneId,
435
+ description: current.description,
436
+ idleSeconds: Math.round((now() - lastActivityAt) / 1000),
437
+ startedAtIso: new Date(startedAt).toISOString(),
438
+ }),
439
+ notes,
440
+ );
441
+ try {
442
+ await h.injectNotice(notice);
443
+ } catch { /* non-fatal */ }
444
+ return;
445
+ }
446
+ await pace();
447
+ }
448
+ } finally {
449
+ pollers.delete(paneId);
450
+ }
451
+ }
452
+
453
+ function startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void> {
454
+ // Always refresh first: a follow_up arriving while the spawn poller still runs must move
455
+ // settlement judgment to the new request instead of being dropped by the has() guard.
456
+ requestByPane.set(paneId, { injectTs, description, requestId });
457
+ if (pollers.has(paneId)) return Promise.resolve();
458
+ pollers.add(paneId);
459
+ return (async () => {
460
+ try {
461
+ if (!subScopes.has(paneId)) {
462
+ const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
463
+ onDispose: () => { pollers.delete(paneId); },
464
+ });
465
+ subScopes.set(paneId, fiber);
466
+ }
467
+ await pollLoop(paneId, cwd, injectTs, description, requestId);
468
+ const fiber = subScopes.get(paneId);
469
+ subScopes.delete(paneId);
470
+ try { await fiber?.dispose(); } catch { /* already gone */ }
471
+ } catch (err) {
472
+ pollers.delete(paneId);
473
+ console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
474
+ }
475
+ })();
476
+ }
477
+
478
+ return { startPoller, pollers };
479
+ }