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/snapshot.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Read-only observations of a delegation's checkout: on receipt of its result
|
|
2
|
+
// and afresh on each status call. A snapshot compares the checkout now with
|
|
3
|
+
// the launch commit recorded in its workspace, inspects only that recorded
|
|
4
|
+
// directory, stays within time and output limits, and reports unknown with a
|
|
5
|
+
// reason whenever something cannot be established. Nothing acts on it.
|
|
6
|
+
import { realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
askGit,
|
|
13
|
+
fields,
|
|
14
|
+
type GitAnswer,
|
|
15
|
+
type GitLaunch,
|
|
16
|
+
type GitPosition,
|
|
17
|
+
onePath,
|
|
18
|
+
type PathSummary,
|
|
19
|
+
PathSummarySchema,
|
|
20
|
+
pendingPaths,
|
|
21
|
+
summarize,
|
|
22
|
+
} from "./git.ts";
|
|
23
|
+
import { PositionSchema } from "./workspace.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A checkout as observed at one moment: its position and head against the
|
|
27
|
+
* launch baseline, committed changes since the baseline and pending work.
|
|
28
|
+
* An observation, not proof of who made the changes.
|
|
29
|
+
*/
|
|
30
|
+
export type WorkspaceSnapshot =
|
|
31
|
+
| { kind: "non-git" }
|
|
32
|
+
| { kind: "unknown"; reason: string }
|
|
33
|
+
| {
|
|
34
|
+
kind: "git";
|
|
35
|
+
position: GitPosition;
|
|
36
|
+
head: string;
|
|
37
|
+
baseline: string;
|
|
38
|
+
ahead: number;
|
|
39
|
+
workingState: "clean" | "dirty";
|
|
40
|
+
committed: PathSummary;
|
|
41
|
+
pending: { staged: PathSummary; unstaged: PathSummary; untracked: PathSummary; conflicted: PathSummary };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** `WorkspaceSnapshot` as a schema, for `delegation_status`. */
|
|
45
|
+
export const WorkspaceSnapshotSchema = Type.Union(
|
|
46
|
+
[
|
|
47
|
+
Type.Object({ kind: Type.Literal("non-git") }),
|
|
48
|
+
Type.Object({ kind: Type.Literal("unknown"), reason: Type.String() }),
|
|
49
|
+
Type.Object({
|
|
50
|
+
kind: Type.Literal("git"),
|
|
51
|
+
position: PositionSchema,
|
|
52
|
+
head: Type.String(),
|
|
53
|
+
baseline: Type.String({ description: "The launch commit the changes are counted from." }),
|
|
54
|
+
ahead: Type.Number({ description: "Commits reachable from head but not from the baseline." }),
|
|
55
|
+
workingState: Type.Union([Type.Literal("clean"), Type.Literal("dirty")]),
|
|
56
|
+
committed: PathSummarySchema,
|
|
57
|
+
pending: Type.Object({
|
|
58
|
+
staged: PathSummarySchema,
|
|
59
|
+
unstaged: PathSummarySchema,
|
|
60
|
+
untracked: PathSummarySchema,
|
|
61
|
+
conflicted: PathSummarySchema,
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
{
|
|
66
|
+
description:
|
|
67
|
+
"The checkout observed now: git (changes since the launch commit, committed and pending), non-git, or unknown with a reason.",
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/** Reporting limits for one snapshot; never evidence for deleting anything. */
|
|
72
|
+
const SNAPSHOT_COMMAND_MS = 2000;
|
|
73
|
+
const SNAPSHOT_TOTAL_MS = 5000;
|
|
74
|
+
const SNAPSHOT_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
75
|
+
/** Why a snapshot is unknown; thrown during an inspection and caught by `inspectWorkspace`. */
|
|
76
|
+
class Unknown extends Error {}
|
|
77
|
+
|
|
78
|
+
/** A Git runner bounded for one inspection: the per-command and total time limits and one capture budget. */
|
|
79
|
+
function budgetedGit(cwd: string): { run(args: string[]): GitAnswer; ask(args: string[]): string } {
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
let captured = 0;
|
|
82
|
+
const run = (args: string[]): GitAnswer => {
|
|
83
|
+
const left = SNAPSHOT_TOTAL_MS - (Date.now() - start);
|
|
84
|
+
if (left <= 0) throw new Unknown(`inspection passed its ${SNAPSHOT_TOTAL_MS / 1000} s total time limit`);
|
|
85
|
+
const answer = askGit(cwd, args, {
|
|
86
|
+
timeout: Math.min(SNAPSHOT_COMMAND_MS, left),
|
|
87
|
+
maxBuffer: SNAPSHOT_CAPTURE_BYTES - captured,
|
|
88
|
+
});
|
|
89
|
+
// Both streams, failed commands included, share one budget.
|
|
90
|
+
captured += answer.bytes;
|
|
91
|
+
if (captured > SNAPSHOT_CAPTURE_BYTES) {
|
|
92
|
+
throw new Unknown(`Git printed more than the ${SNAPSHOT_CAPTURE_BYTES} byte capture limit for one inspection`);
|
|
93
|
+
}
|
|
94
|
+
return answer;
|
|
95
|
+
};
|
|
96
|
+
const ask = (args: string[]): string => {
|
|
97
|
+
const answer = run(args);
|
|
98
|
+
if (!answer.ok) throw new Unknown(`git ${args[args[0] === "-c" ? 2 : 0]} failed: ${answer.message}`);
|
|
99
|
+
return answer.out;
|
|
100
|
+
};
|
|
101
|
+
return { run, ask };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Refuse a directory that is gone or whose checkout root or common directory
|
|
106
|
+
* is not at its recorded location. A repository replaced at the same
|
|
107
|
+
* locations is observed like any other: this checks location, not continuity.
|
|
108
|
+
*/
|
|
109
|
+
function requireLaunchCheckout(
|
|
110
|
+
cwd: string,
|
|
111
|
+
launch: Extract<GitLaunch, { kind: "git" }>,
|
|
112
|
+
ask: (args: string[]) => string,
|
|
113
|
+
): void {
|
|
114
|
+
if (statSync(cwd, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
115
|
+
throw new Unknown(`the checkout ${cwd} is gone or no longer a directory`);
|
|
116
|
+
}
|
|
117
|
+
let checkoutRoot: string;
|
|
118
|
+
let commonDir: string;
|
|
119
|
+
try {
|
|
120
|
+
checkoutRoot = realpathSync(onePath(ask(["rev-parse", "--show-toplevel"])));
|
|
121
|
+
commonDir = realpathSync(resolve(cwd, onePath(ask(["rev-parse", "--git-common-dir"]))));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof Unknown) throw err;
|
|
124
|
+
throw new Unknown(`could not resolve the repository paths: ${(err as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
if (checkoutRoot !== launch.identity.checkoutRoot || commonDir !== launch.identity.commonDir) {
|
|
127
|
+
throw new Unknown(
|
|
128
|
+
`${cwd} is now checkout ${checkoutRoot} of ${commonDir}, not the launch checkout ${launch.identity.checkoutRoot} of ${launch.identity.commonDir}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The checkout recorded at launch, observed now against its launch commit. */
|
|
134
|
+
function inspectCheckout(cwd: string, launch: Extract<GitLaunch, { kind: "git" }>): WorkspaceSnapshot {
|
|
135
|
+
const baseline = launch.baseline;
|
|
136
|
+
if (baseline === null) throw new Unknown("the checkout had no commit at launch, so there is no baseline to compare");
|
|
137
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseline))
|
|
138
|
+
throw new Unknown(`the recorded launch commit ${baseline} is not a commit id`);
|
|
139
|
+
const { run, ask } = budgetedGit(cwd);
|
|
140
|
+
requireLaunchCheckout(cwd, launch, ask);
|
|
141
|
+
const ref = run(["symbolic-ref", "-q", "HEAD"]);
|
|
142
|
+
if (!ref.ok && ref.status !== 1) throw new Unknown(`git symbolic-ref failed: ${ref.message}`);
|
|
143
|
+
const name = ref.ok ? onePath(ref.out) : "";
|
|
144
|
+
const position: GitPosition = ref.ok
|
|
145
|
+
? { branch: name.startsWith("refs/heads/") ? name.slice("refs/heads/".length) : name, detached: false }
|
|
146
|
+
: { branch: null, detached: true };
|
|
147
|
+
const head = onePath(ask(["rev-parse", "--verify", "HEAD^{commit}"]));
|
|
148
|
+
onePath(ask(["rev-parse", "--verify", `${baseline}^{commit}`]));
|
|
149
|
+
const ahead = Number.parseInt(ask(["rev-list", "--count", head, "--not", baseline]), 10);
|
|
150
|
+
if (!Number.isSafeInteger(ahead)) throw new Unknown("git rev-list printed no commit count");
|
|
151
|
+
// A configured diff.relative would drop paths outside a subdirectory cwd and reroot the rest; `-c` rather
|
|
152
|
+
// than `--no-relative`, which needs Git 2.28, while older Git ignores the unknown key.
|
|
153
|
+
const committed = fields(
|
|
154
|
+
ask([
|
|
155
|
+
"-c",
|
|
156
|
+
"diff.relative=false",
|
|
157
|
+
"diff",
|
|
158
|
+
"--name-only",
|
|
159
|
+
"-z",
|
|
160
|
+
"--no-renames",
|
|
161
|
+
"--no-ext-diff",
|
|
162
|
+
baseline,
|
|
163
|
+
head,
|
|
164
|
+
"--",
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
const pending = pendingPaths(ask(["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"]));
|
|
168
|
+
const dirty = Object.values(pending).some((paths) => paths.length > 0);
|
|
169
|
+
return {
|
|
170
|
+
kind: "git",
|
|
171
|
+
position,
|
|
172
|
+
head,
|
|
173
|
+
baseline,
|
|
174
|
+
ahead,
|
|
175
|
+
workingState: dirty ? "dirty" : "clean",
|
|
176
|
+
committed: summarize(committed),
|
|
177
|
+
pending: {
|
|
178
|
+
staged: summarize(pending.staged),
|
|
179
|
+
unstaged: summarize(pending.unstaged),
|
|
180
|
+
untracked: summarize(pending.untracked),
|
|
181
|
+
conflicted: summarize(pending.conflicted),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The recorded checkout as it stands now, read-only and bounded. Only `cwd`
|
|
188
|
+
* and its launch facts are inspected, never the parent's directory; anything
|
|
189
|
+
* that cannot be established is unknown with a reason. Never throws.
|
|
190
|
+
*/
|
|
191
|
+
export function inspectWorkspace(cwd: string | undefined, git: GitLaunch | undefined): WorkspaceSnapshot {
|
|
192
|
+
try {
|
|
193
|
+
if (git === undefined || cwd === undefined) {
|
|
194
|
+
return { kind: "unknown", reason: "this delegation was recorded before its launch checkout was kept" };
|
|
195
|
+
}
|
|
196
|
+
if (git.kind === "unknown")
|
|
197
|
+
return { kind: "unknown", reason: `its Git state at launch was unknown (${git.reason})` };
|
|
198
|
+
if (git.kind === "non-git") {
|
|
199
|
+
return statSync(cwd, { throwIfNoEntry: false })?.isDirectory() === true
|
|
200
|
+
? { kind: "non-git" }
|
|
201
|
+
: { kind: "unknown", reason: `the directory ${cwd} is gone` };
|
|
202
|
+
}
|
|
203
|
+
return inspectCheckout(cwd, git);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { kind: "unknown", reason: (err as Error).message };
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/tmux.ts
CHANGED
|
@@ -13,6 +13,20 @@ export type WindowSpec = {
|
|
|
13
13
|
/** One window as tmux lists it: its id, its name, and whether the program in its pane has exited. */
|
|
14
14
|
export type TmuxWindow = { id: string; name: string; exited: boolean };
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Why a window could not be opened, and whether one may exist anyway:
|
|
18
|
+
* `not-opened` only when nothing was dispatched to tmux that could create it.
|
|
19
|
+
* Anything else, a timeout or a malformed answer included, is `unknown`.
|
|
20
|
+
*/
|
|
21
|
+
export class WindowOpenError extends Error {
|
|
22
|
+
readonly certainty: "not-opened" | "unknown";
|
|
23
|
+
constructor(message: string, certainty: "not-opened" | "unknown") {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "WindowOpenError";
|
|
26
|
+
this.certainty = certainty;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export interface TmuxClient {
|
|
17
31
|
/** Whether this process runs in a tmux pane. */
|
|
18
32
|
insideTmux(): boolean;
|
|
@@ -65,11 +79,24 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
|
|
|
65
79
|
async openWindow(spec) {
|
|
66
80
|
const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd];
|
|
67
81
|
for (const [key, value] of Object.entries(spec.env)) args.push("-e", `${key}=${value}`);
|
|
68
|
-
|
|
82
|
+
let session: string | undefined;
|
|
83
|
+
try {
|
|
84
|
+
session = await targetSession();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// The lookup only reads; no window can exist yet.
|
|
87
|
+
throw new WindowOpenError((err as Error).message, "not-opened");
|
|
88
|
+
}
|
|
69
89
|
if (session !== undefined) args.push("-t", session);
|
|
70
90
|
args.push("--", ...spec.argv);
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
let out: string;
|
|
92
|
+
try {
|
|
93
|
+
out = await run(args);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// After dispatch even an error or timeout may have left a window.
|
|
96
|
+
throw new WindowOpenError((err as Error).message, "unknown");
|
|
97
|
+
}
|
|
98
|
+
const windowId = out.trim();
|
|
99
|
+
if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
|
|
73
100
|
return windowId;
|
|
74
101
|
},
|
|
75
102
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { WorktreePlanSchema } from "./workspace.ts";
|
|
3
|
+
|
|
4
|
+
const ATTEMPT_PHASES = ["planned", "created", "rolled-back", "retained", "interrupted"] as const;
|
|
5
|
+
|
|
6
|
+
const ResourceSchema = Type.Union(
|
|
7
|
+
[
|
|
8
|
+
Type.Object({ kind: Type.Literal("none") }),
|
|
9
|
+
Type.Object({ kind: Type.Literal("branch"), branch: Type.String(), head: Type.String() }),
|
|
10
|
+
Type.Object({ kind: Type.Literal("worktree"), cwd: Type.String(), branch: Type.String(), head: Type.String() }),
|
|
11
|
+
Type.Object({
|
|
12
|
+
kind: Type.Literal("unknown"),
|
|
13
|
+
reason: Type.String(),
|
|
14
|
+
observedPaths: Type.Array(Type.String()),
|
|
15
|
+
observedRefs: Type.Array(Type.String()),
|
|
16
|
+
}),
|
|
17
|
+
],
|
|
18
|
+
{ description: "What is known to exist for a launch that has no window, as facts." },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const AttemptStateSchema = Type.Union([
|
|
22
|
+
Type.Object({ phase: Type.Literal("planned"), resources: ResourceSchema }),
|
|
23
|
+
Type.Object({ phase: Type.Literal("created"), resources: ResourceSchema }),
|
|
24
|
+
Type.Object({
|
|
25
|
+
phase: Type.Literal("rolled-back"),
|
|
26
|
+
resources: Type.Object({ kind: Type.Literal("none") }),
|
|
27
|
+
cause: Type.String(),
|
|
28
|
+
}),
|
|
29
|
+
Type.Object({
|
|
30
|
+
phase: Type.Literal("retained"),
|
|
31
|
+
resources: ResourceSchema,
|
|
32
|
+
cause: Type.String(),
|
|
33
|
+
recovery: Type.Array(Type.String(), {
|
|
34
|
+
description: "Non-forced commands that remove exactly the resources known to remain.",
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
Type.Object({
|
|
38
|
+
phase: Type.Literal("interrupted"),
|
|
39
|
+
resources: ResourceSchema,
|
|
40
|
+
lastPhase: Type.Union([Type.Literal("planned"), Type.Literal("created")]),
|
|
41
|
+
reason: Type.String(),
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** A managed launch that has no window: never running, never closable, with no window id. */
|
|
46
|
+
export const AttemptSchema = Type.Object({
|
|
47
|
+
kind: Type.Literal("workspace-attempt"),
|
|
48
|
+
id: Type.String(),
|
|
49
|
+
parentId: Type.String(),
|
|
50
|
+
agent: Type.String(),
|
|
51
|
+
model: Type.String(),
|
|
52
|
+
thinking: Type.String(),
|
|
53
|
+
name: Type.Optional(Type.String()),
|
|
54
|
+
sessionDir: Type.String(),
|
|
55
|
+
plan: WorktreePlanSchema,
|
|
56
|
+
attempt: AttemptStateSchema,
|
|
57
|
+
requestId: Type.Optional(Type.String({ description: "The task's request id, only when mail really returned one." })),
|
|
58
|
+
state: Type.Union(
|
|
59
|
+
ATTEMPT_PHASES.map((phase) => Type.Literal(phase)),
|
|
60
|
+
{
|
|
61
|
+
description:
|
|
62
|
+
"planned, created: a launch in progress. rolled-back: failed before a window and everything was removed. retained: failed before a window and something remains (see attempt.recovery). interrupted: the parent ended mid-launch; what exists is unknown. No window was opened.",
|
|
63
|
+
},
|
|
64
|
+
),
|
|
65
|
+
});
|
package/src/tracking.ts
CHANGED
|
@@ -8,18 +8,24 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
8
8
|
import { Type } from "typebox";
|
|
9
9
|
|
|
10
10
|
import { type Mark, readMarks } from "./limits.ts";
|
|
11
|
-
import { modelLabel } from "./render.ts";
|
|
11
|
+
import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
|
|
12
12
|
import {
|
|
13
13
|
type DelegationState,
|
|
14
14
|
deriveState,
|
|
15
|
-
|
|
15
|
+
isStarted,
|
|
16
|
+
locateSession,
|
|
16
17
|
type Recorded,
|
|
17
18
|
type Results,
|
|
18
19
|
recordedName,
|
|
20
|
+
type StartedRecorded,
|
|
19
21
|
type WindowState,
|
|
22
|
+
type WorkspaceAttempt,
|
|
20
23
|
} from "./results.ts";
|
|
24
|
+
import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
|
|
21
25
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
26
|
+
import { AttemptSchema } from "./tracking-attempt.ts";
|
|
22
27
|
import type { ReadingError } from "./usage.ts";
|
|
28
|
+
import { WorkspaceSchema } from "./workspace.ts";
|
|
23
29
|
|
|
24
30
|
/** The hook a tick emits so the provider claims waiting replies before windows are checked. */
|
|
25
31
|
const SCAN = "message:scan";
|
|
@@ -49,10 +55,11 @@ export function toolResult(
|
|
|
49
55
|
/** Every tool here touches only local state: tmux, the session's entries and files under the agent directory. */
|
|
50
56
|
const CLOSED_WORLD = { openWorldHint: false } as const;
|
|
51
57
|
|
|
52
|
-
const
|
|
58
|
+
const STARTED_STATES = ["closed", "done", "gone", "running", "window-closed", "unknown"] as const;
|
|
53
59
|
|
|
54
|
-
/** One delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
|
|
55
|
-
const
|
|
60
|
+
/** One started delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
|
|
61
|
+
const StartedSchema = Type.Object({
|
|
62
|
+
kind: Type.Literal("started"),
|
|
56
63
|
id: Type.String(),
|
|
57
64
|
name: Type.String(),
|
|
58
65
|
agent: Type.String(),
|
|
@@ -64,8 +71,14 @@ const DelegationSchema = Type.Object({
|
|
|
64
71
|
autoExit: Type.Boolean(),
|
|
65
72
|
windowId: Type.String(),
|
|
66
73
|
windowName: Type.String(),
|
|
74
|
+
cwd: Type.Optional(
|
|
75
|
+
Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
|
|
76
|
+
),
|
|
77
|
+
sessionDir: Type.Optional(Type.String({ description: "The session-storage directory it was told to use." })),
|
|
78
|
+
workspace: Type.Optional(WorkspaceSchema),
|
|
79
|
+
workspaceSnapshot: WorkspaceSnapshotSchema,
|
|
67
80
|
state: Type.Union(
|
|
68
|
-
|
|
81
|
+
STARTED_STATES.map((state) => Type.Literal(state)),
|
|
69
82
|
{
|
|
70
83
|
description:
|
|
71
84
|
"running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final; gone and window-closed turn done if a late reply arrives.",
|
|
@@ -81,6 +94,8 @@ const DelegationSchema = Type.Object({
|
|
|
81
94
|
),
|
|
82
95
|
});
|
|
83
96
|
|
|
97
|
+
const DelegationSchema = Type.Union([StartedSchema, AttemptSchema]);
|
|
98
|
+
|
|
84
99
|
export const StatusSchema = Type.Object({
|
|
85
100
|
delegations: Type.Array(DelegationSchema),
|
|
86
101
|
marks: Type.Array(
|
|
@@ -124,26 +139,26 @@ async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined>
|
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
127
|
-
function ownWindow(windows: TmuxWindow[], record:
|
|
142
|
+
function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
128
143
|
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
132
|
-
function windowState(windows: TmuxWindow[] | undefined, record:
|
|
147
|
+
function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
133
148
|
if (windows === undefined) return "unknown";
|
|
134
149
|
const own = ownWindow(windows, record);
|
|
135
150
|
return own !== undefined && !own.exited ? "open" : "gone";
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
/** Records from before auto-exit existed kept their windows open, so they read off. */
|
|
139
|
-
function autoExitLine(record:
|
|
154
|
+
function autoExitLine(record: StartedRecorded): string {
|
|
140
155
|
return record.autoExit === true
|
|
141
156
|
? "on — the delegate closes its window after a normal completion unless the user took over there"
|
|
142
157
|
: "off — the window stays open after the result";
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
/** The state, worded for the `state:` line. */
|
|
146
|
-
function stateLine(state: DelegationState, record:
|
|
161
|
+
function stateLine(state: DelegationState, record: StartedRecorded): string {
|
|
147
162
|
switch (state) {
|
|
148
163
|
case "running":
|
|
149
164
|
return "running — the window is open and no result has arrived";
|
|
@@ -157,17 +172,25 @@ function stateLine(state: DelegationState, record: Recorded): string {
|
|
|
157
172
|
return "closed — the window is gone";
|
|
158
173
|
case "unknown":
|
|
159
174
|
return "unknown — tmux could not be asked whether the window is open";
|
|
175
|
+
case "planned":
|
|
176
|
+
case "created":
|
|
177
|
+
case "rolled-back":
|
|
178
|
+
case "retained":
|
|
179
|
+
case "interrupted":
|
|
180
|
+
return `${state} — no window id/start was recorded; a window may still exist`;
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
|
163
184
|
/** One delegation as `delegation_status` reports it; its text and details both read from this. */
|
|
164
185
|
type View = {
|
|
165
|
-
record:
|
|
186
|
+
record: StartedRecorded;
|
|
166
187
|
state: DelegationState;
|
|
167
188
|
name: string;
|
|
168
189
|
sessionPath: string | undefined;
|
|
169
190
|
/** When the session file was last written; set only for a running delegation with a file. */
|
|
170
191
|
lastActive: Date | undefined;
|
|
192
|
+
/** The checkout observed for this status call; the recorded result keeps its own. */
|
|
193
|
+
snapshot: WorkspaceSnapshot;
|
|
171
194
|
};
|
|
172
195
|
|
|
173
196
|
/** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
|
|
@@ -179,9 +202,9 @@ function ago(ms: number): string {
|
|
|
179
202
|
}
|
|
180
203
|
|
|
181
204
|
/** The view of a delegation: its state and the fields the text and details share. */
|
|
182
|
-
function view(record:
|
|
205
|
+
function view(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
|
|
183
206
|
const state = deriveState(record, windowState(windows, record));
|
|
184
|
-
const sessionPath = record
|
|
207
|
+
const sessionPath = locateSession(record, ctx.sessionManager.getSessionDir());
|
|
185
208
|
let lastActive: Date | undefined;
|
|
186
209
|
if (state === "running" && sessionPath !== undefined) {
|
|
187
210
|
try {
|
|
@@ -190,17 +213,23 @@ function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: Extensio
|
|
|
190
213
|
// a failed stat counts as no file
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
|
-
|
|
216
|
+
const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
|
|
217
|
+
return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot };
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
/** The text block for one delegation. */
|
|
197
|
-
function viewText({ record, state, name, sessionPath, lastActive }: View): string {
|
|
221
|
+
function viewText({ record, state, name, sessionPath, lastActive, snapshot }: View): string {
|
|
222
|
+
const [checkout, ...categories] = snapshotLines(snapshot);
|
|
198
223
|
const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
|
|
199
224
|
const lines = [
|
|
200
225
|
`${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
|
|
201
226
|
` name: ${name}`,
|
|
202
227
|
` state: ${stateLine(state, record)}${seen}`,
|
|
203
228
|
` window: ${record.windowName} (${record.windowId})`,
|
|
229
|
+
` cwd: ${cwdLine(record.cwd)}`,
|
|
230
|
+
...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
|
|
231
|
+
` checkout now: ${checkout}`,
|
|
232
|
+
...categories.map((line) => ` ${line}`),
|
|
204
233
|
` auto-exit: ${autoExitLine(record)}`,
|
|
205
234
|
` session: ${sessionPath ?? `${record.id} (no session file yet)`}`,
|
|
206
235
|
];
|
|
@@ -209,8 +238,9 @@ function viewText({ record, state, name, sessionPath, lastActive }: View): strin
|
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
/** The structured details for one delegation. */
|
|
212
|
-
function viewDetails({ record, state, name, sessionPath, lastActive }: View): Record<string, unknown> {
|
|
241
|
+
function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }: View): Record<string, unknown> {
|
|
213
242
|
return {
|
|
243
|
+
kind: "started",
|
|
214
244
|
id: record.id,
|
|
215
245
|
name,
|
|
216
246
|
agent: record.agent,
|
|
@@ -220,6 +250,10 @@ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Re
|
|
|
220
250
|
autoExit: record.autoExit === true,
|
|
221
251
|
windowId: record.windowId,
|
|
222
252
|
windowName: record.windowName,
|
|
253
|
+
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
|
|
254
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
255
|
+
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
256
|
+
workspaceSnapshot: snapshot,
|
|
223
257
|
state,
|
|
224
258
|
closed: record.closed === true,
|
|
225
259
|
closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
|
|
@@ -230,6 +264,16 @@ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Re
|
|
|
230
264
|
};
|
|
231
265
|
}
|
|
232
266
|
|
|
267
|
+
/** One unstarted attempt as the status text words it. */
|
|
268
|
+
function attemptText(record: WorkspaceAttempt): string {
|
|
269
|
+
return [
|
|
270
|
+
`${record.id} ${record.agent} (${modelLabel(record.model, undefined)}, thinking ${record.thinking})`,
|
|
271
|
+
` name: ${recordedName(record)}`,
|
|
272
|
+
` state: ${record.attempt.phase} — no window id/start was recorded; a window may still exist (close requires a verified window id)`,
|
|
273
|
+
...attemptLines(record).map((line) => ` ${line}`),
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
233
277
|
/** The usage-limit marks in force now, as `delegation_status` reports them. */
|
|
234
278
|
function activeMarks(): { scope: string; source: Mark["source"]; reason: string; clearsAt: string }[] {
|
|
235
279
|
const now = Date.now();
|
|
@@ -325,7 +369,7 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
325
369
|
name: "delegation_status",
|
|
326
370
|
label: "Delegation status",
|
|
327
371
|
description:
|
|
328
|
-
"The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
372
|
+
"The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, working directory, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
329
373
|
parameters: Type.Object({
|
|
330
374
|
id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
|
|
331
375
|
}),
|
|
@@ -340,10 +384,16 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
340
384
|
): Promise<ReturnType<typeof toolResult>> {
|
|
341
385
|
const records = selectRecords(results, params.id);
|
|
342
386
|
const windows = await listWindows(tmux);
|
|
343
|
-
const views = records.map((record) => view(record, windows, ctx));
|
|
387
|
+
const views = records.map((record) => (isStarted(record) ? view(record, windows, ctx) : record));
|
|
344
388
|
const marks = activeMarks();
|
|
345
389
|
const errors = readingErrors();
|
|
346
|
-
const details = {
|
|
390
|
+
const details = {
|
|
391
|
+
delegations: views.map((item) =>
|
|
392
|
+
"record" in item ? viewDetails(item) : { ...item, state: item.attempt.phase },
|
|
393
|
+
),
|
|
394
|
+
marks,
|
|
395
|
+
readingErrors: errors,
|
|
396
|
+
};
|
|
347
397
|
const block =
|
|
348
398
|
marks.length === 0 && errors.length === 0
|
|
349
399
|
? []
|
|
@@ -358,7 +408,10 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
358
408
|
const parts =
|
|
359
409
|
views.length === 0
|
|
360
410
|
? ["No delegations are recorded in this session.", ...block]
|
|
361
|
-
: [
|
|
411
|
+
: [
|
|
412
|
+
`${heading}\n\n${views.map((item) => ("record" in item ? viewText(item) : attemptText(item))).join("\n\n")}`,
|
|
413
|
+
...block,
|
|
414
|
+
];
|
|
362
415
|
return toolResult(parts.join("\n\n"), details);
|
|
363
416
|
},
|
|
364
417
|
};
|
|
@@ -385,6 +438,11 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
|
385
438
|
_ctx: ExtensionContext,
|
|
386
439
|
): Promise<ReturnType<typeof toolResult>> {
|
|
387
440
|
const [record] = selectRecords(results, params.id);
|
|
441
|
+
if (!isStarted(record)) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
388
446
|
const closed = (alreadyClosed: boolean, killed: boolean) => ({
|
|
389
447
|
id: record.id,
|
|
390
448
|
windowId: record.windowId,
|