pi-squire 0.9.5 → 0.11.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/src/results.ts CHANGED
@@ -4,22 +4,30 @@
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
- import { readMarks } from "./limits.ts";
13
- import { modelLabel, renderMessage } from "./render.ts";
10
+ import { cwdLine, modelLabel, renderMessage, workspaceLine } from "./render.ts";
11
+ import { resultDetails, resultText, resumeDetails } from "./result-message.ts";
12
+ import { finishedModel, locateSession, sessionIdOf } 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, sessionHeaderCwd, sessionIdOf } 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";
17
29
  /** The footer entry that counts delegations whose result has not arrived. */
18
30
  export const STATUS_KEY = "delegate";
19
- /** A task quoted into the result header is cut at this many UTF-8 bytes. */
20
- const TASK_CAP = 2 * 1024;
21
- /** A result body is cut at this many UTF-8 bytes. */
22
- const BODY_CAP = 32 * 1024;
23
31
  /** The `pi.events` channel a provider emits claimed envelopes on. */
24
32
  const INBOUND = "message:inbound";
25
33
 
@@ -36,6 +44,18 @@ export type Delegation = {
36
44
  requestId: string;
37
45
  /** Whether the delegate closes its own window after a normal completion; absent in records from before auto-exit. */
38
46
  autoExit?: boolean;
47
+ /** The directory its window opened in; absent in records from before it was recorded, where it is unknown. */
48
+ cwd?: string;
49
+ /** The session-storage directory passed as `--session-dir`; absent in older records. */
50
+ sessionDir?: string;
51
+ /** The delegate's session file once found; never set at launch, when the file may not exist yet. */
52
+ sessionPath?: string;
53
+ /** How its directory was chosen and its Git checkout at launch; absent in older records. */
54
+ workspace?: Workspace;
55
+ /** The delegation this one resumed, when it reopened that delegation's session. */
56
+ resumedFrom?: string;
57
+ /** The Pi session id; absent means `id`. A resumed delegation holds the session id of the one it resumed. */
58
+ sessionId?: string;
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. */
@@ -77,8 +99,15 @@ type CommonDetails = {
77
99
  model: string;
78
100
  finishedModel?: string;
79
101
  id: string;
102
+ /** The delegation this one resumed. */
103
+ resumedFrom?: string;
104
+ /** The Pi session id when it is not `id`: a resumed delegation's. */
105
+ sessionId?: string;
80
106
  requestId: string;
81
107
  sessionPath?: string;
108
+ cwd?: string;
109
+ sessionDir?: string;
110
+ workspace?: Workspace;
82
111
  };
83
112
 
84
113
  /** The task as the result quotes it: its text and copy path, or why there is none. */
@@ -95,21 +124,11 @@ export type MessageDetails =
95
124
  task: TaskQuote;
96
125
  /** The reply body, capped. */
97
126
  body: string;
127
+ /** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
128
+ workspaceSnapshot?: WorkspaceSnapshot;
98
129
  })
99
130
  | (CommonDetails & { kind: "gone"; windowName: string });
100
131
 
101
- type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
102
-
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
132
  /** The provider's payload on `message:inbound`. */
114
133
  export type Inbound = {
115
134
  envelope: Envelope;
@@ -121,12 +140,14 @@ export type Inbound = {
121
140
  export type Results = {
122
141
  /** Rebuild the records from the session's custom entries, as on a resume. */
123
142
  restore(ctx: ExtensionContext): void;
124
- /** Record a delegation and show it as running. */
143
+ /** Record a delegation and show it as running; it supersedes an attempt with the same id. */
125
144
  recordStart(delegation: Delegation, ctx: ExtensionContext): void;
145
+ /** Record a workspace attempt, or its next state; never counted as running. */
146
+ recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
126
147
  /** Record a close; does nothing when there is no such delegation or it is already closed. */
127
148
  recordClose(id: string): void;
128
149
  /** Delegations with no result, no recorded close and no gone-window record. */
129
- running(): Recorded[];
150
+ running(): StartedRecorded[];
130
151
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
131
152
  recordGone(id: string): boolean;
132
153
  /** Every delegation, in the order it was recorded. */
@@ -143,7 +164,14 @@ export type WindowState = "open" | "gone" | "unknown";
143
164
  * closed without a result (the poll found the window missing); `window-closed`
144
165
  * is a window tmux no longer lists, seen when asked directly.
145
166
  */
146
- export type DelegationState = "closed" | "done" | "gone" | "running" | "window-closed" | "unknown";
167
+ export type DelegationState =
168
+ | "closed"
169
+ | "done"
170
+ | "gone"
171
+ | "running"
172
+ | "window-closed"
173
+ | "unknown"
174
+ | AttemptState["phase"];
147
175
 
148
176
  /** How much of a delegation id a delegate's session name carries. */
149
177
  const SHORT_ID = 8;
@@ -163,6 +191,7 @@ export function recordedName(record: Recorded): string {
163
191
 
164
192
  /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
165
193
  export function deriveState(record: Recorded, window: WindowState): DelegationState {
194
+ if (!isStarted(record)) return record.attempt.phase;
166
195
  if (record.closed === true) return "closed";
167
196
  if (record.result !== undefined) return "done";
168
197
  if (record.gone === true) return "gone";
@@ -171,135 +200,12 @@ export function deriveState(record: Recorded, window: WindowState): DelegationSt
171
200
  return "unknown";
172
201
  }
173
202
 
174
- /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
175
- function cap(text: string, max: number): string | undefined {
176
- const bytes = Buffer.from(text, "utf8");
177
- if (bytes.length <= max) return undefined;
178
- let end = max;
179
- while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
180
- return bytes.subarray(0, end).toString("utf8");
181
- }
182
-
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
- /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
225
- function limitLine(id: string, status: string): string | undefined {
226
- const now = Date.now();
227
- const mark = readMarks(now).find((item) => item.delegations.includes(id));
228
- if (mark === undefined) return undefined;
229
- const scope = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
230
- const until = new Date(mark.clearsAt).toISOString();
231
- return `Usage limit: the delegate hit a usage limit on ${scope}; ${
232
- mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
233
- }${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
234
- }
235
-
236
- /** The details of a settled delegation's result message. */
237
- function resultDetails(
238
- delegation: Delegation,
239
- payload: Inbound,
240
- session: string | undefined,
241
- finished: string | undefined,
242
- ): ResultDetails {
243
- const reply = payload.envelope;
244
- const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
245
- let task: TaskQuote;
246
- if (copy === undefined) {
247
- task = { note: `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.` };
248
- } else {
249
- const cut = cap(copy.envelope.body, TASK_CAP);
250
- const text =
251
- cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
252
- task = { text, path: copy.path };
253
- }
254
- const cut = cap(reply.body, BODY_CAP);
255
- const body =
256
- cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
257
- const limit = reply.status === "stopped" ? undefined : limitLine(delegation.id, reply.status);
258
- return {
259
- kind: "result",
260
- status: reply.status,
261
- name: recordedName(delegation),
262
- agent: delegation.agent,
263
- model: delegation.model,
264
- ...(finished === undefined ? {} : { finishedModel: finished }),
265
- id: delegation.id,
266
- requestId: delegation.requestId,
267
- envelopePath: payload.path,
268
- ...(session === undefined ? {} : { sessionPath: session }),
269
- ...(limit === undefined ? {} : { limit }),
270
- task,
271
- body,
272
- };
273
- }
274
-
275
- /** The one message the parent model reads for a settled delegation. */
276
- function resultText(details: ResultDetails): string {
277
- const { task } = details;
278
- const quote =
279
- "note" in task
280
- ? task.note
281
- : `Task, quoted from ${task.path}:\n${task.text
282
- .split("\n")
283
- .map((line) => `> ${line}`)
284
- .join("\n")}`;
285
- return [
286
- `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}, request ${details.requestId}.`,
287
- `Status: ${details.status}`,
288
- ...(details.limit === undefined ? [] : [details.limit]),
289
- quote,
290
- `Envelope: ${details.envelopePath}`,
291
- `Delegate session: ${details.sessionPath ?? details.id}`,
292
- "",
293
- details.body,
294
- ].join("\n");
295
- }
296
-
297
203
  export function createResults(pi: ExtensionAPI): Results {
298
204
  pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
299
205
  /** Every recorded delegation by its id, in the order recorded. */
300
206
  const byId = new Map<string, Recorded>();
301
- /** The same records by their request's envelope id, for matching replies. */
302
- const byRequest = new Map<string, Recorded>();
207
+ /** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
208
+ const byRequest = new Map<string, StartedRecorded>();
303
209
  /** Reply envelope ids a result message has already been sent for. */
304
210
  const shown = new Set<string>();
305
211
  /** The latest session context, for the footer and the session directory. */
@@ -308,11 +214,29 @@ export function createResults(pi: ExtensionAPI): Results {
308
214
  let footerShown = false;
309
215
  const icon = Math.random() < 0.5 ? "👨" : "👩";
310
216
 
311
- /** A delegation still going: no result and no recorded close. */
312
- const running = (): Recorded[] =>
313
- [...byId.values()].filter(
314
- (record) => record.result === undefined && record.closed !== true && record.gone !== true,
217
+ /**
218
+ * A delegation still going: no result, no recorded close, and not resumed.
219
+ * A resumed one's process is gone (resuming proved it), so the poll must not
220
+ * report it closed without a result; a late reply to it is still delivered.
221
+ */
222
+ const running = (): StartedRecorded[] => {
223
+ const records = [...byId.values()];
224
+ const resumed = new Set(records.flatMap((record) => (isStarted(record) ? (record.resumedFrom ?? []) : [])));
225
+ return records.filter(
226
+ (record): record is StartedRecorded =>
227
+ isStarted(record) &&
228
+ record.result === undefined &&
229
+ record.closed !== true &&
230
+ record.gone !== true &&
231
+ !resumed.has(record.id),
315
232
  );
233
+ };
234
+
235
+ /** The started record for `id`, if it is one. */
236
+ const startedById = (id: unknown): StartedRecorded | undefined => {
237
+ const record = typeof id === "string" ? byId.get(id) : undefined;
238
+ return record !== undefined && isStarted(record) ? record : undefined;
239
+ };
316
240
 
317
241
  /** The footer counts the running delegations, and hides itself at zero. */
318
242
  function updateFooter(): void {
@@ -332,22 +256,45 @@ export function createResults(pi: ExtensionAPI): Results {
332
256
  shown.clear();
333
257
  for (const entry of context.sessionManager.getEntries()) {
334
258
  if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
335
- const data = entry.data as Partial<Delegation & { result: Result; closed: boolean; gone: boolean }> | undefined;
259
+ const data = entry.data as
260
+ | Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
261
+ | undefined;
336
262
  if (data === undefined || typeof data.id !== "string") continue;
337
- if (typeof data.requestId === "string") {
338
- const record: Recorded = { ...(data as Delegation) };
263
+ // An attempt may carry a real request id; it is checked before the legacy start rule below.
264
+ if (data.kind === "workspace-attempt") {
265
+ const existing = byId.get(data.id);
266
+ if (existing === undefined || !isStarted(existing)) {
267
+ byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
268
+ }
269
+ } else if (typeof data.requestId === "string") {
270
+ const record: StartedRecorded = { ...(data as Delegation) };
339
271
  byId.set(record.id, record);
340
272
  byRequest.set(record.requestId, record);
341
273
  } else if (typeof data.result?.replyId === "string") {
342
274
  shown.add(data.result.replyId);
343
- const record = byId.get(data.id);
275
+ const record = startedById(data.id);
344
276
  if (record !== undefined) record.result = data.result;
345
277
  } else if (data.closed === true) {
346
- const record = byId.get(data.id);
278
+ const record = startedById(data.id);
347
279
  if (record !== undefined) record.closed = true;
348
280
  } else if (data.gone === true) {
349
- const record = byId.get(data.id);
350
- if (record !== undefined) record.gone = true;
281
+ const record = startedById(data.id);
282
+ if (record === undefined) continue;
283
+ record.gone = true;
284
+ if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
285
+ }
286
+ }
287
+ // A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
288
+ for (const record of byId.values()) {
289
+ if (isStarted(record)) continue;
290
+ const { phase, resources } = record.attempt;
291
+ if (phase === "planned" || phase === "created") {
292
+ record.attempt = {
293
+ phase: "interrupted",
294
+ resources,
295
+ lastPhase: phase,
296
+ reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
297
+ };
351
298
  }
352
299
  }
353
300
  updateFooter();
@@ -356,14 +303,20 @@ export function createResults(pi: ExtensionAPI): Results {
356
303
  function recordStart(delegation: Delegation, context: ExtensionContext): void {
357
304
  ctx = context;
358
305
  pi.appendEntry(CUSTOM_TYPE, delegation);
359
- const record: Recorded = { ...delegation };
306
+ const record: StartedRecorded = { ...delegation };
360
307
  byId.set(record.id, record);
361
308
  byRequest.set(record.requestId, record);
362
309
  updateFooter();
363
310
  }
364
311
 
312
+ function recordAttempt(attempt: WorkspaceAttempt, context: ExtensionContext): void {
313
+ ctx = context;
314
+ pi.appendEntry(CUSTOM_TYPE, attempt);
315
+ byId.set(attempt.id, { ...attempt });
316
+ }
317
+
365
318
  function recordClose(id: string): void {
366
- const record = byId.get(id);
319
+ const record = startedById(id);
367
320
  if (record === undefined || record.closed === true) return;
368
321
  pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
369
322
  record.closed = true;
@@ -371,12 +324,12 @@ export function createResults(pi: ExtensionAPI): Results {
371
324
  }
372
325
 
373
326
  function recordGone(id: string): boolean {
374
- const record = byId.get(id);
327
+ const record = startedById(id);
375
328
  if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
376
329
  return false;
377
- const session = findSession(ctx?.sessionManager.getSessionDir(), record.id);
330
+ const session = locateSession(record, ctx?.sessionManager.getSessionDir());
378
331
  const finished = finishedModel(session);
379
- pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
332
+ pi.appendEntry(CUSTOM_TYPE, { id, gone: true, ...(session === undefined ? {} : { sessionPath: session }) });
380
333
  record.gone = true;
381
334
  const details: MessageDetails = {
382
335
  kind: "gone",
@@ -385,8 +338,12 @@ export function createResults(pi: ExtensionAPI): Results {
385
338
  model: record.model,
386
339
  ...(finished === undefined ? {} : { finishedModel: finished }),
387
340
  id: record.id,
341
+ ...resumeDetails(record),
388
342
  requestId: record.requestId,
389
343
  ...(session === undefined ? {} : { sessionPath: session }),
344
+ ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
345
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
346
+ ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
390
347
  windowName: record.windowName,
391
348
  };
392
349
  pi.sendMessage(
@@ -394,9 +351,11 @@ export function createResults(pi: ExtensionAPI): Results {
394
351
  customType: CUSTOM_TYPE,
395
352
  details,
396
353
  content: [
397
- `[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.`,
398
- `Delegate session: ${session ?? record.id}`,
399
- "A reply that arrives later is still delivered.",
354
+ `[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
355
+ `Working directory: ${cwdLine(record.cwd)}`,
356
+ ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
357
+ `Delegate session: ${session ?? sessionIdOf(record)}`,
358
+ "A reply that arrives later is still delivered. To continue the delegate's session in a new window, call delegation_resume with this delegation's id.",
400
359
  ].join("\n"),
401
360
  display: true,
402
361
  },
@@ -410,16 +369,21 @@ export function createResults(pi: ExtensionAPI): Results {
410
369
  if (payload?.handled === true) return;
411
370
  const reply = payload?.envelope;
412
371
  if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
413
- // Only the delegate's own reply counts: `from` must be the delegation's id.
414
- const requestId = reply.in_reply_to.find((id) => byRequest.get(id)?.id === reply.from);
372
+ // Only the delegate's own reply counts: `from` must be the delegation's session id.
373
+ const requestId = reply.in_reply_to.find((id) => {
374
+ const record = byRequest.get(id);
375
+ return record !== undefined && sessionIdOf(record) === reply.from;
376
+ });
415
377
  if (requestId === undefined) return;
416
378
  payload.handled = true;
417
379
  if (shown.has(reply.id)) return;
418
- const delegation = byRequest.get(requestId) as Recorded;
380
+ const delegation = byRequest.get(requestId) as StartedRecorded;
419
381
  shown.add(reply.id);
420
- const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
382
+ const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
421
383
  const finished = finishedModel(session);
422
- const details = resultDetails(delegation, payload, session, finished);
384
+ // After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
385
+ const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
386
+ const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
423
387
  // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
424
388
  pi.sendMessage(
425
389
  { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
@@ -431,6 +395,7 @@ export function createResults(pi: ExtensionAPI): Results {
431
395
  envelopePath: payload.path,
432
396
  ...(session === undefined ? {} : { sessionPath: session }),
433
397
  ...(finished === undefined ? {} : { finishedModel: finished }),
398
+ workspaceSnapshot: snapshot,
434
399
  };
435
400
  pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
436
401
  delegation.result = result;
@@ -442,6 +407,7 @@ export function createResults(pi: ExtensionAPI): Results {
442
407
  return {
443
408
  restore,
444
409
  recordStart,
410
+ recordAttempt,
445
411
  recordClose,
446
412
  running,
447
413
  recordGone,