@ferris1225/pi-subagents 1.0.0 → 1.0.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.
package/src/tools.ts CHANGED
@@ -9,7 +9,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { Text } from "@earendil-works/pi-tui";
10
10
  import { Type } from "typebox";
11
11
  import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
12
- import { emptyUsage, formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
12
+ import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
13
+ import { emptyUsage } from "./rpc-run.ts";
13
14
  import {
14
15
  formatElapsed,
15
16
  formatUsageCompact,
@@ -21,7 +22,6 @@ import {
21
22
  } from "./monitor.ts";
22
23
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
23
24
  import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
24
- import { trajectoryStore } from "./trajectory.ts";
25
25
 
26
26
  /** In-turn result lookup. Dispatch already ended the turn and results arrive as
27
27
  * wake-up messages, so the default must NOT block: a settled run returns its
@@ -97,7 +97,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
97
97
  return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.control.getPhase()}; only a running thread can be steered.` }], details: {} };
98
98
  }
99
99
  await thread.control.steer(instruction);
100
- trajectoryStore.get(thread.id).trajectory.append({ kind: "steer", instruction });
101
100
  return { content: [{ type: "text", text: `Queued steering instruction for run #${thread.id} after its current tool batch.` }], details: {} };
102
101
  }
103
102
  case "retarget": {
@@ -110,7 +109,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
110
109
  thread.task = objective;
111
110
  thread.control.retargetPending(objective);
112
111
  monitor.setTask(thread.id, objective);
113
- trajectoryStore.get(thread.id).trajectory.append({ kind: "retarget", objective });
114
112
  return { content: [{ type: "text", text: `Updated queued run #${thread.id} to the new objective; no child was spawned by this control action.` }], details: {} };
115
113
  }
116
114
  if (!(["starting", "running", "steering", "interrupting", "retrying"] as const).includes(phase as any)) {
@@ -119,7 +117,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
119
117
  thread.task = objective;
120
118
  monitor.setTask(thread.id, objective);
121
119
  await thread.control.retarget(objective);
122
- trajectoryStore.get(thread.id).trajectory.append({ kind: "retarget", objective });
123
120
  return { content: [{ type: "text", text: `Retargeted run #${thread.id} in the same session; the aborted objective will not be delivered as a completion.` }], details: {} };
124
121
  }
125
122
  case "park": {
@@ -229,8 +226,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
229
226
  typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
230
227
  ? params.timeoutMs
231
228
  : SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
232
- const isActive = (run: { status: RunStatus; retained?: boolean }): boolean =>
233
- isRunActiveStatus(run.status) || run.retained === true;
229
+ const isActive = (run: { status: RunStatus }): boolean => isRunActiveStatus(run.status);
234
230
 
235
231
  const requested = params.id?.trim();
236
232
  // A run that already settled resolves immediately with its result.
@@ -420,7 +416,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
420
416
 
421
417
  const now = Date.now();
422
418
  const activeRuns = monitor.getRuns().filter(
423
- (run) => isRunActiveStatus(run.status) || run.retained,
419
+ (run) => isRunActiveStatus(run.status),
424
420
  );
425
421
  const activeLines = activeRuns.map((run) => {
426
422
  const thread = runtime.threads.get(run.id);
@@ -651,7 +647,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
651
647
  }
652
648
  : {
653
649
  agent: thread.agentName,
654
- agentSource: "builtin",
655
650
  task: thread.task,
656
651
  exitCode: 1,
657
652
  messages: [],
@@ -663,8 +658,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
663
658
  errorMessage: stopMessage,
664
659
  runId,
665
660
  isolation: thread.isolation,
666
- originalCwd: thread.cwd,
667
- isolationCwd: thread.executionCwd,
668
661
  };
669
662
  const finalization = await thread.finalizeIsolation(generation, stoppedResult);
670
663
  if (finalization?.status === "retained") retainedIntegration.push(`#${runId}`);
@@ -672,24 +665,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
672
665
  thread.lastResult = stoppedResult;
673
666
  }
674
667
  monitor.setStatus(runId, "failed");
675
- if (stoppedResult) {
676
- const trajectoryState = trajectoryStore.get(runId);
677
- const alreadyStampedStopped = trajectoryState.trajectory.getGenerationEvents().some(
678
- (event) => event.kind === "settled" && event.status === "stopped",
679
- );
680
- if (!alreadyStampedStopped) {
681
- trajectoryState.trajectory.append({
682
- kind: "settled",
683
- status: "stopped",
684
- model: stoppedResult.model ?? run?.model,
685
- isolation: thread.isolation,
686
- ...(stoppedResult.integrationStatus && stoppedResult.integrationStatus !== "pending"
687
- ? { integrationStatus: stoppedResult.integrationStatus }
688
- : {}),
689
- });
690
- }
691
- completionResults.push(stoppedResult);
692
- }
668
+ if (stoppedResult) completionResults.push(stoppedResult);
693
669
  monitor.removeRun(runId);
694
670
  runtime.retireThreadSession(thread);
695
671
  if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
package/src/ui.ts CHANGED
@@ -45,9 +45,7 @@ export interface PickerStyles {
45
45
  filterEcho: (t: string) => string;
46
46
  }
47
47
 
48
- export interface PickerItem extends SelectItem {
49
- disabled?: boolean;
50
- }
48
+ export type PickerItem = SelectItem;
51
49
 
52
50
  export function pickerItemSearchText(item: PickerItem): string {
53
51
  return `${item.value} ${item.label} ${item.description ?? ""}`;
@@ -118,9 +116,7 @@ export class Picker implements Component, Focusable {
118
116
  const item = visible[i];
119
117
  const isCursor = start + i === this.cursor;
120
118
  const mark = isCursor ? s.cursorMark("❯ ") : " ";
121
- const label = item.disabled
122
- ? s.dim(item.label)
123
- : isCursor ? s.selectedLabel(item.label) : s.label(item.label);
119
+ const label = isCursor ? s.selectedLabel(item.label) : s.label(item.label);
124
120
  const description = item.description ? s.dim(` — ${item.description}`) : "";
125
121
  const line = this.multi
126
122
  ? mark + (this.selected.has(item.value) ? s.checked("[x] ") : s.unchecked("[ ] ")) + label + description
@@ -149,7 +145,7 @@ export class Picker implements Component, Focusable {
149
145
  if (this.multi) this.cb.onConfirm?.([...this.selected]);
150
146
  else {
151
147
  const item = this.filtered[this.cursor];
152
- if (item && !item.disabled) this.cb.onSelect?.(item.value);
148
+ if (item) this.cb.onSelect?.(item.value);
153
149
  }
154
150
  return;
155
151
  } else if (kb.matches(data, "tui.select.cancel")) {
package/src/widget.ts ADDED
@@ -0,0 +1,107 @@
1
+ /** Lightweight active-run widget for interactive Pi sessions. */
2
+
3
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import {
6
+ formatElapsed,
7
+ isRunActiveStatus,
8
+ monitor,
9
+ statusIcon,
10
+ statusLabel,
11
+ type RunView,
12
+ } from "./monitor.ts";
13
+
14
+ export const SUBAGENTS_WIDGET_ID = "pi-subagents";
15
+
16
+ /** Keep the elapsed tail visible while clipping the descriptive left side. */
17
+ function compactLine(left: string, right: string, width: number): string {
18
+ if (width <= 0) return "";
19
+ if (!right) return truncateToWidth(left, width, "");
20
+ const separator = " ";
21
+ const rightWidth = visibleWidth(right);
22
+ if (rightWidth >= width) return truncateToWidth(right, width, "");
23
+ const leftWidth = width - rightWidth - visibleWidth(separator);
24
+ if (leftWidth <= 0) return truncateToWidth(right, width, "");
25
+ return `${truncateToWidth(left, leftWidth, "…")}${separator}${right}`;
26
+ }
27
+
28
+ /** One compact line per genuinely active run. Settled and parked threads never
29
+ * appear, so elapsed time cannot keep ticking beside a terminal status. */
30
+ export function formatActiveRunLines(
31
+ runs: readonly RunView[],
32
+ theme: Theme,
33
+ width: number,
34
+ now: number = Date.now(),
35
+ ): string[] {
36
+ const dim = (text: string): string => theme.fg("dim", text);
37
+ return runs
38
+ .filter((run) => isRunActiveStatus(run.status))
39
+ .map((run) => {
40
+ const icon = statusIcon(run.status, theme);
41
+ const name = theme.fg("accent", theme.bold(run.agent));
42
+ const context = run.relationLabel ?? run.label;
43
+ const activity = run.status === "running"
44
+ ? (run.activity ?? statusLabel(run.status))
45
+ : statusLabel(run.status);
46
+ const parts = [
47
+ `${icon} ${dim(`#${run.id}`)} ${name}`,
48
+ context ? dim(`· ${context}`) : undefined,
49
+ activity ? dim(`· ${activity}`) : undefined,
50
+ ].filter((part): part is string => Boolean(part));
51
+ const elapsed = formatElapsed(run, now);
52
+ return compactLine(parts.join(" "), elapsed ? dim(elapsed) : "", width);
53
+ });
54
+ }
55
+
56
+ function hasTickingRun(): boolean {
57
+ return monitor.getRuns().some(
58
+ (run) => isRunActiveStatus(run.status) && run.startedAt !== undefined,
59
+ );
60
+ }
61
+
62
+ /** Install the widget for one TUI session. Its timer exists only while at least
63
+ * one active run has started and is disposed with the widget. */
64
+ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
65
+ if (ctx.mode !== "tui") return;
66
+ ctx.ui.setWidget(
67
+ SUBAGENTS_WIDGET_ID,
68
+ (tui, theme) => {
69
+ let timer: ReturnType<typeof setInterval> | undefined;
70
+ let disposed = false;
71
+
72
+ const syncTimer = (): void => {
73
+ if (disposed) return;
74
+ if (hasTickingRun()) {
75
+ if (timer) return;
76
+ timer = setInterval(() => tui.requestRender(), 1_000);
77
+ timer.unref?.();
78
+ return;
79
+ }
80
+ if (timer) clearInterval(timer);
81
+ timer = undefined;
82
+ };
83
+
84
+ const unsubscribe = monitor.subscribe(() => {
85
+ syncTimer();
86
+ tui.requestRender();
87
+ });
88
+ syncTimer();
89
+
90
+ return {
91
+ render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width),
92
+ invalidate() {},
93
+ dispose() {
94
+ disposed = true;
95
+ unsubscribe();
96
+ if (timer) clearInterval(timer);
97
+ timer = undefined;
98
+ },
99
+ };
100
+ },
101
+ { placement: "aboveEditor" },
102
+ );
103
+ }
104
+
105
+ export function clearActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
106
+ if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENTS_WIDGET_ID, undefined);
107
+ }
package/src/worktree.ts CHANGED
@@ -238,7 +238,7 @@ export interface WorktreeIsolation {
238
238
  * attempting to apply the same patch twice. */
239
239
  snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
240
240
  /** Remove a newly-created continuation that failed before it was dispatched. */
241
- discard?(): Promise<void>;
241
+ discard(): Promise<void>;
242
242
  /** Idempotent across stale generations and repeated stop/shutdown paths. */
243
243
  finalize(): Promise<WorktreeFinalization>;
244
244
  }
package/src/trajectory.ts DELETED
@@ -1,312 +0,0 @@
1
- /**
2
- * Append-only lifecycle trajectories for logical sub-agent threads.
3
- *
4
- * Events cover dispatch, model candidates, retries, controls, tool activity,
5
- * usage, worktrees, forks, and settlement. Each event keeps its generation and
6
- * timestamp across resume/restart; mutable summary fields reset per generation.
7
- * Tool arguments are reduced to a short terminal-safe summary with obvious
8
- * credential fields and embedded secrets redacted.
9
- */
10
-
11
- import { stripVTControlCharacters } from "node:util";
12
- import type { UsageStats } from "./rpc-run.ts";
13
- import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
14
-
15
- // ---------------------------------------------------------------------------
16
- // Event types
17
- // ---------------------------------------------------------------------------
18
-
19
- export const TRAJECTORY_VERSION = 1;
20
-
21
- /** Typed append-only trajectory events. */
22
- export type TrajectoryEvent =
23
- | { v: number; runId: number; generation: number; at: number; kind: "dispatch"; agent: string; task: string; model?: string; thinking?: string; pool?: readonly string[]; vision?: boolean; resumed?: boolean; isolation?: IsolationMode; originalCwd?: string; isolationCwd?: string }
24
- | { v: number; runId: number; generation: number; at: number; kind: "status"; status: string; phase?: string }
25
- | { v: number; runId: number; generation: number; at: number; kind: "candidate"; model?: string; index?: number; total?: number; fallbackFrom?: string }
26
- | { v: number; runId: number; generation: number; at: number; kind: "retry"; reason: string; delayMs?: number }
27
- | { v: number; runId: number; generation: number; at: number; kind: "steer"; instruction: string }
28
- | { v: number; runId: number; generation: number; at: number; kind: "retarget"; objective: string }
29
- | { v: number; runId: number; generation: number; at: number; kind: "park" }
30
- | { v: number; runId: number; generation: number; at: number; kind: "resume"; objective?: string }
31
- | { v: number; runId: number; generation: number; at: number; kind: "fork"; sourceRunId: number; childRunId: number; objective?: string }
32
- | { v: number; runId: number; generation: number; at: number; kind: "stop"; reason?: string }
33
- | { v: number; runId: number; generation: number; at: number; kind: "worktree"; status: "created" | WorktreeFinalizationStatus; originalCwd: string; isolationCwd?: string; worktreePath?: string; patchPath?: string; integrated?: boolean; error?: string }
34
- | { v: number; runId: number; generation: number; at: number; kind: "settled"; status: "done" | "failed" | "stopped"; model?: string; isolation?: IsolationMode; integrationStatus?: WorktreeFinalizationStatus }
35
- | { v: number; runId: number; generation: number; at: number; kind: "tool_start"; tool: string; toolCallId?: string; summary: string }
36
- | { v: number; runId: number; generation: number; at: number; kind: "tool_end"; tool: string; toolCallId?: string; isError: boolean; error?: string }
37
- | { v: number; runId: number; generation: number; at: number; kind: "usage"; usage: UsageStats; model?: string };
38
-
39
- export type TrajectoryEventKind = TrajectoryEvent["kind"];
40
-
41
- /** Distributive Omit: per-variant event payload without the envelope fields
42
- * (v/runId/generation/at are stamped by TrajectoryLog.append). */
43
- type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
44
- export type NewTrajectoryEvent = DistributiveOmit<TrajectoryEvent, "v" | "runId" | "generation" | "at">;
45
-
46
- // ---------------------------------------------------------------------------
47
- // Tool-arg safety: concise summary + redaction
48
- // ---------------------------------------------------------------------------
49
-
50
- /** Truncation budget for one scalar argument value inside a summary. */
51
- export const TOOL_ARG_VALUE_MAX = 48;
52
- /** Total budget for a summarized tool-args blob. */
53
- export const TOOL_ARG_SUMMARY_MAX = 160;
54
-
55
- const SENSITIVE_KEY_RE = /token|password|authorization|api[-_]?key|apikey|secret|credential|cookie|bearer/i;
56
- const REDACTED = "<redacted>";
57
-
58
- function isSensitiveKey(key: string): boolean {
59
- return SENSITIVE_KEY_RE.test(key);
60
- }
61
-
62
- /** Redact credentials embedded inside otherwise ordinary scalar fields such as
63
- * `command`. Key-only filtering is insufficient for shell/header arguments. */
64
- export function redactSensitiveText(value: string): string {
65
- let text = stripVTControlCharacters(value);
66
- text = text.replace(
67
- /(\bauthorization\s*:\s*(?:bearer|basic)\s+)([^\s'"`;,]+)/giu,
68
- `$1${REDACTED}`,
69
- );
70
- text = text.replace(
71
- /(\bbearer\s+)([A-Za-z0-9._~+/=-]{6,})/giu,
72
- `$1${REDACTED}`,
73
- );
74
- text = text.replace(
75
- /(\b(?:api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|token|password|passwd|secret|credential|cookie)\b\s*(?:=|:)\s*)(?:"[^"]*"|'[^']*'|[^\s;&,]+)/giu,
76
- `$1${REDACTED}`,
77
- );
78
- text = text.replace(
79
- /((?:--?(?:api[-_]?key|access[-_]?token|token|password|secret|credential))\s+)(?:"[^"]*"|'[^']*'|\S+)/giu,
80
- `$1${REDACTED}`,
81
- );
82
- text = text.replace(
83
- /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[A-Z0-9]{16})\b/gu,
84
- REDACTED,
85
- );
86
- return text;
87
- }
88
-
89
- function summarizeScalar(value: unknown): string | undefined {
90
- if (value === null || value === undefined || value === "") return undefined;
91
- let text: string;
92
- if (typeof value === "string") text = value;
93
- else if (typeof value === "number" || typeof value === "boolean") text = String(value);
94
- else return undefined; // arrays/objects are dropped from the summary
95
- const oneLine = value.toString() === "[object Object]" ? "" : redactSensitiveText(text).replace(/\s+/g, " ").trim();
96
- if (!oneLine) return undefined;
97
- const chars = [...oneLine];
98
- return chars.length > TOOL_ARG_VALUE_MAX ? `${chars.slice(0, TOOL_ARG_VALUE_MAX - 1).join("")}…` : oneLine;
99
- }
100
-
101
- /**
102
- * Concise, display-safe one-line summary of a tool-args payload. Only the first
103
- * few scalar fields are shown; sensitive-looking values are redacted; deep
104
- * structures are collapsed. Never throws. Output is width-agnostic plain text
105
- * (callers truncate to their display budget with truncateToWidth).
106
- */
107
- export function summarizeToolArgs(args: unknown): string {
108
- if (args === null || args === undefined) return "";
109
- if (typeof args !== "object") {
110
- const s = summarizeScalar(args);
111
- return s ? truncateSummary(s) : "";
112
- }
113
- const record = args as Record<string, unknown>;
114
- const parts: string[] = [];
115
- for (const [key, value] of Object.entries(record)) {
116
- if (parts.length >= 5) {
117
- parts.push("…");
118
- break;
119
- }
120
- if (isSensitiveKey(key)) {
121
- parts.push(`${key}=${REDACTED}`);
122
- continue;
123
- }
124
- const scalar = summarizeScalar(value);
125
- if (scalar !== undefined) parts.push(`${key}=${scalar}`);
126
- else if (value !== undefined && value !== null) parts.push(`${key}={…}`);
127
- }
128
- return truncateSummary(parts.filter(Boolean).join(" "));
129
- }
130
-
131
- function truncateSummary(text: string): string {
132
- const chars = [...text];
133
- return chars.length > TOOL_ARG_SUMMARY_MAX ? `${chars.slice(0, TOOL_ARG_SUMMARY_MAX - 1).join("")}…` : text;
134
- }
135
-
136
- // ---------------------------------------------------------------------------
137
- // Trajectory log
138
- // ---------------------------------------------------------------------------
139
-
140
- export interface TrajectorySummaryRecord {
141
- model?: string;
142
- thinking?: string;
143
- modelFallbackFrom?: string;
144
- toolCount: number;
145
- currentTool?: string;
146
- activity?: string;
147
- lastAt?: number;
148
- endedAt?: number;
149
- isolation?: IsolationMode;
150
- integrationStatus?: WorktreeFinalizationStatus | "pending";
151
- originalCwd?: string;
152
- isolationCwd?: string;
153
- forkedFromRunId?: number;
154
- forkChildRunIds?: number[];
155
- }
156
-
157
- /**
158
- * Append-only event log for one thread id. `clearSummary()` retires the mutable
159
- * latest-state fields at the start of a new generation while the event history
160
- * stays. `clearAll()` (parent-session teardown only) drops everything.
161
- */
162
- export class TrajectoryLog {
163
- private readonly events: TrajectoryEvent[] = [];
164
- /** Mutable latest-state summary; NOT append-only — reset per generation. */
165
- private summaryRecord: TrajectorySummaryRecord = { toolCount: 0 };
166
-
167
- constructor(
168
- readonly runId: number,
169
- private readonly notify: () => void,
170
- ) {}
171
-
172
- get generation(): number {
173
- return this.summaryGeneration;
174
- }
175
-
176
- /** Internal counter also stored on each event; bumped by restart(). Starts
177
- * at 1 to mirror the thread's generation numbering in the runtime. */
178
- private summaryGeneration = 1;
179
-
180
- append(event: NewTrajectoryEvent, now: number = Date.now()): void {
181
- const full = { v: TRAJECTORY_VERSION, runId: this.runId, generation: this.summaryGeneration, at: now, ...event } as TrajectoryEvent;
182
- this.events.push(full);
183
- this.applySummary(full);
184
- try {
185
- this.notify();
186
- } catch {
187
- /* observers must never break trajectory capture */
188
- }
189
- }
190
-
191
- /** All events in append order (oldest first). Generation ascends monotonically. */
192
- getEvents(): readonly TrajectoryEvent[] {
193
- return this.events;
194
- }
195
-
196
- /** Events of the latest generation only (oldest first). */
197
- getGenerationEvents(): readonly TrajectoryEvent[] {
198
- let start = this.events.length;
199
- for (let i = this.events.length - 1; i >= 0; i--) {
200
- if (this.events[i].generation === this.summaryGeneration) start = i;
201
- else break;
202
- }
203
- return this.events.slice(start);
204
- }
205
-
206
- summary(): Readonly<TrajectorySummaryRecord> {
207
- return this.summaryRecord;
208
- }
209
-
210
- /** Begin a new generation: same id, bumped generation, fresh mutable summary,
211
- * untouched event history. Appends no event itself — dispatch appends the
212
- * dispatch/resume event with the new-generation attributes. */
213
- restart(): number {
214
- this.summaryGeneration += 1;
215
- this.summaryRecord = { toolCount: 0 };
216
- return this.summaryGeneration;
217
- }
218
-
219
- private applySummary(event: TrajectoryEvent): void {
220
- const s = this.summaryRecord;
221
- s.lastAt = event.at;
222
- switch (event.kind) {
223
- case "dispatch":
224
- if (event.model !== undefined) s.model = event.model;
225
- if (event.isolation !== undefined) s.isolation = event.isolation;
226
- if (event.originalCwd !== undefined) s.originalCwd = event.originalCwd;
227
- if (event.isolationCwd !== undefined) s.isolationCwd = event.isolationCwd;
228
- if (event.isolation === "worktree") s.integrationStatus = "pending";
229
- if (event.thinking !== undefined) s.thinking = event.thinking;
230
- s.modelFallbackFrom = undefined;
231
- s.endedAt = undefined;
232
- s.currentTool = undefined;
233
- s.activity = undefined;
234
- break;
235
- case "candidate":
236
- if (event.model !== undefined) s.model = event.model;
237
- if (event.fallbackFrom !== undefined) s.modelFallbackFrom = event.fallbackFrom;
238
- break;
239
- case "tool_start":
240
- s.toolCount += 1;
241
- s.currentTool = event.tool;
242
- s.activity = event.summary ? `${event.tool} ${event.summary}` : event.tool;
243
- break;
244
- case "tool_end":
245
- if (s.currentTool === event.tool) s.currentTool = undefined;
246
- break;
247
- case "fork":
248
- if (event.runId === event.childRunId) s.forkedFromRunId = event.sourceRunId;
249
- if (event.runId === event.sourceRunId) {
250
- s.forkChildRunIds ??= [];
251
- if (!s.forkChildRunIds.includes(event.childRunId)) s.forkChildRunIds.push(event.childRunId);
252
- }
253
- break;
254
- case "worktree":
255
- s.isolation = "worktree";
256
- s.originalCwd = event.originalCwd;
257
- if (event.isolationCwd !== undefined) s.isolationCwd = event.isolationCwd;
258
- if (event.status !== "created") s.integrationStatus = event.status;
259
- break;
260
- case "settled":
261
- s.endedAt = event.at;
262
- s.currentTool = undefined;
263
- if (event.isolation !== undefined) s.isolation = event.isolation;
264
- if (event.integrationStatus !== undefined) s.integrationStatus = event.integrationStatus;
265
- break;
266
- case "status":
267
- s.activity = undefined;
268
- if (event.status === "parked" || event.status === "done" || event.status === "failed") s.endedAt ??= event.at;
269
- break;
270
- }
271
- }
272
-
273
- /** Parent-session teardown: wipe summary AND history. */
274
- clearAll(): void {
275
- this.events.length = 0;
276
- this.summaryGeneration = 1;
277
- this.summaryRecord = { toolCount: 0 };
278
- }
279
- }
280
-
281
- export class ThreadTrajectoryState {
282
- readonly trajectory: TrajectoryLog;
283
-
284
- constructor(readonly runId: number) {
285
- this.trajectory = new TrajectoryLog(runId, () => {});
286
- }
287
-
288
- get generation(): number {
289
- return this.trajectory.generation;
290
- }
291
- }
292
-
293
- /** Session-scoped registry of logical-thread trajectories. */
294
- export class TrajectoryStore {
295
- private readonly states = new Map<number, ThreadTrajectoryState>();
296
-
297
- get(runId: number): ThreadTrajectoryState {
298
- let state = this.states.get(runId);
299
- if (!state) {
300
- state = new ThreadTrajectoryState(runId);
301
- this.states.set(runId, state);
302
- }
303
- return state;
304
- }
305
-
306
- clearAll(): void {
307
- for (const state of this.states.values()) state.trajectory.clearAll();
308
- this.states.clear();
309
- }
310
- }
311
-
312
- export const trajectoryStore = new TrajectoryStore();