pi-pier 0.1.4 → 0.2.0

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 (55) hide show
  1. package/package.json +1 -1
  2. package/schemas/role-manifest.schema.json +1 -2
  3. package/src/bootstrap.ts +18 -22
  4. package/src/core/grid-shape.ts +13 -14
  5. package/src/core/subagent.ts +325 -857
  6. package/src/core/terminal.ts +25 -28
  7. package/src/core/todo.ts +47 -53
  8. package/src/gc-core.ts +15 -15
  9. package/src/git-adapter.ts +104 -0
  10. package/src/herdr-client.ts +76 -68
  11. package/src/history-store.ts +70 -36
  12. package/src/index-locks.ts +105 -0
  13. package/src/index-master.ts +113 -0
  14. package/src/index-notices.ts +43 -0
  15. package/src/index-pipe.ts +73 -0
  16. package/src/index-runtime.ts +23 -0
  17. package/src/index-worker.ts +39 -0
  18. package/src/index.ts +134 -386
  19. package/src/ledger.ts +15 -17
  20. package/src/lock-core.ts +15 -29
  21. package/src/manifest-compose.ts +9 -23
  22. package/src/notice-buffer.ts +9 -14
  23. package/src/pane-title.ts +24 -24
  24. package/src/pi-surface.ts +22 -29
  25. package/src/pipe-channel.ts +67 -34
  26. package/src/platform-paths.ts +72 -0
  27. package/src/progress-core.ts +26 -24
  28. package/src/reconcile-core.ts +25 -24
  29. package/src/role-loader.ts +32 -35
  30. package/src/role-manifest.ts +24 -28
  31. package/src/roles/master.json +2 -2
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/runtime-policy.ts +66 -0
  34. package/src/session-tail.ts +46 -45
  35. package/src/settle-wake-core.ts +19 -18
  36. package/src/slim-frame.ts +45 -44
  37. package/src/stale-core.ts +23 -21
  38. package/src/storage-layout.ts +80 -0
  39. package/src/subagent-core.ts +151 -94
  40. package/src/subagent-gc.ts +237 -0
  41. package/src/subagent-git-io.ts +80 -0
  42. package/src/subagent-launch.ts +110 -0
  43. package/src/subagent-poll-loop.ts +285 -0
  44. package/src/subagent-poller.ts +90 -0
  45. package/src/subagent-port.ts +27 -0
  46. package/src/subagent-scope.ts +4 -4
  47. package/src/subagent-session-io.ts +143 -0
  48. package/src/subagent-spawn.ts +156 -0
  49. package/src/terminal-core.ts +56 -54
  50. package/src/todo-core.ts +27 -64
  51. package/src/todo-read-hook.ts +28 -24
  52. package/src/todo-reminder-core.ts +89 -0
  53. package/src/todos-service.ts +36 -25
  54. package/src/tool-gate.ts +7 -28
  55. package/src/vocab.ts +18 -19
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Background settlement poller (takeover, blocked gate, observation, vacuum).
3
+ *
4
+ * Why: planners already live in subagent-poller.ts; this adapter owns the
5
+ * herdr/session I/O loop and pane-scope lifecycle so the plugin only starts it.
6
+ */
7
+ import { appendFileSync } from 'node:fs';
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
10
+ import { applyReportedSessionFile } from './history-store.ts';
11
+ import { runtimePolicy } from './runtime-policy.ts';
12
+ import { mountSubagentScope } from './subagent-scope.ts';
13
+ import {
14
+ OBSERVATION_TICK_MS,
15
+ TAKEOVER_RECHECK_MS,
16
+ planBlockedGate,
17
+ planObservationTick,
18
+ planTakeoverTick,
19
+ planVacuumTick,
20
+ } from './subagent-poller.ts';
21
+ import { buildBlockedGateNotice, type SubEntry } from './subagent-core.ts';
22
+ import { formatSettlementNotice } from './vocab.ts';
23
+ import type { SessionIo } from './subagent-session-io.ts';
24
+ import type { GitIo } from './subagent-git-io.ts';
25
+
26
+ function sleep(ms: number): Promise<void> {
27
+ const wait = Promise.withResolvers<void>();
28
+ setTimeout(wait.resolve, ms);
29
+ return wait.promise;
30
+ }
31
+
32
+ export interface PollerHost {
33
+ client: HerdrClientLike;
34
+ sessionRoot: Context;
35
+ subs: Map<string, SubEntry>;
36
+ persistSubs(): void;
37
+ writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
38
+ blockedGateNotified: Set<string>;
39
+ lastMachineInjectAt: Map<string, number>;
40
+ session: SessionIo;
41
+ git: GitIo;
42
+ injectNotice(content: string): Promise<void>;
43
+ reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
44
+ withReconcileNotes(base: string, notes: readonly string[]): string;
45
+ claimSettleNotice(key: string): boolean;
46
+ }
47
+
48
+ export interface Poller {
49
+ startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void;
50
+ readonly pollers: Set<string>;
51
+ }
52
+
53
+ export function createPoller(h: PollerHost): Poller {
54
+ const pollers = new Set<string>();
55
+ const subScopes = new Map<string, { dispose: () => Promise<void> }>();
56
+ const observeWindowMs = runtimePolicy.observationWindowMs;
57
+ const machineInjectGraceMs = runtimePolicy.settlementWindowMs;
58
+ const takeoverIdleMs = runtimePolicy.settlementWindowMs;
59
+ const timeoutMs = runtimePolicy.subagentTimeoutMs;
60
+
61
+ async function pollLoop(
62
+ paneId: string,
63
+ cwd: string,
64
+ spawnedAt: number,
65
+ injectTs: number,
66
+ description: string,
67
+ requestId: string,
68
+ ): Promise<void> {
69
+ void spawnedAt;
70
+ const startedAt = Date.now();
71
+ let lastActivityAt = Date.now();
72
+ const pollTrace = process.env.PI_HERDR_TRACE
73
+ ? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${Date.now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
74
+ : null;
75
+ try {
76
+ while (true) {
77
+ const entry = h.subs.get(paneId);
78
+ if (!entry || entry.status === 'settled') return;
79
+
80
+ if (entry.userTakeover) {
81
+ try {
82
+ const agents = await h.client.listAgents();
83
+ const agent = agents.find((a) => a.paneId === paneId);
84
+ const tick = planTakeoverTick({
85
+ currentStatus: agent?.status ?? null,
86
+ previousStatus: entry.lastAgentStatus,
87
+ idleStartedAt: entry.observationStartedAt,
88
+ now: Date.now(),
89
+ idleMs: takeoverIdleMs,
90
+ });
91
+ if (tick.kind === 'start-idle') {
92
+ entry.observationStartedAt = Date.now();
93
+ entry.lastAgentStatus = 'idle';
94
+ h.persistSubs();
95
+ } else if (tick.kind === 'return-control') {
96
+ entry.userTakeover = false;
97
+ entry.observationStartedAt = null;
98
+ entry.lastAgentStatus = null;
99
+ h.persistSubs();
100
+ } else if (tick.kind === 'hold' && tick.clearIdleTimer) {
101
+ entry.lastAgentStatus = tick.lastAgentStatus;
102
+ entry.observationStartedAt = null;
103
+ h.persistSubs();
104
+ }
105
+ } catch {
106
+ /* status probe failure must not stop the poller */
107
+ }
108
+ if (entry.userTakeover) {
109
+ await sleep(TAKEOVER_RECHECK_MS);
110
+ continue;
111
+ }
112
+ }
113
+
114
+ let state: HerdrAgentState | null;
115
+ try {
116
+ state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], runtimePolicy.pollIntervalMs);
117
+ } catch {
118
+ state = null;
119
+ }
120
+ const gate = planBlockedGate(state, h.blockedGateNotified.has(paneId));
121
+ if (gate.kind === 'stay-blocked') {
122
+ if (gate.notify) {
123
+ h.blockedGateNotified.add(paneId);
124
+ const question = await h.session.readAskFlag(paneId);
125
+ try {
126
+ await h.injectNotice(buildBlockedGateNotice({ paneId, description, question }));
127
+ } catch {
128
+ /* list_agents can recover a missed notice */
129
+ }
130
+ }
131
+ continue;
132
+ }
133
+ if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
134
+ if (state === 'idle' || state === 'done') {
135
+ const s = await h.session.subSessionState(paneId, cwd, injectTs);
136
+ pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(entry.userTakeover === true)}`);
137
+ if (s.text || (!s.pendingTool && s.activity)) {
138
+ const closing = s.text;
139
+ const obs = planObservationTick({
140
+ observationStartedAt: entry.observationStartedAt,
141
+ now: Date.now(),
142
+ windowMs: observeWindowMs,
143
+ agentStatus: null,
144
+ machineInjectAgoMs: Date.now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
145
+ machineInjectGraceMs,
146
+ });
147
+ if (obs.kind === 'start-observation') {
148
+ entry.observationStartedAt = Date.now();
149
+ entry.lastAgentStatus = 'idle';
150
+ h.persistSubs();
151
+ await sleep(OBSERVATION_TICK_MS);
152
+ continue;
153
+ }
154
+ let agentStatus: string | null = null;
155
+ try {
156
+ const agents = await h.client.listAgents();
157
+ agentStatus = agents.find((a) => a.paneId === paneId)?.status ?? null;
158
+ } catch {
159
+ /* observation continues */
160
+ }
161
+ const obs2 = planObservationTick({
162
+ observationStartedAt: entry.observationStartedAt,
163
+ now: Date.now(),
164
+ windowMs: observeWindowMs,
165
+ agentStatus,
166
+ machineInjectAgoMs: Date.now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
167
+ machineInjectGraceMs,
168
+ });
169
+ if (obs2.kind === 'user-takeover') {
170
+ entry.userTakeover = true;
171
+ entry.observationStartedAt = Date.now();
172
+ entry.lastAgentStatus = 'working';
173
+ h.persistSubs();
174
+ continue;
175
+ }
176
+ if (obs2.kind === 'machine-inject-reset') {
177
+ entry.observationStartedAt = Date.now();
178
+ h.persistSubs();
179
+ await sleep(OBSERVATION_TICK_MS);
180
+ continue;
181
+ }
182
+ if (obs2.kind === 'wait') {
183
+ await sleep(OBSERVATION_TICK_MS);
184
+ continue;
185
+ }
186
+ entry.observationStartedAt = null;
187
+ if (!entry.sessionFile) {
188
+ entry.sessionFile = applyReportedSessionFile(
189
+ entry.sessionFile,
190
+ await h.session.resolveSessionFile(paneId, cwd),
191
+ );
192
+ }
193
+ entry.status = 'consumed';
194
+ entry.consumedAt = Date.now();
195
+ h.writeHistory(entry, { outcome: closing }, 'poll-settle');
196
+ const notes = h.reconcileOnSettlement(description, 'settled');
197
+ const statLine = await h.git.worktreeStatLine(entry);
198
+ const notice = h.withReconcileNotes(
199
+ formatSettlementNotice(`${paneId} (${description})`, closing) + (statLine ? `\n${statLine}` : ''),
200
+ notes,
201
+ );
202
+ if (h.claimSettleNotice(`${paneId}:${requestId}`)) {
203
+ try {
204
+ await h.injectNotice(notice);
205
+ } catch {
206
+ /* list_agents can recover */
207
+ }
208
+ }
209
+ return;
210
+ }
211
+ }
212
+ let alive = false;
213
+ try {
214
+ alive = (await h.client.listAgents()).some((a) => a.paneId === paneId);
215
+ } catch {
216
+ alive = true;
217
+ }
218
+ const vacuum = planVacuumTick({
219
+ waitState: state,
220
+ paneAlive: alive,
221
+ now: Date.now(),
222
+ lastActivityAt,
223
+ timeoutMs,
224
+ });
225
+ if (vacuum.refreshActivity) lastActivityAt = Date.now();
226
+ if (vacuum.action === 'pane-closed') {
227
+ entry.status = 'consumed';
228
+ entry.consumedAt = Date.now();
229
+ h.persistSubs();
230
+ h.writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
231
+ const notes = h.reconcileOnSettlement(description, 'failed');
232
+ const notice = h.withReconcileNotes(
233
+ `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
234
+ notes,
235
+ );
236
+ try {
237
+ await h.injectNotice(notice);
238
+ } catch { /* non-fatal */ }
239
+ return;
240
+ }
241
+ if (vacuum.action === 'timeout') {
242
+ entry.status = 'consumed';
243
+ entry.consumedAt = Date.now();
244
+ h.persistSubs();
245
+ h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
246
+ const notes = h.reconcileOnSettlement(description, 'failed');
247
+ const notice = h.withReconcileNotes(
248
+ `Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
249
+ notes,
250
+ );
251
+ try {
252
+ await h.injectNotice(notice);
253
+ } catch { /* non-fatal */ }
254
+ return;
255
+ }
256
+ }
257
+ } finally {
258
+ pollers.delete(paneId);
259
+ }
260
+ }
261
+
262
+ function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
263
+ if (pollers.has(paneId)) return;
264
+ pollers.add(paneId);
265
+ void (async () => {
266
+ try {
267
+ if (!subScopes.has(paneId)) {
268
+ const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
269
+ onDispose: () => { pollers.delete(paneId); },
270
+ });
271
+ subScopes.set(paneId, fiber);
272
+ }
273
+ await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
274
+ const fiber = subScopes.get(paneId);
275
+ subScopes.delete(paneId);
276
+ try { await fiber?.dispose(); } catch { /* already gone */ }
277
+ } catch (err) {
278
+ pollers.delete(paneId);
279
+ console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
280
+ }
281
+ })();
282
+ }
283
+
284
+ return { startPoller, pollers };
285
+ }
@@ -0,0 +1,90 @@
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.
7
+ */
8
+
9
+ export const TAKEOVER_RECHECK_MS = 5_000;
10
+ export const OBSERVATION_TICK_MS = 1_000;
11
+
12
+ export type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
13
+
14
+ export type TakeoverTickPlan =
15
+ | { kind: 'ignore' }
16
+ | { kind: 'start-idle' }
17
+ | { kind: 'return-control' }
18
+ | { kind: 'hold'; lastAgentStatus: string; clearIdleTimer: boolean };
19
+
20
+ export function planTakeoverTick(input: {
21
+ currentStatus: string | null;
22
+ previousStatus: string | null | undefined;
23
+ idleStartedAt: number | null | undefined;
24
+ now: number;
25
+ idleMs: number;
26
+ }): TakeoverTickPlan {
27
+ if (input.currentStatus == null) return { kind: 'ignore' };
28
+ if (input.currentStatus === 'idle') {
29
+ if (input.previousStatus !== 'idle') return { kind: 'start-idle' };
30
+ if (input.idleStartedAt != null && input.now - input.idleStartedAt > input.idleMs) {
31
+ return { kind: 'return-control' };
32
+ }
33
+ return { kind: 'hold', lastAgentStatus: 'idle', clearIdleTimer: false };
34
+ }
35
+ return { kind: 'hold', lastAgentStatus: input.currentStatus, clearIdleTimer: true };
36
+ }
37
+
38
+ export type BlockedGatePlan =
39
+ | { kind: 'stay-blocked'; notify: boolean }
40
+ | { kind: 'clear-gate' }
41
+ | { kind: 'pass' };
42
+
43
+ export function planBlockedGate(state: HerdrWaitState, alreadyNotified: boolean): BlockedGatePlan {
44
+ if (state === 'blocked') return { kind: 'stay-blocked', notify: !alreadyNotified };
45
+ if (state !== null) return { kind: 'clear-gate' };
46
+ return { kind: 'pass' };
47
+ }
48
+
49
+ export type ObservationTickPlan =
50
+ | { kind: 'start-observation' }
51
+ | { kind: 'user-takeover' }
52
+ | { kind: 'machine-inject-reset' }
53
+ | { kind: 'wait' }
54
+ | { kind: 'settle' };
55
+
56
+ export function planObservationTick(input: {
57
+ observationStartedAt: number | null | undefined;
58
+ now: number;
59
+ windowMs: number;
60
+ agentStatus: string | null;
61
+ machineInjectAgoMs: number;
62
+ machineInjectGraceMs: number;
63
+ }): ObservationTickPlan {
64
+ if (!input.observationStartedAt) return { kind: 'start-observation' };
65
+ if (input.agentStatus === 'working') {
66
+ if (input.machineInjectAgoMs > input.machineInjectGraceMs) return { kind: 'user-takeover' };
67
+ return { kind: 'machine-inject-reset' };
68
+ }
69
+ if (input.now - input.observationStartedAt < input.windowMs) return { kind: 'wait' };
70
+ return { kind: 'settle' };
71
+ }
72
+
73
+ export type VacuumTickPlan = {
74
+ refreshActivity: boolean;
75
+ action: 'pane-closed' | 'timeout' | 'continue';
76
+ };
77
+
78
+ export function planVacuumTick(input: {
79
+ waitState: HerdrWaitState;
80
+ paneAlive: boolean;
81
+ now: number;
82
+ lastActivityAt: number;
83
+ timeoutMs: number;
84
+ }): VacuumTickPlan {
85
+ const refreshActivity = input.waitState === null;
86
+ const lastActivityAt = refreshActivity ? input.now : input.lastActivityAt;
87
+ if (!input.paneAlive) return { refreshActivity, action: 'pane-closed' };
88
+ if (input.now - lastActivityAt > input.timeoutMs) return { refreshActivity, action: 'timeout' };
89
+ return { refreshActivity, action: 'continue' };
90
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Subagent outbound port (composition root → plugin).
3
+ *
4
+ * Why: index used to pass a bag of independently-nullable callbacks that the
5
+ * plugin mutated field-by-field. A missing bag crashed at mount
6
+ * (`Cannot set properties of undefined (setting 'applyReplySession')`).
7
+ * Binding one `current` object is atomic: all methods or none.
8
+ */
9
+ export interface RunningSub {
10
+ paneId: string;
11
+ description: string;
12
+ }
13
+
14
+ export interface SubagentPort {
15
+ applyReplySession(paneId: string, sessionFile: string | null): void;
16
+ reconcileOnReply(paneId: string): string[];
17
+ listRunningSubs(): RunningSub[];
18
+ settleStatLine(paneId: string): Promise<string | null>;
19
+ }
20
+
21
+ export interface SubagentPortBox {
22
+ current: SubagentPort | null;
23
+ }
24
+
25
+ export function emptySubagentPortBox(): SubagentPortBox {
26
+ return { current: null };
27
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 档 0:master 进程内每个 subagent 一个 cordis fiber。
3
- * pipe / poller / history 的清理走 ctx.effect,dispose LIFO。
4
- * C3:worker 进程不引入本模块。
2
+ * One Cordis fiber per subagent in the master process.
3
+ * pipe / poller / history cleanup uses ctx.effect (LIFO dispose).
4
+ * C3: workers must not import this module.
5
5
  */
6
6
  import { Context } from '@deepseek-ai/cordis';
7
7
 
@@ -34,6 +34,6 @@ export async function disposeSessionRoot(root: Context): Promise<void> {
34
34
  try {
35
35
  await root.fiber.dispose();
36
36
  } catch {
37
- /* 二次 dispose 是 no-op */
37
+ /* second dispose is a no-op */
38
38
  }
39
39
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Child-session JSONL I/O: path resolution, settlement text, liveness probes.
3
+ *
4
+ * Why: pollLoop, foreground wait, and revive all shared the same candidate
5
+ * order (reported path/id before recent-file fallback, excluding the parent
6
+ * session). Keeping it in one adapter prevents settlement text from crossing sessions.
7
+ */
8
+ import { stat as statCb } from 'node:fs';
9
+ import { promisify } from 'node:util';
10
+ import type { HerdrClientLike } from './herdr-client.ts';
11
+ import {
12
+ hasAssistantAfter,
13
+ hasPendingToolCall,
14
+ lastAssistantText,
15
+ listSessionFiles,
16
+ readSessionFile,
17
+ sessionFileById,
18
+ } from './session-tail.ts';
19
+ import type { AliveProbe } from './subagent-core.ts';
20
+
21
+ const statAsync = promisify(statCb);
22
+
23
+ export interface SessionIoHost {
24
+ client: HerdrClientLike;
25
+ getSessionId: () => string;
26
+ sessionsDir: () => string;
27
+ }
28
+
29
+ export interface SessionIo {
30
+ resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]>;
31
+ resolveSessionFile(paneId: string, cwd: string): Promise<string | null>;
32
+ collectFinalText(paneId: string, cwd: string, sinceTs: number, attempts?: number): Promise<string | null>;
33
+ readAskFlag(paneId: string): Promise<string | null>;
34
+ probeAlive(paneId: string, cwd: string): Promise<AliveProbe>;
35
+ subSessionState(paneId: string, cwd: string, sinceTs: number): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }>;
36
+ }
37
+
38
+ export function createSessionIo(h: SessionIoHost): SessionIo {
39
+ async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
40
+ const out: string[] = [];
41
+ try {
42
+ const reported = await h.client.getAgentSessionPath(paneId);
43
+ if (reported) {
44
+ if (/\.jsonl$/.test(reported)) out.push(reported);
45
+ else {
46
+ const byId = sessionFileById(cwd, h.sessionsDir(), reported);
47
+ if (byId) out.push(byId);
48
+ }
49
+ }
50
+ } catch {
51
+ /* Reports may be unavailable during startup. */
52
+ }
53
+ const ownSession = h.getSessionId();
54
+ for (const f of listSessionFiles(cwd, h.sessionsDir(), 4)) {
55
+ if (f !== ownSession && !out.includes(f)) out.push(f);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
61
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
62
+ if (readSessionFile(file)) return file;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ async function collectFinalText(
68
+ paneId: string,
69
+ cwd: string,
70
+ sinceTs: number,
71
+ attempts = 12,
72
+ ): Promise<string | null> {
73
+ for (let i = 0; i < attempts; i++) {
74
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
75
+ const entries = readSessionFile(file);
76
+ if (!entries) continue;
77
+ const r = lastAssistantText(entries, { sinceTs });
78
+ if (r?.text) return r.text;
79
+ }
80
+ const wait = Promise.withResolvers<void>();
81
+ setTimeout(wait.resolve, 500);
82
+ await wait.promise;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ async function readAskFlag(paneId: string): Promise<string | null> {
88
+ try {
89
+ const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
90
+ const v = a?.tokens?.['pi-ask'];
91
+ return typeof v === 'string' && v ? v : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
98
+ const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
99
+ try {
100
+ const agents = await h.client.listAgents();
101
+ const a = agents.find((x) => x.paneId === paneId);
102
+ probe.paneExists = a != null;
103
+ probe.agentStatus = a?.status ?? null;
104
+ } catch {
105
+ /* Fall back to session activity when agent.list is unavailable. */
106
+ }
107
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
108
+ try {
109
+ const mtime = (await statAsync(file)).mtimeMs;
110
+ if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
111
+ } catch {
112
+ /* Session files can disappear during candidate scanning. */
113
+ }
114
+ }
115
+ return probe;
116
+ }
117
+
118
+ async function subSessionState(
119
+ paneId: string,
120
+ cwd: string,
121
+ sinceTs: number,
122
+ ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
123
+ for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
124
+ const entries = readSessionFile(file);
125
+ if (entries.length === 0) continue;
126
+ const r = lastAssistantText(entries, { sinceTs });
127
+ if (r?.text) return { text: r.text, pendingTool: false, activity: true };
128
+ if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
129
+ if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
130
+ }
131
+ return { text: null, pendingTool: false, activity: false };
132
+ }
133
+
134
+ return {
135
+ resolveSessionFileCandidates,
136
+ resolveSessionFile,
137
+ collectFinalText,
138
+ readAskFlag,
139
+ probeAlive,
140
+ subSessionState,
141
+ };
142
+ }
143
+