pi-crew 0.10.2 → 0.10.3
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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-worker.ts — worker-side surface lifecycle (spec §5.2 D7 + §5.3).
|
|
3
|
+
*
|
|
4
|
+
* Runs INSIDE a surface worker pane (the worker is a full pi session that also
|
|
5
|
+
* loads this extension). Three independently-gated blocks, registered by
|
|
6
|
+
* `registerSurfaceWorkerLifecycle` from prompt-runtime:
|
|
7
|
+
*
|
|
8
|
+
* A. RECORDER (gate: PI_CREW_SURFACE + PI_CREW_AGENT_EVENTS_PATH) — a
|
|
9
|
+
* surface worker has NO stdout JSON stream (`stripHeadlessModeArgs`
|
|
10
|
+
* removes it), so nothing would reach `agents/<taskId>/events.jsonl` and
|
|
11
|
+
* the agent-view overlay would render an empty pane. The recorder
|
|
12
|
+
* subscribes the in-process session event stream instead and writes the
|
|
13
|
+
* SAME `{seq,time,event}` JSONL lines the host writes for headless
|
|
14
|
+
* workers (crew-agent-records.ts appendCrewAgentEvent), using the same
|
|
15
|
+
* compaction (compactChildPiEvent) so ONE parser — agent-transcript.ts —
|
|
16
|
+
* reads both shapes. seq continues after whatever is already in the log
|
|
17
|
+
* (tail scan = nextAgentEventSeq semantics): that reader filters
|
|
18
|
+
* `seq > sinceSeq`, so restarting at 1 over an older attempt's log would
|
|
19
|
+
* hide every new line forever.
|
|
20
|
+
*
|
|
21
|
+
* B. AUTO-EXIT (gate: PI_CREW_AUTO_EXIT=1, spec D7) — when the agent run has
|
|
22
|
+
* fully settled on a naturally-finished turn and NOTHING is pending
|
|
23
|
+
* (ask / delegate / steer), append the terminal run-level event
|
|
24
|
+
* `worker.completed` FIRST (D7 ordering: report before dying), abort any
|
|
25
|
+
* in-flight turn, then shut the session down (which closes the pane). A
|
|
26
|
+
* settled `stopReason:"error"` emits a capped `worker.error` instead and
|
|
27
|
+
* keeps the pane open for inspection.
|
|
28
|
+
*
|
|
29
|
+
* C. PARENT-GUARD (gate: PI_CREW_PARENT_PID [+ surface/auto-exit context]) —
|
|
30
|
+
* a 5s poll: if the parent disappears (pid gone OR starttime mismatch —
|
|
31
|
+
* the PID-reuse Critical-4 fix) emit `worker.parent-lost` and terminate
|
|
32
|
+
* like (B) so no orphaned pane keeps burning tokens.
|
|
33
|
+
*
|
|
34
|
+
* The stopReason contract differs from the spec's literal wording on purpose:
|
|
35
|
+
* pi normalizes provider stop reasons to StopReason = "pending"|"stop"|
|
|
36
|
+
* "length"|"toolUse"|"error"|"aborted"|"deferred" (@earendil-works/pi-ai).
|
|
37
|
+
* A finished task arrives as "stop"; there is no "done"/"end_turn" value
|
|
38
|
+
* today, but both aliases are accepted so a future pi surfacing them keeps
|
|
39
|
+
* working. error/aborted/length turns deliberately stay alive: the pane stays
|
|
40
|
+
* open for inspection and the host watchdog (taskTimeoutMs / degrade path)
|
|
41
|
+
* owns that lifecycle.
|
|
42
|
+
*
|
|
43
|
+
* Verified decisions (2026-08-26):
|
|
44
|
+
* - Shutdown uses ExtensionContext.shutdown() ("Gracefully shutdown pi and
|
|
45
|
+
* exit", pi extensions types.d.ts; interactive-mode.js binds it via
|
|
46
|
+
* shutdownHandler and process.exit(0)s after emitting session_shutdown) —
|
|
47
|
+
* the same seam pi-interactive-subagents/subagent-done.ts calls from
|
|
48
|
+
* inside handlers. process.exit(0) remains a logged last-resort fallback.
|
|
49
|
+
* - Event recording uses extension API hooks (message_end /
|
|
50
|
+
* tool_execution_start / tool_execution_end): they expose exactly what the
|
|
51
|
+
* host-side stdout bridge consumes. Tailing pi's own session file (the
|
|
52
|
+
* spec §11.2 fallback) would duplicate offsets handling for less data.
|
|
53
|
+
* - fsync is intentionally omitted: each record is one synchronous O_APPEND
|
|
54
|
+
* write of a single line and the consumer reads the same machine's page
|
|
55
|
+
* cache, so it sees the bytes immediately. fsync only matters for power
|
|
56
|
+
* loss, and a per-terminal-write fsync re-introduces the measured ~13ms
|
|
57
|
+
* stall flagged in perf round 3 for no reader-visible gain.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import * as fs from "node:fs";
|
|
61
|
+
import * as path from "node:path";
|
|
62
|
+
|
|
63
|
+
import { getCrewEnv } from "../config/env-vars.ts";
|
|
64
|
+
import type { ExtensionAPI } from "../extension/pi-api.ts";
|
|
65
|
+
import { compactChildPiEvent } from "../runtime/child-pi/child-pi-streams.ts";
|
|
66
|
+
import { procStartTimeTicks } from "../runtime/process/proc-stat.ts";
|
|
67
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
68
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
69
|
+
import { createWorkerEventsChannel } from "./worker-events-channel.ts";
|
|
70
|
+
|
|
71
|
+
/** Worker-owned per-agent events log (prepareSurfaceSpawn derives the path). */
|
|
72
|
+
export const PI_CREW_AGENT_EVENTS_PATH_ENV = "PI_CREW_AGENT_EVENTS_PATH";
|
|
73
|
+
/** "1" → auto-exit (D7): terminate the session after the final settled turn. */
|
|
74
|
+
export const PI_CREW_AUTO_EXIT_ENV = "PI_CREW_AUTO_EXIT";
|
|
75
|
+
/** Surface kind written by prepareSurfaceSpawn ("tmux" | "herdr"). */
|
|
76
|
+
export const PI_CREW_SURFACE_ENV = "PI_CREW_SURFACE";
|
|
77
|
+
/** Parent starttime ticks (field 22 of /proc/<pid>/stat) captured at spawn. */
|
|
78
|
+
export const PI_CREW_PARENT_START_TIME_ENV = "PI_CREW_PARENT_START_TIME";
|
|
79
|
+
|
|
80
|
+
/** Poll cadence of the parent-guard (spec §5.2: every 5s). */
|
|
81
|
+
export const PARENT_GUARD_INTERVAL_MS = 5000;
|
|
82
|
+
/**
|
|
83
|
+
* Gap between an idle-looking settled turn and the actual exit. It closes the
|
|
84
|
+
* race where a steer/inbox item was being handed to the queue while the turn
|
|
85
|
+
* wound down: 600ms spans one steering-file poll tick (500ms), so anything
|
|
86
|
+
* durable shows up as pending (or has already started its own run) BEFORE we
|
|
87
|
+
* decide to die.
|
|
88
|
+
*/
|
|
89
|
+
export const AUTO_EXIT_SETTLE_CONFIRM_MS = 600;
|
|
90
|
+
/**
|
|
91
|
+
* `worker.error` rides the no-rate-limit terminal path, so an error loop
|
|
92
|
+
* (model keeps failing across steered retries) needs its own bound. After the
|
|
93
|
+
* cap the turn keeps failing silently — task/usage budgets and the host
|
|
94
|
+
* watchdog remain the real stoppers.
|
|
95
|
+
*/
|
|
96
|
+
export const WORKER_ERROR_EVENT_CAP = 5;
|
|
97
|
+
|
|
98
|
+
// ── shouldAutoExit ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Stop reasons that mean "the task finished": pi's normalized `"stop"` plus
|
|
102
|
+
* the two aliases the spec names (defensive forward-compat — pi never emits
|
|
103
|
+
* them today).
|
|
104
|
+
*/
|
|
105
|
+
export const AUTO_EXIT_STOP_REASONS: ReadonlySet<string> = new Set(["stop", "end_turn", "done"]);
|
|
106
|
+
|
|
107
|
+
export interface AutoExitSignals {
|
|
108
|
+
/** pi StopReason of the LAST assistant message. */
|
|
109
|
+
stopReason?: string | null;
|
|
110
|
+
askPending?: boolean;
|
|
111
|
+
delegatesRunning?: boolean;
|
|
112
|
+
steersPending?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pure decision core of D7: exit ONLY on a natural finish with zero pending work. */
|
|
116
|
+
export function shouldAutoExit(signals: AutoExitSignals): boolean {
|
|
117
|
+
if (typeof signals.stopReason !== "string") return false;
|
|
118
|
+
if (!AUTO_EXIT_STOP_REASONS.has(signals.stopReason)) return false;
|
|
119
|
+
return !signals.askPending && !signals.delegatesRunning && !signals.steersPending;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── parent liveness ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure parent-liveness decision.
|
|
126
|
+
* - stat unreadable → parent is GONE (/proc ENOENT means dead on Linux).
|
|
127
|
+
* - readable + expected ticks present + mismatch → PID REUSE, treat as lost.
|
|
128
|
+
* - expected ticks absent (macOS, or spawn never recorded them) →
|
|
129
|
+
* alive-with-caveat: pid-only semantics. Never kills on SIGSTOP or an
|
|
130
|
+
* unreadable clock field — a false "alive" falls back to the host
|
|
131
|
+
* watchdog, while a false "dead" kills a healthy worker.
|
|
132
|
+
*/
|
|
133
|
+
export function parentAlive(pid: number, expectedStartTime: string | undefined, readStat?: (pid: number) => string | undefined): boolean {
|
|
134
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
135
|
+
const reader =
|
|
136
|
+
readStat ??
|
|
137
|
+
((p: number): string | undefined => {
|
|
138
|
+
try {
|
|
139
|
+
return fs.readFileSync(`/proc/${p}/stat`, "utf8");
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
let stat: string | undefined;
|
|
145
|
+
try {
|
|
146
|
+
stat = reader(pid);
|
|
147
|
+
} catch {
|
|
148
|
+
stat = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (stat === undefined) return false;
|
|
151
|
+
if (!expectedStartTime) return true;
|
|
152
|
+
const observed = procStartTimeTicks(stat);
|
|
153
|
+
if (!observed) return true;
|
|
154
|
+
return observed === expectedStartTime;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── ask/delegate activity tracking ────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export type WorkerActivityKind = "ask" | "delegate";
|
|
160
|
+
|
|
161
|
+
export interface WorkerActivityTracker {
|
|
162
|
+
begin(kind: WorkerActivityKind): void;
|
|
163
|
+
end(kind: WorkerActivityKind): void;
|
|
164
|
+
busy(): { askPending: boolean; delegatesRunning: boolean };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Counters backing the ask/delegate signals of shouldAutoExit. */
|
|
168
|
+
export function createWorkerActivityTracker(): WorkerActivityTracker {
|
|
169
|
+
const counts = { ask: 0, delegate: 0 };
|
|
170
|
+
return {
|
|
171
|
+
begin(kind) {
|
|
172
|
+
counts[kind] += 1;
|
|
173
|
+
},
|
|
174
|
+
end(kind) {
|
|
175
|
+
counts[kind] = Math.max(0, counts[kind] - 1);
|
|
176
|
+
},
|
|
177
|
+
busy() {
|
|
178
|
+
return { askPending: counts.ask > 0, delegatesRunning: counts.delegate > 0 };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Wrap a tool definition so its in-flight state feeds the activity tracker.
|
|
185
|
+
* The ask/delegate tools stay untouched — wrapping replaces `execute` only,
|
|
186
|
+
* and a throwing execute still clears its flag via `finally`.
|
|
187
|
+
*/
|
|
188
|
+
export function trackToolActivity<TTool extends { execute: (...args: never[]) => Promise<unknown> }>(
|
|
189
|
+
tool: TTool,
|
|
190
|
+
tracker: WorkerActivityTracker,
|
|
191
|
+
kind: WorkerActivityKind,
|
|
192
|
+
): TTool {
|
|
193
|
+
async function trackedExecute(this: unknown, ...args: never[]): Promise<unknown> {
|
|
194
|
+
tracker.begin(kind);
|
|
195
|
+
try {
|
|
196
|
+
return await tool.execute.apply(this, args);
|
|
197
|
+
} finally {
|
|
198
|
+
tracker.end(kind);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ...tool, execute: trackedExecute };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── per-agent event recorder ──────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
export interface AgentEventRecorderOptions {
|
|
207
|
+
eventsPath: string;
|
|
208
|
+
/** Default: one synchronous O_APPEND appendFileSync of the whole line. */
|
|
209
|
+
appendLine?: (path: string, line: string) => void;
|
|
210
|
+
now?: () => number;
|
|
211
|
+
/** Default: redactSecrets — the host applies the same filter to stdout records. */
|
|
212
|
+
redact?: (value: unknown) => unknown;
|
|
213
|
+
/**
|
|
214
|
+
* Override the automatic tail scan (tests / explicit continuation point).
|
|
215
|
+
* When absent, seq continues AFTER the highest `{seq}` already in the log.
|
|
216
|
+
*/
|
|
217
|
+
seedSeq?: number;
|
|
218
|
+
/** Tail-reader override for the seed scan (tests). */
|
|
219
|
+
readTail?: (path: string, bytes: number) => string | undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface RecorderStats {
|
|
223
|
+
written: number;
|
|
224
|
+
failed: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface TurnSnapshot {
|
|
228
|
+
resultText: string;
|
|
229
|
+
usage: Record<string, unknown>;
|
|
230
|
+
stopReason: string | undefined;
|
|
231
|
+
/** Provider error text of the latest failed assistant message (if any). */
|
|
232
|
+
errorMessage: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface AgentEventRecorder {
|
|
236
|
+
record(event: unknown): void;
|
|
237
|
+
/**
|
|
238
|
+
* Worker self-report through the SAME `{seq,time,event}` pipeline WITHOUT
|
|
239
|
+
* pi-event compaction (compaction would drop non-pi shapes like the §12.2
|
|
240
|
+
* `worker.started` run-level contract). Keeps seq monotonic so host tail
|
|
241
|
+
* consumers never see a duplicate id.
|
|
242
|
+
*/
|
|
243
|
+
recordSelfReport(event: Record<string, unknown>): void;
|
|
244
|
+
turnSnapshot(): TurnSnapshot;
|
|
245
|
+
stats(): RecorderStats;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
249
|
+
|
|
250
|
+
function record(value: unknown): Record<string, unknown> {
|
|
251
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function costTotal(usage: Record<string, unknown>): number {
|
|
255
|
+
const cost = record(usage.cost);
|
|
256
|
+
return num(cost.total ?? usage.cost);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function assistantText(message: Record<string, unknown>): string {
|
|
260
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
261
|
+
return content
|
|
262
|
+
.flatMap((part) => {
|
|
263
|
+
const item = record(part);
|
|
264
|
+
return item.type === "text" && typeof item.text === "string" ? [item.text] : [];
|
|
265
|
+
})
|
|
266
|
+
.join("\n")
|
|
267
|
+
.trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mergeUsage(into: Record<string, unknown>, incoming: Record<string, unknown>): void {
|
|
271
|
+
into.input = num(into.input) + num(incoming.input);
|
|
272
|
+
into.output = num(into.output) + num(incoming.output);
|
|
273
|
+
into.cacheRead = num(into.cacheRead) + num(incoming.cacheRead);
|
|
274
|
+
into.cacheWrite = num(into.cacheWrite) + num(incoming.cacheWrite);
|
|
275
|
+
into.cost = { total: costTotal(into) + costTotal(incoming) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** How much of an existing log's tail the seed scan reads (256KB ≈ hundreds of lines). */
|
|
279
|
+
export const SEQ_SEED_TAIL_BYTES = 262_144;
|
|
280
|
+
|
|
281
|
+
function defaultReadTail(target: string, bytes: number): string | undefined {
|
|
282
|
+
try {
|
|
283
|
+
const fd = fs.openSync(target, "r");
|
|
284
|
+
try {
|
|
285
|
+
const size = fs.fstatSync(fd).size;
|
|
286
|
+
if (size === 0) return "";
|
|
287
|
+
const start = Math.max(0, size - bytes);
|
|
288
|
+
const buffer = Buffer.alloc(size - start);
|
|
289
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
290
|
+
return buffer.toString("utf-8");
|
|
291
|
+
} finally {
|
|
292
|
+
fs.closeSync(fd);
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined; // absent / unreadable log — nothing to continue from
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Highest `{seq}` in the last `tailBytes` of an existing per-agent log.
|
|
301
|
+
* Host writers (crew-agent-records) append sequentially, so the tail always
|
|
302
|
+
* holds the maximum; this is what lets a re-attempt CONTINUE the numbering —
|
|
303
|
+
* `readCrewAgentEventsCursor` filters `event.seq > sinceSeq`, so restarting at
|
|
304
|
+
* 1 over an older log would make every new line permanently invisible to the
|
|
305
|
+
* pane reader. Absent/corrupt data yields 0 (start fresh at 1).
|
|
306
|
+
*/
|
|
307
|
+
export function lastSeqInLog(
|
|
308
|
+
eventsPath: string,
|
|
309
|
+
deps?: { readTail?: (path: string, bytes: number) => string | undefined; tailBytes?: number },
|
|
310
|
+
): number {
|
|
311
|
+
const readTail = deps?.readTail ?? defaultReadTail;
|
|
312
|
+
const raw = readTail(eventsPath, deps?.tailBytes ?? SEQ_SEED_TAIL_BYTES);
|
|
313
|
+
if (!raw) return 0;
|
|
314
|
+
let max = 0;
|
|
315
|
+
for (const line of raw.split("\n")) {
|
|
316
|
+
if (!line.trim()) continue;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = record(JSON.parse(line));
|
|
319
|
+
const candidate = typeof parsed.seq === "number" && Number.isInteger(parsed.seq) ? parsed.seq : NaN;
|
|
320
|
+
if (Number.isFinite(candidate) && candidate > max) max = candidate;
|
|
321
|
+
} catch {
|
|
322
|
+
/* partial or corrupt line — skipped */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return max;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Worker-side mirror of appendCrewAgentEvent: bounded `{seq,time,event}` JSONL
|
|
330
|
+
* lines into the per-agent events log. seq CONTINUES from whatever is already
|
|
331
|
+
* on disk (tail scan above), mirroring nextAgentEventSeq's monotonic contract;
|
|
332
|
+
* a fresh file simply starts at 1. Uses the exact host-side compaction, so
|
|
333
|
+
* agent-transcript.ts parses headless and surface logs identically. Write
|
|
334
|
+
* failures are counted, never thrown — telemetry must not take down a live
|
|
335
|
+
* worker.
|
|
336
|
+
*/
|
|
337
|
+
export function createAgentEventRecorder(options: AgentEventRecorderOptions): AgentEventRecorder {
|
|
338
|
+
const { eventsPath } = options;
|
|
339
|
+
const now = options.now ?? Date.now;
|
|
340
|
+
const redact = options.redact ?? redactSecrets;
|
|
341
|
+
const appendLine =
|
|
342
|
+
options.appendLine ??
|
|
343
|
+
((target: string, line: string) => {
|
|
344
|
+
try {
|
|
345
|
+
fs.appendFileSync(target, line, "utf-8");
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if ((error as NodeJS.ErrnoException | null)?.code !== "ENOENT" || dirEnsured) throw error;
|
|
348
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
349
|
+
dirEnsured = true;
|
|
350
|
+
fs.appendFileSync(target, line, "utf-8");
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Same layout contract as the host writer: agents/<taskId>/ may not exist
|
|
355
|
+
// yet (the host normally creates it via status.json — never assume). The
|
|
356
|
+
// default writer creates it lazily on the first ENOENT, so injected sinks
|
|
357
|
+
// (tests) and already-existing dirs never trigger filesystem side effects.
|
|
358
|
+
let dirEnsured = false;
|
|
359
|
+
|
|
360
|
+
let seq =
|
|
361
|
+
options.seedSeq !== undefined
|
|
362
|
+
? Math.max(0, options.seedSeq)
|
|
363
|
+
: lastSeqInLog(eventsPath, options.readTail ? { readTail: options.readTail } : undefined);
|
|
364
|
+
let lastAssistantText = "";
|
|
365
|
+
let lastStopReason: string | undefined;
|
|
366
|
+
let lastErrorMessage = "";
|
|
367
|
+
const usage: Record<string, unknown> = {};
|
|
368
|
+
const stats = { written: 0, failed: 0 };
|
|
369
|
+
|
|
370
|
+
// Fold compacted events into the running turn summary backing
|
|
371
|
+
// `worker.completed` / `worker.error` (final text, cumulative usage,
|
|
372
|
+
// final stopReason / provider error).
|
|
373
|
+
const observeTurnState = (compacted: Record<string, unknown>): void => {
|
|
374
|
+
// Event-level usage wins over message-level (a usage-only tail record
|
|
375
|
+
// carries the delta), and only one side contributes — merging both
|
|
376
|
+
// would double-count pi messages that repeat their own usage.
|
|
377
|
+
const eventUsage = record(compacted.usage);
|
|
378
|
+
const message = record(compacted.message);
|
|
379
|
+
const delta = Object.keys(eventUsage).length > 0 ? eventUsage : message.usage !== undefined ? record(message.usage) : null;
|
|
380
|
+
if (delta) mergeUsage(usage, delta);
|
|
381
|
+
|
|
382
|
+
if (message.role !== "assistant") return;
|
|
383
|
+
const text = assistantText(message);
|
|
384
|
+
if (text) lastAssistantText = text;
|
|
385
|
+
if (typeof message.errorMessage === "string" && message.errorMessage.trim()) lastErrorMessage = message.errorMessage.trim();
|
|
386
|
+
if (typeof message.stopReason === "string") lastStopReason = message.stopReason;
|
|
387
|
+
else if (typeof compacted.stopReason === "string") lastStopReason = compacted.stopReason;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
record(rawEvent: unknown): void {
|
|
392
|
+
const compactedValue = compactChildPiEvent(rawEvent);
|
|
393
|
+
if (compactedValue === undefined) return;
|
|
394
|
+
const compacted = record(compactedValue);
|
|
395
|
+
observeTurnState(compacted);
|
|
396
|
+
|
|
397
|
+
seq += 1;
|
|
398
|
+
let line: string;
|
|
399
|
+
try {
|
|
400
|
+
line = `${JSON.stringify(redact({ seq, time: new Date(now()).toISOString(), event: compacted }))}\n`;
|
|
401
|
+
} catch {
|
|
402
|
+
// Unserializable payload — drop the RECORD but keep advancing seq
|
|
403
|
+
// so consumers never see a duplicated id.
|
|
404
|
+
stats.failed += 1;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
appendLine(eventsPath, line);
|
|
409
|
+
stats.written += 1;
|
|
410
|
+
} catch (error) {
|
|
411
|
+
stats.failed += 1;
|
|
412
|
+
logInternalError("prompt-runtime.surface-recorder-write", error as Error, `eventsPath=${eventsPath}`);
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
recordSelfReport(selfEvent: Record<string, unknown>): void {
|
|
416
|
+
seq += 1;
|
|
417
|
+
let line: string;
|
|
418
|
+
try {
|
|
419
|
+
line = `${JSON.stringify(redact({ seq, time: new Date(now()).toISOString(), event: selfEvent }))}\n`;
|
|
420
|
+
} catch {
|
|
421
|
+
stats.failed += 1;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
appendLine(eventsPath, line);
|
|
426
|
+
stats.written += 1;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
stats.failed += 1;
|
|
429
|
+
logInternalError("prompt-runtime.surface-recorder-write", error as Error, `eventsPath=${eventsPath}`);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
turnSnapshot(): TurnSnapshot {
|
|
433
|
+
return { resultText: lastAssistantText, usage: { ...usage }, stopReason: lastStopReason, errorMessage: lastErrorMessage };
|
|
434
|
+
},
|
|
435
|
+
stats(): RecorderStats {
|
|
436
|
+
return { ...stats };
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── wiring ────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/** The slice of ExtensionContext the lifecycle relies on (kept structural so
|
|
444
|
+
* older pi runtimes degrade instead of throwing). */
|
|
445
|
+
interface LifecycleCtx {
|
|
446
|
+
isIdle?: () => boolean;
|
|
447
|
+
hasPendingMessages?: () => boolean;
|
|
448
|
+
abort?: () => void;
|
|
449
|
+
shutdown?: () => void;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface SurfaceWorkerDeps {
|
|
453
|
+
/** Env source override (tests). Production reads via getCrewEnv. */
|
|
454
|
+
env?: NodeJS.ProcessEnv;
|
|
455
|
+
now?: () => number;
|
|
456
|
+
/** Per-agent JSONL append sink (tests inject a collector). */
|
|
457
|
+
appendLine?: (path: string, line: string) => void;
|
|
458
|
+
/** Run-level terminal-event sink (tests inject a collector). Default:
|
|
459
|
+
* emitTerminal + immediate flush on the WP-9 worker-events channel —
|
|
460
|
+
* flush-before-shutdown is the D7 durability order. */
|
|
461
|
+
emitRunEvent?: (type: string, data: Record<string, unknown>) => void;
|
|
462
|
+
/** /proc reader override for parentAlive (tests). */
|
|
463
|
+
readStat?: (pid: number) => string | undefined;
|
|
464
|
+
setIntervalFn?: (fn: () => void, ms: number) => unknown;
|
|
465
|
+
clearIntervalFn?: (timer: unknown) => void;
|
|
466
|
+
setTimeoutFn?: (fn: () => void, ms?: number) => unknown;
|
|
467
|
+
clearTimeoutFn?: (timer: unknown) => void;
|
|
468
|
+
/** Last-resort exit when pi hands us no shutdown-capable ctx (tests spy). */
|
|
469
|
+
exit?: (code: number) => never;
|
|
470
|
+
/** Shared ask/delegate counters owned by prompt-runtime (so wrapping the
|
|
471
|
+
* tools feeds the signals even when this lifecycle is dormant). */
|
|
472
|
+
activity?: WorkerActivityTracker;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface SurfaceWorkerHandle {
|
|
476
|
+
recorder: AgentEventRecorder;
|
|
477
|
+
activity: WorkerActivityTracker;
|
|
478
|
+
dispose(): void;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Register all three blocks against a worker's ExtensionAPI. Returns
|
|
483
|
+
* undefined when no gate is present (main user sessions and other non-team
|
|
484
|
+
* contexts) so nothing is armed there.
|
|
485
|
+
*/
|
|
486
|
+
export function registerSurfaceWorkerLifecycle(
|
|
487
|
+
pi: Pick<ExtensionAPI, "on">,
|
|
488
|
+
deps: SurfaceWorkerDeps = {},
|
|
489
|
+
): SurfaceWorkerHandle | undefined {
|
|
490
|
+
const get = (name: string): string | undefined => (deps.env ? deps.env[name] : getCrewEnv(name));
|
|
491
|
+
const surfaceKind = get(PI_CREW_SURFACE_ENV);
|
|
492
|
+
const agentEventsPath = get(PI_CREW_AGENT_EVENTS_PATH_ENV);
|
|
493
|
+
const autoExitEnabled = get(PI_CREW_AUTO_EXIT_ENV) === "1";
|
|
494
|
+
const parentPid = Number.parseInt(get("PI_CREW_PARENT_PID") ?? "", 10);
|
|
495
|
+
const parentStartTime = get(PI_CREW_PARENT_START_TIME_ENV);
|
|
496
|
+
|
|
497
|
+
const recorderActive = Boolean(surfaceKind && agentEventsPath);
|
|
498
|
+
if (surfaceKind && !agentEventsPath) {
|
|
499
|
+
// Surface without a per-agent log = an empty agent-view pane later;
|
|
500
|
+
// say so now instead of debugging from the missing transcript.
|
|
501
|
+
logInternalError(
|
|
502
|
+
"prompt-runtime.surface-worker-config",
|
|
503
|
+
new Error("PI_CREW_SURFACE set without PI_CREW_AGENT_EVENTS_PATH — recording disabled"),
|
|
504
|
+
undefined,
|
|
505
|
+
"warn",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
// The guard rides along ONLY with surface/auto-exit context: arming it for
|
|
509
|
+
// every crew worker would change headless + async-run lifecycles (their
|
|
510
|
+
// parents outlive the run by design), which A1 does not own.
|
|
511
|
+
const guardArmed = Number.isFinite(parentPid) && parentPid > 0 && (Boolean(surfaceKind) || autoExitEnabled);
|
|
512
|
+
if (!recorderActive && !autoExitEnabled && !guardArmed) return undefined;
|
|
513
|
+
|
|
514
|
+
const now = deps.now ?? Date.now;
|
|
515
|
+
const timers = {
|
|
516
|
+
setInterval: deps.setIntervalFn ?? ((fn: () => void, ms: number) => setInterval(fn, ms)),
|
|
517
|
+
clearInterval: deps.clearIntervalFn ?? ((timer: unknown) => clearInterval(timer as ReturnType<typeof setInterval>)),
|
|
518
|
+
setTimeout: deps.setTimeoutFn ?? ((fn: () => void, ms?: number) => setTimeout(fn, ms)),
|
|
519
|
+
clearTimeout: deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>)),
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const channel = createWorkerEventsChannel({ env: deps.env });
|
|
523
|
+
const emitRunEvent =
|
|
524
|
+
deps.emitRunEvent ??
|
|
525
|
+
((type: string, data: Record<string, unknown>) => {
|
|
526
|
+
// D7 ordering, spelled out: append the terminal event synchronously,
|
|
527
|
+
// drain any queued retry buffer, THEN hand control to shutdown.
|
|
528
|
+
channel.emitTerminal(type, data);
|
|
529
|
+
channel.flush();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const recorder = createAgentEventRecorder({
|
|
533
|
+
eventsPath: agentEventsPath ?? "",
|
|
534
|
+
appendLine: deps.appendLine ?? undefined,
|
|
535
|
+
now,
|
|
536
|
+
});
|
|
537
|
+
const activity = deps.activity ?? createWorkerActivityTracker();
|
|
538
|
+
|
|
539
|
+
if (recorderActive) {
|
|
540
|
+
// §12.2 worker.started (terminal-path, no rate limit): the ONLY host-visible
|
|
541
|
+
// signal of "this pane has a live worker" — doctor's zombie sweep and the
|
|
542
|
+
// T11 degrade manifest (`workerPids`/`sessionPaths`) read it. sessionPath
|
|
543
|
+
// stays optional (pi does not expose it to extensions in A1).
|
|
544
|
+
const startedData: Record<string, unknown> = {
|
|
545
|
+
pid: process.pid,
|
|
546
|
+
surface: surfaceKind,
|
|
547
|
+
surfacePaneId: get("PI_CREW_SURFACE_PANE"),
|
|
548
|
+
};
|
|
549
|
+
recorder.recordSelfReport({ type: "worker.started", ...startedData });
|
|
550
|
+
emitRunEvent("worker.started", startedData);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// pi hands every handler an ExtensionContext; keep the freshest one around,
|
|
554
|
+
// because the parent-guard fires OUTSIDE any handler yet still needs
|
|
555
|
+
// ctx.shutdown().
|
|
556
|
+
let latestCtx: LifecycleCtx | undefined;
|
|
557
|
+
let terminated = false;
|
|
558
|
+
let confirmTimer: unknown;
|
|
559
|
+
let guardTimer: unknown;
|
|
560
|
+
// worker.error rides the no-rate-limit terminal path; cap it so a task
|
|
561
|
+
// stuck in an error loop cannot flood the shared run log.
|
|
562
|
+
let errorReports = 0;
|
|
563
|
+
|
|
564
|
+
const stopParentGuard = (): void => {
|
|
565
|
+
if (guardTimer === undefined) return;
|
|
566
|
+
timers.clearInterval(guardTimer);
|
|
567
|
+
guardTimer = undefined;
|
|
568
|
+
};
|
|
569
|
+
const clearConfirmTimer = (): void => {
|
|
570
|
+
if (confirmTimer === undefined) return;
|
|
571
|
+
timers.clearTimeout(confirmTimer);
|
|
572
|
+
confirmTimer = undefined;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const requestShutdown = (): void => {
|
|
576
|
+
const shutdown = latestCtx?.shutdown;
|
|
577
|
+
if (typeof shutdown === "function") {
|
|
578
|
+
shutdown.call(latestCtx);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
logInternalError(
|
|
582
|
+
"prompt-runtime.surface-worker-shutdown",
|
|
583
|
+
new Error("no ctx.shutdown() available — falling back to process.exit"),
|
|
584
|
+
undefined,
|
|
585
|
+
"warn",
|
|
586
|
+
);
|
|
587
|
+
(deps.exit ?? ((code: number) => process.exit(code)))(0);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Terminal path shared by auto-exit and parent-lost: report FIRST (D7 —
|
|
592
|
+
* the append is synchronous), then abort any in-flight turn and shut down.
|
|
593
|
+
*
|
|
594
|
+
* abort() before shutdown() matters when the parent dies MID-TURN:
|
|
595
|
+
* pi defers a shutdown request until the current agent loop finishes
|
|
596
|
+
* (interactive-mode binds shutdownHandler as `if idle → shutdown`), which
|
|
597
|
+
* could leave the pane frozen for minutes while the recorder has already
|
|
598
|
+
* stopped. Aborting the operation makes that deferred shutdown immediate.
|
|
599
|
+
*/
|
|
600
|
+
const terminate = (type: string, data: Record<string, unknown>): void => {
|
|
601
|
+
if (terminated) return;
|
|
602
|
+
terminated = true;
|
|
603
|
+
stopParentGuard();
|
|
604
|
+
clearConfirmTimer();
|
|
605
|
+
emitRunEvent(type, data);
|
|
606
|
+
if (typeof latestCtx?.abort === "function") {
|
|
607
|
+
try {
|
|
608
|
+
latestCtx.abort();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
logInternalError("prompt-runtime.surface-worker-abort", error as Error, undefined, "warn");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
requestShutdown();
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/** Deferred confirmation armed by agent_settled (runs off the 600ms window). */
|
|
617
|
+
const confirmSettle = (): boolean => {
|
|
618
|
+
clearConfirmTimer();
|
|
619
|
+
if (terminated) return false;
|
|
620
|
+
|
|
621
|
+
// steersPending is really "the session still has work queued or
|
|
622
|
+
// running": pi keeps hasPendingMessages precise, and isIdle guards the
|
|
623
|
+
// case where a queued steer already STARTED its own agent run.
|
|
624
|
+
const ctx = latestCtx;
|
|
625
|
+
const steersPending = Boolean(ctx?.hasPendingMessages?.()) || (typeof ctx?.isIdle === "function" ? !ctx.isIdle() : false);
|
|
626
|
+
|
|
627
|
+
const snapshot = recorder.turnSnapshot();
|
|
628
|
+
// §12.2: surface failed turns so the run shows WHY it stalled — but do
|
|
629
|
+
// NOT exit on them (the pane stays open for inspection; the host
|
|
630
|
+
// watchdog owns that lifecycle).
|
|
631
|
+
if (snapshot.stopReason === "error") {
|
|
632
|
+
errorReports += 1;
|
|
633
|
+
if (errorReports <= WORKER_ERROR_EVENT_CAP) {
|
|
634
|
+
emitRunEvent("worker.error", {
|
|
635
|
+
errorMessage: snapshot.errorMessage || snapshot.resultText || "agent turn ended with stopReason=error",
|
|
636
|
+
usage: snapshot.usage,
|
|
637
|
+
stopReason: snapshot.stopReason,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (
|
|
644
|
+
!shouldAutoExit({
|
|
645
|
+
stopReason: snapshot.stopReason,
|
|
646
|
+
...activity.busy(),
|
|
647
|
+
steersPending,
|
|
648
|
+
})
|
|
649
|
+
) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
terminate("worker.completed", {
|
|
653
|
+
result: snapshot.resultText,
|
|
654
|
+
usage: snapshot.usage,
|
|
655
|
+
stopReason: snapshot.stopReason ?? "",
|
|
656
|
+
});
|
|
657
|
+
return true;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
if (autoExitEnabled) {
|
|
661
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
662
|
+
if (terminated) return;
|
|
663
|
+
latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
|
|
664
|
+
// Anything obviously still in flight → skip; the next settled event
|
|
665
|
+
// re-evaluates with fresh signals (no timer churn while asking).
|
|
666
|
+
const busy = activity.busy();
|
|
667
|
+
if (busy.askPending || busy.delegatesRunning || ctx.hasPendingMessages?.()) return;
|
|
668
|
+
clearConfirmTimer();
|
|
669
|
+
confirmTimer = timers.setTimeout(() => {
|
|
670
|
+
confirmSettle();
|
|
671
|
+
}, AUTO_EXIT_SETTLE_CONFIRM_MS);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (guardArmed) {
|
|
676
|
+
guardTimer = timers.setInterval(() => {
|
|
677
|
+
if (terminated) return;
|
|
678
|
+
if (parentAlive(parentPid, parentStartTime, deps.readStat)) return;
|
|
679
|
+
terminate("worker.parent-lost", { parentPid, expectedStartTicks: parentStartTime ?? "" });
|
|
680
|
+
}, PARENT_GUARD_INTERVAL_MS);
|
|
681
|
+
(guardTimer as { unref?: () => void })?.unref?.();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Block A: recorder (in-process session stream → per-agent JSONL).
|
|
685
|
+
if (recorderActive) {
|
|
686
|
+
// message_end carries assistant + toolResult messages (usage, stopReason);
|
|
687
|
+
// tool_execution_start/end carry the tool cards the pane folds them into.
|
|
688
|
+
const capture = (event: unknown): void => {
|
|
689
|
+
if (terminated) return;
|
|
690
|
+
recorder.record(event);
|
|
691
|
+
};
|
|
692
|
+
pi.on("tool_execution_start", capture);
|
|
693
|
+
pi.on("tool_execution_end", capture);
|
|
694
|
+
pi.on("message_end", capture);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Teardown hygiene: never leak the timers across a quit/reload/resume.
|
|
698
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
699
|
+
latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
|
|
700
|
+
stopParentGuard();
|
|
701
|
+
clearConfirmTimer();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Capture a context EARLY: the parent-guard fires from its own timer, and
|
|
705
|
+
// grabbing ctx here means the shutdown seam exists even if the parent dies
|
|
706
|
+
// before the worker ever ran a turn.
|
|
707
|
+
pi.on("session_start", (_event, ctx) => {
|
|
708
|
+
latestCtx = (ctx as LifecycleCtx) ?? latestCtx;
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
recorder,
|
|
713
|
+
activity,
|
|
714
|
+
dispose() {
|
|
715
|
+
terminated = true;
|
|
716
|
+
stopParentGuard();
|
|
717
|
+
clearConfirmTimer();
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|