@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/README.md +18 -14
- package/agents/reviewer.md +2 -0
- package/package.json +1 -1
- package/src/agents.ts +2 -7
- package/src/announcements.ts +4 -1
- package/src/completion.ts +7 -36
- package/src/config.ts +327 -364
- package/src/dispatch.ts +47 -198
- package/src/fixloop.ts +0 -16
- package/src/format.ts +2 -7
- package/src/index.ts +5 -2
- package/src/models.ts +0 -10
- package/src/monitor.ts +46 -98
- package/src/rpc-run.ts +1 -15
- package/src/runtime.ts +3 -15
- package/src/session-fork.ts +0 -4
- package/src/spawn.ts +542 -557
- package/src/tools.ts +5 -29
- package/src/ui.ts +3 -7
- package/src/widget.ts +107 -0
- package/src/worktree.ts +1 -1
- package/src/trajectory.ts +0 -312
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 {
|
|
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
|
|
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)
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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();
|