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.
@@ -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
- findSession,
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 DELEGATION_STATES: readonly DelegationState[] = ["closed", "done", "gone", "running", "window-closed", "unknown"];
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 DelegationSchema = Type.Object({
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
- DELEGATION_STATES.map((state) => Type.Literal(state)),
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: Recorded): TmuxWindow | undefined {
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: Recorded): WindowState {
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: Recorded): string {
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: Recorded): string {
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: Recorded;
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: Recorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
205
+ function view(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
183
206
  const state = deriveState(record, windowState(windows, record));
184
- const sessionPath = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
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
- return { record, state, name: recordedName(record), sessionPath, lastActive };
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 = { delegations: views.map(viewDetails), marks, readingErrors: errors };
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
- : [`${heading}\n\n${views.map(viewText).join("\n\n")}`, ...block];
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,