@ferris1225/pi-subagents 1.0.0 → 2.0.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.
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.
@@ -239,7 +235,10 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
239
235
  if (settledIds.length > 0) {
240
236
  return {
241
237
  content: [
242
- { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
238
+ { type: "text", text: settledIds.map((id) => {
239
+ const result = runtime.settledRuns.get(id)!;
240
+ return formatCompletionBlock(result, config.maxResultLines, result.projectCwd ?? ctx.cwd);
241
+ }).join("\n\n") },
243
242
  ],
244
243
  details: {},
245
244
  };
@@ -334,7 +333,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
334
333
 
335
334
  const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
336
335
  const blocks = outcomes.map((outcome) =>
337
- outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
336
+ outcome.result
337
+ ? formatCompletionBlock(outcome.result, config.maxResultLines, outcome.result.projectCwd ?? ctx.cwd)
338
+ : (outcome.note ?? "(no outcome)"),
338
339
  );
339
340
  return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
340
341
  },
@@ -387,7 +388,17 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
387
388
  if (settledIds.length > 0) {
388
389
  return {
389
390
  content: [
390
- { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
391
+ {
392
+ type: "text",
393
+ text: settledIds
394
+ .map((id) => formatCompletionBlock(
395
+ runtime.settledRuns.get(id)!,
396
+ config.maxResultLines,
397
+ runtime.settledRuns.get(id)!.projectCwd ?? ctx.cwd,
398
+ { failedToolDetails: true },
399
+ ))
400
+ .join("\n\n"),
401
+ },
391
402
  ],
392
403
  details: {},
393
404
  };
@@ -420,12 +431,12 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
420
431
 
421
432
  const now = Date.now();
422
433
  const activeRuns = monitor.getRuns().filter(
423
- (run) => isRunActiveStatus(run.status) || run.retained,
434
+ (run) => isRunActiveStatus(run.status),
424
435
  );
425
436
  const activeLines = activeRuns.map((run) => {
426
437
  const thread = runtime.threads.get(run.id);
427
438
  const model = run.modelFallbackFrom
428
- ? `${run.model ?? "?"} (pool fallback from ${run.modelFallbackFrom})`
439
+ ? `${run.model ?? "?"} (main after ${run.modelFallbackFrom} failed)`
429
440
  : (run.model ?? "?");
430
441
  const parts = [
431
442
  `#${run.id} ${run.agent}`,
@@ -454,7 +465,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
454
465
  const usage = formatUsage(result.usage);
455
466
  const label = runLabel(result.task);
456
467
  const model = result.modelFallbackFrom
457
- ? `${result.model ?? "?"} (pool fallback from ${result.modelFallbackFrom})`
468
+ ? `${result.model ?? "?"} (main after ${result.modelFallbackFrom} failed)`
458
469
  : (result.model ?? "?");
459
470
  const isolation = result.isolation === "worktree" ? ` · worktree ${result.integrationStatus ?? "unknown"}` : "";
460
471
  const relations = [
@@ -651,7 +662,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
651
662
  }
652
663
  : {
653
664
  agent: thread.agentName,
654
- agentSource: "builtin",
655
665
  task: thread.task,
656
666
  exitCode: 1,
657
667
  messages: [],
@@ -659,12 +669,11 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
659
669
  usage: emptyUsage(),
660
670
  model: run?.model,
661
671
  thinking: run?.thinking,
672
+ projectCwd: thread.cwd,
662
673
  stopReason: "aborted",
663
674
  errorMessage: stopMessage,
664
675
  runId,
665
676
  isolation: thread.isolation,
666
- originalCwd: thread.cwd,
667
- isolationCwd: thread.executionCwd,
668
677
  };
669
678
  const finalization = await thread.finalizeIsolation(generation, stoppedResult);
670
679
  if (finalization?.status === "retained") retainedIntegration.push(`#${runId}`);
@@ -672,24 +681,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
672
681
  thread.lastResult = stoppedResult;
673
682
  }
674
683
  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
- }
684
+ if (stoppedResult) completionResults.push(stoppedResult);
693
685
  monitor.removeRun(runId);
694
686
  runtime.retireThreadSession(thread);
695
687
  if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
@@ -701,7 +693,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
701
693
  const maxResultLines = (await configPromise)?.maxResultLines ?? DEFAULT_MAX_RESULT_LINES;
702
694
  runtime.sendCompletionGroup(completionResults.map((result) => ({
703
695
  agent: result.agent,
704
- block: formatCompletionBlock(result, maxResultLines, ctx.cwd),
696
+ block: formatCompletionBlock(result, maxResultLines, result.projectCwd ?? ctx.cwd),
705
697
  triggerTurn: true,
706
698
  })));
707
699
  runtime.completionBatcher.flush();
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,144 @@
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
+ formatTaskSummary,
8
+ isRunActiveStatus,
9
+ monitor,
10
+ statusIcon,
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 primary line per genuinely active run, plus an optional indented
29
+ * activity line. The primary line reserves effective model/thinking and elapsed
30
+ * width before truncating the task. Settled and parked threads never appear, so
31
+ * elapsed time cannot keep ticking beside a terminal status. */
32
+ export function formatActiveRunLines(
33
+ runs: readonly RunView[],
34
+ theme: Theme,
35
+ width: number,
36
+ now: number = Date.now(),
37
+ ): string[] {
38
+ const dim = (text: string): string => theme.fg("dim", text);
39
+ return runs
40
+ .filter((run) => isRunActiveStatus(run.status))
41
+ .flatMap((run) => {
42
+ const icon = statusIcon(run.status, theme);
43
+ const name = theme.fg("accent", theme.bold(run.agent));
44
+ const identity = `${icon} ${dim(`#${run.id}`)} ${name}`;
45
+ const elapsed = formatElapsed(run, now);
46
+ // Render only the resolved model id plus thinking level. Provider auth and
47
+ // other configuration never enter monitor state or this line.
48
+ const modelId = run.model?.split("/").at(-1);
49
+ const modelSource = formatTaskSummary(
50
+ modelId ? `${modelId}${run.thinking ? `/${run.thinking}` : ""}` : run.thinking ? `thinking:${run.thinking}` : "",
51
+ 64,
52
+ false,
53
+ );
54
+ const taskSource = formatTaskSummary(run.task, 64);
55
+ const primaryPartCount = 2 + (modelSource ? 1 : 0) + (elapsed ? 1 : 0);
56
+ const contentWidth = Math.max(
57
+ 0,
58
+ width -
59
+ visibleWidth(identity) -
60
+ visibleWidth(elapsed) -
61
+ (primaryPartCount - 1) * visibleWidth(" · "),
62
+ );
63
+ const modelDesired = visibleWidth(modelSource);
64
+ const modelFloor = Math.min(modelDesired, Math.min(12, contentWidth));
65
+ let modelWidth = modelSource
66
+ ? Math.min(modelDesired, Math.max(modelFloor, contentWidth - 8))
67
+ : 0;
68
+ let taskWidth = contentWidth - modelWidth;
69
+ if (visibleWidth(taskSource) < taskWidth) {
70
+ modelWidth = Math.min(modelDesired, modelWidth + taskWidth - visibleWidth(taskSource));
71
+ taskWidth = contentWidth - modelWidth;
72
+ }
73
+ const task = taskWidth > 0 ? formatTaskSummary(taskSource, taskWidth) : "";
74
+ const modelThinking = modelWidth > 0 ? formatTaskSummary(modelSource, modelWidth, false) : "";
75
+ const primaryLeft = [
76
+ identity,
77
+ task ? dim(task) : undefined,
78
+ modelThinking ? dim(modelThinking) : undefined,
79
+ ].filter((part): part is string => Boolean(part)).join(" · ");
80
+ const primary = compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
81
+
82
+ const activity = run.activity?.trim();
83
+ if (!activity) return [primary];
84
+ const activityIndent = " ";
85
+ const activityWidth = width - visibleWidth(activityIndent);
86
+ if (activityWidth <= 0) return [primary];
87
+ const activitySummary = formatTaskSummary(activity, activityWidth);
88
+ if (!activitySummary) return [primary];
89
+ return [primary, truncateToWidth(`${activityIndent}${dim(activitySummary)}`, width, "")];
90
+ });
91
+ }
92
+
93
+ function hasTickingRun(): boolean {
94
+ return monitor.getRuns().some(
95
+ (run) => isRunActiveStatus(run.status) && run.startedAt !== undefined,
96
+ );
97
+ }
98
+
99
+ /** Install the widget for one TUI session. Its timer exists only while at least
100
+ * one active run has started and is disposed with the widget. */
101
+ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
102
+ if (ctx.mode !== "tui") return;
103
+ ctx.ui.setWidget(
104
+ SUBAGENTS_WIDGET_ID,
105
+ (tui, theme) => {
106
+ let timer: ReturnType<typeof setInterval> | undefined;
107
+ let disposed = false;
108
+
109
+ const syncTimer = (): void => {
110
+ if (disposed) return;
111
+ if (hasTickingRun()) {
112
+ if (timer) return;
113
+ timer = setInterval(() => tui.requestRender(), 1_000);
114
+ timer.unref?.();
115
+ return;
116
+ }
117
+ if (timer) clearInterval(timer);
118
+ timer = undefined;
119
+ };
120
+
121
+ const unsubscribe = monitor.subscribe(() => {
122
+ syncTimer();
123
+ tui.requestRender();
124
+ });
125
+ syncTimer();
126
+
127
+ return {
128
+ render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width),
129
+ invalidate() {},
130
+ dispose() {
131
+ disposed = true;
132
+ unsubscribe();
133
+ if (timer) clearInterval(timer);
134
+ timer = undefined;
135
+ },
136
+ };
137
+ },
138
+ { placement: "aboveEditor" },
139
+ );
140
+ }
141
+
142
+ export function clearActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
143
+ if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENTS_WIDGET_ID, undefined);
144
+ }
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();