@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/LICENSE +2 -0
- package/README.md +188 -95
- package/agents/cleaner.md +51 -0
- package/agents/explore.md +6 -4
- package/agents/reviewer.md +2 -0
- package/package.json +9 -7
- package/src/agents.ts +2 -7
- package/src/announcements.ts +12 -1
- package/src/completion.ts +7 -36
- package/src/config.ts +310 -364
- package/src/dispatch.ts +145 -275
- package/src/fixloop.ts +0 -16
- package/src/format.ts +28 -30
- package/src/index.ts +6 -3
- package/src/models.ts +89 -106
- package/src/monitor.ts +57 -101
- package/src/prompt.ts +13 -8
- package/src/rpc-run.ts +90 -21
- package/src/runtime.ts +3 -17
- package/src/session-fork.ts +0 -4
- package/src/setup.ts +437 -639
- package/src/spawn.ts +587 -557
- package/src/tools.ts +27 -35
- package/src/ui.ts +3 -7
- package/src/widget.ts +144 -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.
|
|
@@ -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) =>
|
|
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
|
|
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
|
-
{
|
|
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)
|
|
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 ?? "?"} (
|
|
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 ?? "?"} (
|
|
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
|
|
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,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
|
|
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();
|