pi-squire 0.9.4 → 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 +97 -1
- package/README.md +319 -27
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +415 -65
- package/src/render.ts +232 -123
- 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
|
@@ -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,
|