pi-squire 0.9.5 → 0.10.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/CHANGELOG.md +85 -1
- package/README.md +305 -16
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +415 -65
- package/src/render.ts +106 -1
- package/src/result-sessions.ts +47 -0
- package/src/result-types.ts +36 -0
- package/src/results.ts +122 -77
- package/src/snapshot.ts +207 -0
- package/src/tmux.ts +30 -3
- package/src/tracking-attempt.ts +65 -0
- package/src/tracking.ts +78 -20
- package/src/workspace.ts +420 -0
- package/src/worktree-placement.ts +138 -0
- package/src/worktree.ts +474 -0
package/src/render.ts
CHANGED
|
@@ -18,13 +18,106 @@ import { Box, type Component, Container, Markdown, Spacer, Text, TruncatedText }
|
|
|
18
18
|
import type { Static } from "typebox";
|
|
19
19
|
|
|
20
20
|
import type { DelegateSchema } from "./index.ts";
|
|
21
|
-
import type { MessageDetails } from "./results.ts";
|
|
21
|
+
import type { MessageDetails, ResourceFacts, WorkspaceAttempt } from "./results.ts";
|
|
22
|
+
import type { WorkspaceSnapshot } from "./snapshot.ts";
|
|
23
|
+
import type { PathSummary, Workspace } from "./workspace.ts";
|
|
22
24
|
|
|
23
25
|
/** The launch model, or `launch → finished` when the delegate finished on another one. */
|
|
24
26
|
export function modelLabel(model: string, finished: string | undefined): string {
|
|
25
27
|
return finished === undefined || finished === model ? model : `${model} → ${finished}`;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
const displayReason = (reason: string): string => JSON.stringify(reason).slice(1, -1);
|
|
31
|
+
|
|
32
|
+
/** Where a delegation ran, or that its record, from before cwd was kept, does not say. */
|
|
33
|
+
export function cwdLine(cwd: string | undefined): string {
|
|
34
|
+
return cwd ?? "unknown (not recorded)";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How a delegation's directory was chosen and its Git checkout at launch, on one line. */
|
|
38
|
+
export function workspaceLine(workspace: Workspace): string {
|
|
39
|
+
if (workspace.mode === "managed") {
|
|
40
|
+
const { createdBranch, requestedBase, baseCommit, source, cwd } = workspace;
|
|
41
|
+
return `managed worktree ${cwd}, new branch ${createdBranch} created from ${requestedBase} (${baseCommit}) of ${source.checkoutRoot}; ${dirtyNotice(workspace.sourceDirty)}`;
|
|
42
|
+
}
|
|
43
|
+
const { mode, git } = workspace;
|
|
44
|
+
const how = mode === "inherited" ? "the parent's directory" : "existing directory";
|
|
45
|
+
if (git.kind === "non-git") return `${how}; not a Git checkout`;
|
|
46
|
+
if (git.kind === "unknown") return `${how}; Git state unknown (${displayReason(git.reason)})`;
|
|
47
|
+
const { mainRoot, checkoutRoot } = git.identity;
|
|
48
|
+
const linked = checkoutRoot === mainRoot ? "" : ` (linked worktree ${checkoutRoot})`;
|
|
49
|
+
const at = git.position.detached ? "detached HEAD" : `branch ${git.position.branch}`;
|
|
50
|
+
return `${how}; Git checkout ${mainRoot}${linked}, ${at}, launch commit ${git.baseline ?? "none (no commits yet)"}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The source's uncommitted paths a worktree does not carry, bounded to a few names. */
|
|
54
|
+
export function dirtyNotice({ paths, total }: PathSummary): string {
|
|
55
|
+
if (total === 0) return "the source had no uncommitted files";
|
|
56
|
+
const shown = paths.slice(0, 5).map((path) => JSON.stringify(path));
|
|
57
|
+
const rest = total > shown.length ? `, and ${total - shown.length} more` : "";
|
|
58
|
+
return `${total} uncommitted source path${total === 1 ? " is" : "s are"} not copied into it (${shown.join(", ")}${rest})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What is known to exist for an attempt, in words. */
|
|
62
|
+
export function resourceLine(resources: ResourceFacts): string {
|
|
63
|
+
switch (resources.kind) {
|
|
64
|
+
case "none":
|
|
65
|
+
return "nothing created by this attempt remains";
|
|
66
|
+
case "branch":
|
|
67
|
+
return `the branch ${resources.branch} remains at ${resources.head}; its worktree is gone`;
|
|
68
|
+
case "worktree":
|
|
69
|
+
return `the worktree ${resources.cwd} and its branch ${resources.branch} (at ${resources.head}) remain`;
|
|
70
|
+
case "unknown":
|
|
71
|
+
return `unknown: ${displayReason(resources.reason)}; observed paths ${JSON.stringify(resources.observedPaths)}, refs ${JSON.stringify(resources.observedRefs)}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The lines that describe an attempt without a window: its plan, what exists, why it stopped and recovery. */
|
|
76
|
+
export function attemptLines({ plan, attempt, requestId }: WorkspaceAttempt): string[] {
|
|
77
|
+
let why: string[] = [];
|
|
78
|
+
if ("cause" in attempt) why = [`cause: ${attempt.cause}`];
|
|
79
|
+
else if ("reason" in attempt) why = [`reason: ${attempt.reason}`];
|
|
80
|
+
return [
|
|
81
|
+
`source: ${plan.sourceCwd} (repository ${plan.source.mainRoot})`,
|
|
82
|
+
`worktree: ${plan.destination} on new branch ${plan.branch} from ${plan.requestedBase} (${plan.baseCommit})`,
|
|
83
|
+
`resources: ${resourceLine(attempt.resources)}`,
|
|
84
|
+
...why,
|
|
85
|
+
...(attempt.phase === "retained" && attempt.recovery.length > 0
|
|
86
|
+
? ["recovery (non-forced; check each command's result):", ...attempt.recovery.map((line) => ` ${line}`)]
|
|
87
|
+
: []),
|
|
88
|
+
...(requestId === undefined
|
|
89
|
+
? []
|
|
90
|
+
: [`request: ${requestId} (the task was mailed; no window id/start was recorded and a window may still exist)`]),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A path list as data: each name JSON-quoted, so spaces, quotes and newlines stay inside one line. */
|
|
95
|
+
function pathList({ paths, total }: PathSummary): string {
|
|
96
|
+
if (total === 0) return "none";
|
|
97
|
+
const more = total > paths.length ? `, and ${total - paths.length} more not listed` : "";
|
|
98
|
+
return `${total} path${total === 1 ? "" : "s"}: ${paths.map((path) => JSON.stringify(path)).join(", ")}${more}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A checkout observation: a summary line, then for Git one line per path
|
|
103
|
+
* category. Committed changes are counted from the launch commit; pending
|
|
104
|
+
* ones are the index and working tree now.
|
|
105
|
+
*/
|
|
106
|
+
export function snapshotLines(snapshot: WorkspaceSnapshot): string[] {
|
|
107
|
+
if (snapshot.kind === "non-git") return ["not a Git checkout"];
|
|
108
|
+
if (snapshot.kind === "unknown") return [`Git state unknown (${displayReason(snapshot.reason)})`];
|
|
109
|
+
const { position, head, baseline, ahead, workingState, committed, pending } = snapshot;
|
|
110
|
+
const at = position.detached ? "detached HEAD" : `branch ${position.branch}`;
|
|
111
|
+
return [
|
|
112
|
+
`${at}, head ${head}, launch commit ${baseline}, ${ahead} commit${ahead === 1 ? "" : "s"} since launch, working tree ${workingState}`,
|
|
113
|
+
`committed since launch: ${pathList(committed)}`,
|
|
114
|
+
`staged: ${pathList(pending.staged)}`,
|
|
115
|
+
`unstaged: ${pathList(pending.unstaged)}`,
|
|
116
|
+
`untracked: ${pathList(pending.untracked)}`,
|
|
117
|
+
`conflicted: ${pathList(pending.conflicted)}`,
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
28
121
|
/** Lines of a result the collapsed message previews. */
|
|
29
122
|
const PREVIEW_LINES = 5;
|
|
30
123
|
/** Characters of the task's first line the call row shows. */
|
|
@@ -63,6 +156,12 @@ function idLines(details: MessageDetails): string[] {
|
|
|
63
156
|
const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
|
|
64
157
|
if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
|
|
65
158
|
else lines.push(`window ${details.windowName}`);
|
|
159
|
+
lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
|
|
160
|
+
if (details.workspace !== undefined) lines.push(`workspace ${workspaceLine(details.workspace)}`);
|
|
161
|
+
if (details.kind === "result" && details.workspaceSnapshot !== undefined) {
|
|
162
|
+
const [first, ...rest] = snapshotLines(details.workspaceSnapshot);
|
|
163
|
+
lines.push(`checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`));
|
|
164
|
+
}
|
|
66
165
|
lines.push(
|
|
67
166
|
`session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
|
|
68
167
|
);
|
|
@@ -194,6 +293,12 @@ export function renderDelegateResult(
|
|
|
194
293
|
...started.notices,
|
|
195
294
|
`delegation ${started.id}`,
|
|
196
295
|
`window ${started.windowName} (${started.windowId})`,
|
|
296
|
+
// Results saved before cwd was recorded have none.
|
|
297
|
+
`cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
|
|
298
|
+
// Results saved before workspaces were recorded have none.
|
|
299
|
+
...((started.workspace as Workspace | undefined) === undefined
|
|
300
|
+
? []
|
|
301
|
+
: [`workspace ${workspaceLine(started.workspace)}`]),
|
|
197
302
|
`thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
|
|
198
303
|
].map((line) => theme.fg("dim", line)),
|
|
199
304
|
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import type { StartedRecorded } from "./result-types.ts";
|
|
4
|
+
|
|
5
|
+
/** The delegate session file in a directory, found by its `_<id>.jsonl` suffix. */
|
|
6
|
+
export function findSession(dir: string | undefined, id: string): string | undefined {
|
|
7
|
+
if (dir === undefined) return undefined;
|
|
8
|
+
let names: string[];
|
|
9
|
+
try {
|
|
10
|
+
names = readdirSync(dir);
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
|
|
15
|
+
return name === undefined ? undefined : join(dir, name);
|
|
16
|
+
}
|
|
17
|
+
/** Use a recorded absolute session path, otherwise search its recorded session directory. */
|
|
18
|
+
export function locateSession(record: StartedRecorded, fallbackDir: string | undefined): string | undefined {
|
|
19
|
+
const known = record.sessionPath ?? record.result?.sessionPath;
|
|
20
|
+
if (known !== undefined) return known;
|
|
21
|
+
const found = findSession(record.sessionDir ?? fallbackDir, record.id);
|
|
22
|
+
if (found !== undefined && isAbsolute(found)) record.sessionPath = found;
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
/** The provider/model of the last assistant message in the delegate session, if available. */
|
|
26
|
+
export function finishedModel(sessionPath: string | undefined): string | undefined {
|
|
27
|
+
if (sessionPath === undefined) return undefined;
|
|
28
|
+
let lines: string[];
|
|
29
|
+
try {
|
|
30
|
+
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
35
|
+
let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
|
|
36
|
+
try {
|
|
37
|
+
entry = JSON.parse(lines[i]);
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const message = entry?.message;
|
|
42
|
+
if (entry?.type !== "message" || message?.role !== "assistant") continue;
|
|
43
|
+
if (typeof message.provider === "string" && typeof message.model === "string")
|
|
44
|
+
return `${message.provider}/${message.model}`;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Delegation } from "./results.ts";
|
|
2
|
+
import type { WorktreePlan } from "./worktree.ts";
|
|
3
|
+
|
|
4
|
+
/** One started delegation as it stands: what was recorded at its start, plus what came after. */
|
|
5
|
+
export type StartedRecorded = Delegation & { result?: import("./results.ts").Result; closed?: boolean; gone?: boolean };
|
|
6
|
+
/** What is known to exist for an attempt; facts, not authorization to remove anything. */
|
|
7
|
+
export type ResourceFacts =
|
|
8
|
+
| { kind: "none" }
|
|
9
|
+
| { kind: "branch"; branch: string; head: string }
|
|
10
|
+
| { kind: "worktree"; cwd: string; branch: string; head: string }
|
|
11
|
+
| { kind: "unknown"; reason: string; observedPaths: string[]; observedRefs: string[] };
|
|
12
|
+
/** Where a managed launch stands before, or instead of, a window. */
|
|
13
|
+
export type AttemptState =
|
|
14
|
+
| { phase: "planned"; resources: ResourceFacts }
|
|
15
|
+
| { phase: "created"; resources: ResourceFacts }
|
|
16
|
+
| { phase: "rolled-back"; resources: { kind: "none" }; cause: string }
|
|
17
|
+
| { phase: "retained"; resources: ResourceFacts; cause: string; recovery: string[] }
|
|
18
|
+
| { phase: "interrupted"; resources: ResourceFacts; lastPhase: "planned" | "created"; reason: string };
|
|
19
|
+
/** A managed launch with no window; its request id exists only if mail returned one. */
|
|
20
|
+
export type WorkspaceAttempt = {
|
|
21
|
+
kind: "workspace-attempt";
|
|
22
|
+
id: string;
|
|
23
|
+
parentId: string;
|
|
24
|
+
agent: string;
|
|
25
|
+
model: string;
|
|
26
|
+
thinking: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
sessionDir: string;
|
|
29
|
+
plan: WorktreePlan;
|
|
30
|
+
attempt: AttemptState;
|
|
31
|
+
requestId?: string;
|
|
32
|
+
};
|
|
33
|
+
export type Recorded = StartedRecorded | WorkspaceAttempt;
|
|
34
|
+
export function isStarted(record: Recorded): record is StartedRecorded {
|
|
35
|
+
return !("kind" in record && record.kind === "workspace-attempt");
|
|
36
|
+
}
|
package/src/results.ts
CHANGED
|
@@ -4,13 +4,25 @@
|
|
|
4
4
|
// the message over, shows the parent one delegate result and records the
|
|
5
5
|
// result. The records are the one source of truth for a delegation's state,
|
|
6
6
|
// for the footer, `delegation_status` and `delegation_close`.
|
|
7
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
7
|
|
|
10
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
9
|
|
|
12
10
|
import { readMarks } from "./limits.ts";
|
|
13
|
-
import { modelLabel, renderMessage } from "./render.ts";
|
|
11
|
+
import { cwdLine, modelLabel, renderMessage, snapshotLines, workspaceLine } from "./render.ts";
|
|
12
|
+
import { finishedModel, locateSession } from "./result-sessions.ts";
|
|
13
|
+
import {
|
|
14
|
+
type AttemptState,
|
|
15
|
+
isStarted,
|
|
16
|
+
type Recorded,
|
|
17
|
+
type StartedRecorded,
|
|
18
|
+
type WorkspaceAttempt,
|
|
19
|
+
} from "./result-types.ts";
|
|
20
|
+
import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
|
|
21
|
+
import type { Workspace } from "./workspace.ts";
|
|
22
|
+
|
|
23
|
+
export { findSession, finishedModel } from "./result-sessions.ts";
|
|
24
|
+
export type { AttemptState, Recorded, ResourceFacts, StartedRecorded, WorkspaceAttempt } from "./result-types.ts";
|
|
25
|
+
export { isStarted, locateSession };
|
|
14
26
|
|
|
15
27
|
/** The session entry type both delegation records and results use. */
|
|
16
28
|
export const CUSTOM_TYPE = "delegate";
|
|
@@ -36,6 +48,14 @@ export type Delegation = {
|
|
|
36
48
|
requestId: string;
|
|
37
49
|
/** Whether the delegate closes its own window after a normal completion; absent in records from before auto-exit. */
|
|
38
50
|
autoExit?: boolean;
|
|
51
|
+
/** The directory its window opened in; absent in records from before it was recorded, where it is unknown. */
|
|
52
|
+
cwd?: string;
|
|
53
|
+
/** The session-storage directory passed as `--session-dir`; absent in older records. */
|
|
54
|
+
sessionDir?: string;
|
|
55
|
+
/** The delegate's session file once found; never set at launch, when the file may not exist yet. */
|
|
56
|
+
sessionPath?: string;
|
|
57
|
+
/** How its directory was chosen and its Git checkout at launch; absent in older records. */
|
|
58
|
+
workspace?: Workspace;
|
|
39
59
|
};
|
|
40
60
|
|
|
41
61
|
/**
|
|
@@ -67,6 +87,8 @@ export type Result = {
|
|
|
67
87
|
sessionPath?: string;
|
|
68
88
|
/** The `provider/model` of the delegate's last assistant message, when its session file holds one. */
|
|
69
89
|
finishedModel?: string;
|
|
90
|
+
/** The checkout as observed when the result arrived; absent in results from before it was kept. */
|
|
91
|
+
workspaceSnapshot?: WorkspaceSnapshot;
|
|
70
92
|
};
|
|
71
93
|
|
|
72
94
|
/** What both delegate messages carry for their view, beside the text the model reads. */
|
|
@@ -79,6 +101,9 @@ type CommonDetails = {
|
|
|
79
101
|
id: string;
|
|
80
102
|
requestId: string;
|
|
81
103
|
sessionPath?: string;
|
|
104
|
+
cwd?: string;
|
|
105
|
+
sessionDir?: string;
|
|
106
|
+
workspace?: Workspace;
|
|
82
107
|
};
|
|
83
108
|
|
|
84
109
|
/** The task as the result quotes it: its text and copy path, or why there is none. */
|
|
@@ -95,21 +120,13 @@ export type MessageDetails =
|
|
|
95
120
|
task: TaskQuote;
|
|
96
121
|
/** The reply body, capped. */
|
|
97
122
|
body: string;
|
|
123
|
+
/** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
|
|
124
|
+
workspaceSnapshot?: WorkspaceSnapshot;
|
|
98
125
|
})
|
|
99
126
|
| (CommonDetails & { kind: "gone"; windowName: string });
|
|
100
127
|
|
|
101
128
|
type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
|
|
102
129
|
|
|
103
|
-
/** One delegation as it stands: what was recorded at its start, plus what came after. */
|
|
104
|
-
export type Recorded = Delegation & {
|
|
105
|
-
/** Set when a result arrived. */
|
|
106
|
-
result?: Result;
|
|
107
|
-
/** Set when `delegation_close` recorded a close. */
|
|
108
|
-
closed?: boolean;
|
|
109
|
-
/** Set when the poll found the window gone with no result; a result outranks it. */
|
|
110
|
-
gone?: boolean;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
130
|
/** The provider's payload on `message:inbound`. */
|
|
114
131
|
export type Inbound = {
|
|
115
132
|
envelope: Envelope;
|
|
@@ -121,12 +138,14 @@ export type Inbound = {
|
|
|
121
138
|
export type Results = {
|
|
122
139
|
/** Rebuild the records from the session's custom entries, as on a resume. */
|
|
123
140
|
restore(ctx: ExtensionContext): void;
|
|
124
|
-
/** Record a delegation and show it as running. */
|
|
141
|
+
/** Record a delegation and show it as running; it supersedes an attempt with the same id. */
|
|
125
142
|
recordStart(delegation: Delegation, ctx: ExtensionContext): void;
|
|
143
|
+
/** Record a workspace attempt, or its next state; never counted as running. */
|
|
144
|
+
recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
|
|
126
145
|
/** Record a close; does nothing when there is no such delegation or it is already closed. */
|
|
127
146
|
recordClose(id: string): void;
|
|
128
147
|
/** Delegations with no result, no recorded close and no gone-window record. */
|
|
129
|
-
running():
|
|
148
|
+
running(): StartedRecorded[];
|
|
130
149
|
/** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
|
|
131
150
|
recordGone(id: string): boolean;
|
|
132
151
|
/** Every delegation, in the order it was recorded. */
|
|
@@ -143,7 +162,14 @@ export type WindowState = "open" | "gone" | "unknown";
|
|
|
143
162
|
* closed without a result (the poll found the window missing); `window-closed`
|
|
144
163
|
* is a window tmux no longer lists, seen when asked directly.
|
|
145
164
|
*/
|
|
146
|
-
export type DelegationState =
|
|
165
|
+
export type DelegationState =
|
|
166
|
+
| "closed"
|
|
167
|
+
| "done"
|
|
168
|
+
| "gone"
|
|
169
|
+
| "running"
|
|
170
|
+
| "window-closed"
|
|
171
|
+
| "unknown"
|
|
172
|
+
| AttemptState["phase"];
|
|
147
173
|
|
|
148
174
|
/** How much of a delegation id a delegate's session name carries. */
|
|
149
175
|
const SHORT_ID = 8;
|
|
@@ -163,6 +189,7 @@ export function recordedName(record: Recorded): string {
|
|
|
163
189
|
|
|
164
190
|
/** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
|
|
165
191
|
export function deriveState(record: Recorded, window: WindowState): DelegationState {
|
|
192
|
+
if (!isStarted(record)) return record.attempt.phase;
|
|
166
193
|
if (record.closed === true) return "closed";
|
|
167
194
|
if (record.result !== undefined) return "done";
|
|
168
195
|
if (record.gone === true) return "gone";
|
|
@@ -180,47 +207,6 @@ function cap(text: string, max: number): string | undefined {
|
|
|
180
207
|
return bytes.subarray(0, end).toString("utf8");
|
|
181
208
|
}
|
|
182
209
|
|
|
183
|
-
/** The delegate's session file in `dir`, found by its `_<id>.jsonl` suffix. */
|
|
184
|
-
export function findSession(dir: string | undefined, id: string): string | undefined {
|
|
185
|
-
if (dir === undefined) return undefined;
|
|
186
|
-
let names: string[];
|
|
187
|
-
try {
|
|
188
|
-
names = readdirSync(dir);
|
|
189
|
-
} catch {
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
|
|
193
|
-
return name === undefined ? undefined : join(dir, name);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The `provider/model` of the last assistant message in a delegate's session
|
|
198
|
-
* file: the model that actually answered, after any fallback or `/model`.
|
|
199
|
-
* Undefined when the file is missing or holds no assistant message.
|
|
200
|
-
*/
|
|
201
|
-
export function finishedModel(sessionPath: string | undefined): string | undefined {
|
|
202
|
-
if (sessionPath === undefined) return undefined;
|
|
203
|
-
let lines: string[];
|
|
204
|
-
try {
|
|
205
|
-
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
206
|
-
} catch {
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
210
|
-
let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
|
|
211
|
-
try {
|
|
212
|
-
entry = JSON.parse(lines[i]);
|
|
213
|
-
} catch {
|
|
214
|
-
continue; // a line still being written, or a blank one
|
|
215
|
-
}
|
|
216
|
-
const message = entry?.message;
|
|
217
|
-
if (entry?.type !== "message" || message?.role !== "assistant") continue;
|
|
218
|
-
if (typeof message.provider === "string" && typeof message.model === "string")
|
|
219
|
-
return `${message.provider}/${message.model}`;
|
|
220
|
-
}
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
210
|
/** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
|
|
225
211
|
function limitLine(id: string, status: string): string | undefined {
|
|
226
212
|
const now = Date.now();
|
|
@@ -235,10 +221,11 @@ function limitLine(id: string, status: string): string | undefined {
|
|
|
235
221
|
|
|
236
222
|
/** The details of a settled delegation's result message. */
|
|
237
223
|
function resultDetails(
|
|
238
|
-
delegation:
|
|
224
|
+
delegation: StartedRecorded,
|
|
239
225
|
payload: Inbound,
|
|
240
226
|
session: string | undefined,
|
|
241
227
|
finished: string | undefined,
|
|
228
|
+
snapshot: WorkspaceSnapshot,
|
|
242
229
|
): ResultDetails {
|
|
243
230
|
const reply = payload.envelope;
|
|
244
231
|
const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
|
|
@@ -266,12 +253,22 @@ function resultDetails(
|
|
|
266
253
|
requestId: delegation.requestId,
|
|
267
254
|
envelopePath: payload.path,
|
|
268
255
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
256
|
+
...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
|
|
257
|
+
...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
|
|
258
|
+
...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
|
|
269
259
|
...(limit === undefined ? {} : { limit }),
|
|
270
260
|
task,
|
|
271
261
|
body,
|
|
262
|
+
workspaceSnapshot: snapshot,
|
|
272
263
|
};
|
|
273
264
|
}
|
|
274
265
|
|
|
266
|
+
/** The snapshot lines of the result text: a heading line, then its path lists indented. */
|
|
267
|
+
function checkoutText(snapshot: WorkspaceSnapshot): string[] {
|
|
268
|
+
const [first, ...rest] = snapshotLines(snapshot);
|
|
269
|
+
return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
|
|
270
|
+
}
|
|
271
|
+
|
|
275
272
|
/** The one message the parent model reads for a settled delegation. */
|
|
276
273
|
function resultText(details: ResultDetails): string {
|
|
277
274
|
const { task } = details;
|
|
@@ -287,6 +284,9 @@ function resultText(details: ResultDetails): string {
|
|
|
287
284
|
`Status: ${details.status}`,
|
|
288
285
|
...(details.limit === undefined ? [] : [details.limit]),
|
|
289
286
|
quote,
|
|
287
|
+
`Working directory: ${cwdLine(details.cwd)}`,
|
|
288
|
+
...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
|
|
289
|
+
...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
|
|
290
290
|
`Envelope: ${details.envelopePath}`,
|
|
291
291
|
`Delegate session: ${details.sessionPath ?? details.id}`,
|
|
292
292
|
"",
|
|
@@ -298,8 +298,8 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
298
298
|
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
299
299
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
300
300
|
const byId = new Map<string, Recorded>();
|
|
301
|
-
/**
|
|
302
|
-
const byRequest = new Map<string,
|
|
301
|
+
/** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
|
|
302
|
+
const byRequest = new Map<string, StartedRecorded>();
|
|
303
303
|
/** Reply envelope ids a result message has already been sent for. */
|
|
304
304
|
const shown = new Set<string>();
|
|
305
305
|
/** The latest session context, for the footer and the session directory. */
|
|
@@ -309,11 +309,18 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
309
309
|
const icon = Math.random() < 0.5 ? "👨" : "👩";
|
|
310
310
|
|
|
311
311
|
/** A delegation still going: no result and no recorded close. */
|
|
312
|
-
const running = ():
|
|
312
|
+
const running = (): StartedRecorded[] =>
|
|
313
313
|
[...byId.values()].filter(
|
|
314
|
-
(record)
|
|
314
|
+
(record): record is StartedRecorded =>
|
|
315
|
+
isStarted(record) && record.result === undefined && record.closed !== true && record.gone !== true,
|
|
315
316
|
);
|
|
316
317
|
|
|
318
|
+
/** The started record for `id`, if it is one. */
|
|
319
|
+
const startedById = (id: unknown): StartedRecorded | undefined => {
|
|
320
|
+
const record = typeof id === "string" ? byId.get(id) : undefined;
|
|
321
|
+
return record !== undefined && isStarted(record) ? record : undefined;
|
|
322
|
+
};
|
|
323
|
+
|
|
317
324
|
/** The footer counts the running delegations, and hides itself at zero. */
|
|
318
325
|
function updateFooter(): void {
|
|
319
326
|
if (ctx?.hasUI !== true) return;
|
|
@@ -332,22 +339,45 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
332
339
|
shown.clear();
|
|
333
340
|
for (const entry of context.sessionManager.getEntries()) {
|
|
334
341
|
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
|
|
335
|
-
const data = entry.data as
|
|
342
|
+
const data = entry.data as
|
|
343
|
+
| Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
|
|
344
|
+
| undefined;
|
|
336
345
|
if (data === undefined || typeof data.id !== "string") continue;
|
|
337
|
-
|
|
338
|
-
|
|
346
|
+
// An attempt may carry a real request id; it is checked before the legacy start rule below.
|
|
347
|
+
if (data.kind === "workspace-attempt") {
|
|
348
|
+
const existing = byId.get(data.id);
|
|
349
|
+
if (existing === undefined || !isStarted(existing)) {
|
|
350
|
+
byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
|
|
351
|
+
}
|
|
352
|
+
} else if (typeof data.requestId === "string") {
|
|
353
|
+
const record: StartedRecorded = { ...(data as Delegation) };
|
|
339
354
|
byId.set(record.id, record);
|
|
340
355
|
byRequest.set(record.requestId, record);
|
|
341
356
|
} else if (typeof data.result?.replyId === "string") {
|
|
342
357
|
shown.add(data.result.replyId);
|
|
343
|
-
const record =
|
|
358
|
+
const record = startedById(data.id);
|
|
344
359
|
if (record !== undefined) record.result = data.result;
|
|
345
360
|
} else if (data.closed === true) {
|
|
346
|
-
const record =
|
|
361
|
+
const record = startedById(data.id);
|
|
347
362
|
if (record !== undefined) record.closed = true;
|
|
348
363
|
} else if (data.gone === true) {
|
|
349
|
-
const record =
|
|
350
|
-
if (record
|
|
364
|
+
const record = startedById(data.id);
|
|
365
|
+
if (record === undefined) continue;
|
|
366
|
+
record.gone = true;
|
|
367
|
+
if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
|
|
371
|
+
for (const record of byId.values()) {
|
|
372
|
+
if (isStarted(record)) continue;
|
|
373
|
+
const { phase, resources } = record.attempt;
|
|
374
|
+
if (phase === "planned" || phase === "created") {
|
|
375
|
+
record.attempt = {
|
|
376
|
+
phase: "interrupted",
|
|
377
|
+
resources,
|
|
378
|
+
lastPhase: phase,
|
|
379
|
+
reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
|
|
380
|
+
};
|
|
351
381
|
}
|
|
352
382
|
}
|
|
353
383
|
updateFooter();
|
|
@@ -356,14 +386,20 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
356
386
|
function recordStart(delegation: Delegation, context: ExtensionContext): void {
|
|
357
387
|
ctx = context;
|
|
358
388
|
pi.appendEntry(CUSTOM_TYPE, delegation);
|
|
359
|
-
const record:
|
|
389
|
+
const record: StartedRecorded = { ...delegation };
|
|
360
390
|
byId.set(record.id, record);
|
|
361
391
|
byRequest.set(record.requestId, record);
|
|
362
392
|
updateFooter();
|
|
363
393
|
}
|
|
364
394
|
|
|
395
|
+
function recordAttempt(attempt: WorkspaceAttempt, context: ExtensionContext): void {
|
|
396
|
+
ctx = context;
|
|
397
|
+
pi.appendEntry(CUSTOM_TYPE, attempt);
|
|
398
|
+
byId.set(attempt.id, { ...attempt });
|
|
399
|
+
}
|
|
400
|
+
|
|
365
401
|
function recordClose(id: string): void {
|
|
366
|
-
const record =
|
|
402
|
+
const record = startedById(id);
|
|
367
403
|
if (record === undefined || record.closed === true) return;
|
|
368
404
|
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
369
405
|
record.closed = true;
|
|
@@ -371,12 +407,12 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
371
407
|
}
|
|
372
408
|
|
|
373
409
|
function recordGone(id: string): boolean {
|
|
374
|
-
const record =
|
|
410
|
+
const record = startedById(id);
|
|
375
411
|
if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
|
|
376
412
|
return false;
|
|
377
|
-
const session =
|
|
413
|
+
const session = locateSession(record, ctx?.sessionManager.getSessionDir());
|
|
378
414
|
const finished = finishedModel(session);
|
|
379
|
-
pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
|
|
415
|
+
pi.appendEntry(CUSTOM_TYPE, { id, gone: true, ...(session === undefined ? {} : { sessionPath: session }) });
|
|
380
416
|
record.gone = true;
|
|
381
417
|
const details: MessageDetails = {
|
|
382
418
|
kind: "gone",
|
|
@@ -387,6 +423,9 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
387
423
|
id: record.id,
|
|
388
424
|
requestId: record.requestId,
|
|
389
425
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
426
|
+
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
|
|
427
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
428
|
+
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
390
429
|
windowName: record.windowName,
|
|
391
430
|
};
|
|
392
431
|
pi.sendMessage(
|
|
@@ -395,6 +434,8 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
395
434
|
details,
|
|
396
435
|
content: [
|
|
397
436
|
`[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
|
|
437
|
+
`Working directory: ${cwdLine(record.cwd)}`,
|
|
438
|
+
...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
|
|
398
439
|
`Delegate session: ${session ?? record.id}`,
|
|
399
440
|
"A reply that arrives later is still delivered.",
|
|
400
441
|
].join("\n"),
|
|
@@ -415,11 +456,13 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
415
456
|
if (requestId === undefined) return;
|
|
416
457
|
payload.handled = true;
|
|
417
458
|
if (shown.has(reply.id)) return;
|
|
418
|
-
const delegation = byRequest.get(requestId) as
|
|
459
|
+
const delegation = byRequest.get(requestId) as StartedRecorded;
|
|
419
460
|
shown.add(reply.id);
|
|
420
|
-
const session =
|
|
461
|
+
const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
|
|
421
462
|
const finished = finishedModel(session);
|
|
422
|
-
|
|
463
|
+
// After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
|
|
464
|
+
const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
|
|
465
|
+
const details = resultDetails(delegation, payload, session, finished, snapshot);
|
|
423
466
|
// Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
|
|
424
467
|
pi.sendMessage(
|
|
425
468
|
{ customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
|
|
@@ -431,6 +474,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
431
474
|
envelopePath: payload.path,
|
|
432
475
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
433
476
|
...(finished === undefined ? {} : { finishedModel: finished }),
|
|
477
|
+
workspaceSnapshot: snapshot,
|
|
434
478
|
};
|
|
435
479
|
pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
|
|
436
480
|
delegation.result = result;
|
|
@@ -442,6 +486,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
442
486
|
return {
|
|
443
487
|
restore,
|
|
444
488
|
recordStart,
|
|
489
|
+
recordAttempt,
|
|
445
490
|
recordClose,
|
|
446
491
|
running,
|
|
447
492
|
recordGone,
|