pi-squire 0.10.0 → 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.
@@ -0,0 +1,120 @@
1
+ // The text and details of the one message the parent receives for a settled delegation.
2
+ import { readMarks } from "./limits.ts";
3
+ import { cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
4
+ import { sessionIdOf } from "./result-sessions.ts";
5
+ import type { StartedRecorded } from "./result-types.ts";
6
+ import type { Inbound, MessageDetails, TaskQuote } from "./results.ts";
7
+ import type { WorkspaceSnapshot } from "./snapshot.ts";
8
+
9
+ /** A task quoted into the result header is cut at this many UTF-8 bytes. */
10
+ const TASK_CAP = 2 * 1024;
11
+ /** A result body is cut at this many UTF-8 bytes. */
12
+ const BODY_CAP = 32 * 1024;
13
+
14
+ type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
15
+
16
+ /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
17
+ function cap(text: string, max: number): string | undefined {
18
+ const bytes = Buffer.from(text, "utf8");
19
+ if (bytes.length <= max) return undefined;
20
+ let end = max;
21
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
22
+ return bytes.subarray(0, end).toString("utf8");
23
+ }
24
+
25
+ /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
26
+ function limitLine(sessionId: string, status: string): string | undefined {
27
+ const now = Date.now();
28
+ const mark = readMarks(now).find((item) => item.delegations.includes(sessionId));
29
+ if (mark === undefined) return undefined;
30
+ const scope = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
31
+ const until = new Date(mark.clearsAt).toISOString();
32
+ return `Usage limit: the delegate hit a usage limit on ${scope}; ${
33
+ mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
34
+ }${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
35
+ }
36
+
37
+ /** The details that say a delegation reopened an earlier one's session. */
38
+ export function resumeDetails(record: StartedRecorded): { resumedFrom?: string; sessionId?: string } {
39
+ return {
40
+ ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
41
+ ...(record.sessionId === undefined ? {} : { sessionId: record.sessionId }),
42
+ };
43
+ }
44
+
45
+ /** The details of a settled delegation's result message. */
46
+ export function resultDetails(
47
+ delegation: StartedRecorded,
48
+ name: string,
49
+ payload: Inbound,
50
+ session: string | undefined,
51
+ finished: string | undefined,
52
+ snapshot: WorkspaceSnapshot,
53
+ ): ResultDetails {
54
+ const reply = payload.envelope;
55
+ const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
56
+ let task: TaskQuote;
57
+ if (copy === undefined) {
58
+ task = { note: `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.` };
59
+ } else {
60
+ const cut = cap(copy.envelope.body, TASK_CAP);
61
+ const text =
62
+ cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
63
+ task = { text, path: copy.path };
64
+ }
65
+ const cut = cap(reply.body, BODY_CAP);
66
+ const body =
67
+ cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
68
+ const limit = reply.status === "stopped" ? undefined : limitLine(sessionIdOf(delegation), reply.status);
69
+ return {
70
+ kind: "result",
71
+ status: reply.status,
72
+ name,
73
+ agent: delegation.agent,
74
+ model: delegation.model,
75
+ ...(finished === undefined ? {} : { finishedModel: finished }),
76
+ id: delegation.id,
77
+ ...resumeDetails(delegation),
78
+ requestId: delegation.requestId,
79
+ envelopePath: payload.path,
80
+ ...(session === undefined ? {} : { sessionPath: session }),
81
+ ...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
82
+ ...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
83
+ ...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
84
+ ...(limit === undefined ? {} : { limit }),
85
+ task,
86
+ body,
87
+ workspaceSnapshot: snapshot,
88
+ };
89
+ }
90
+
91
+ /** The snapshot lines of the result text: a heading line, then its path lists indented. */
92
+ function checkoutText(snapshot: WorkspaceSnapshot): string[] {
93
+ const [first, ...rest] = snapshotLines(snapshot);
94
+ return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
95
+ }
96
+
97
+ /** The one message the parent model reads for a settled delegation. */
98
+ export function resultText(details: ResultDetails): string {
99
+ const { task } = details;
100
+ const quote =
101
+ "note" in task
102
+ ? task.note
103
+ : `Task, quoted from ${task.path}:\n${task.text
104
+ .split("\n")
105
+ .map((line) => `> ${line}`)
106
+ .join("\n")}`;
107
+ return [
108
+ `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}${details.resumedFrom === undefined ? "" : ` (resumed from ${details.resumedFrom})`}, request ${details.requestId}.`,
109
+ `Status: ${details.status}`,
110
+ ...(details.limit === undefined ? [] : [details.limit]),
111
+ quote,
112
+ `Working directory: ${cwdLine(details.cwd)}`,
113
+ ...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
114
+ ...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
115
+ `Envelope: ${details.envelopePath}`,
116
+ `Delegate session: ${details.sessionPath ?? details.sessionId ?? details.id}`,
117
+ "",
118
+ details.body,
119
+ ].join("\n");
120
+ }
@@ -1,8 +1,17 @@
1
- import { readdirSync, readFileSync } from "node:fs";
1
+ import { closeSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import type { StartedRecorded } from "./result-types.ts";
4
4
 
5
- /** The delegate session file in a directory, found by its `_<id>.jsonl` suffix. */
5
+ /**
6
+ * The Pi session id a delegation's mail, replies, usage-limit marks and file
7
+ * name use. A resumed delegation has its own id but reopens the first one's
8
+ * session; records from before resuming have no `sessionId`, and it is their `id`.
9
+ */
10
+ export function sessionIdOf(record: { id: string; sessionId?: string }): string {
11
+ return record.sessionId ?? record.id;
12
+ }
13
+
14
+ /** The delegate session file in a directory, found by its `_<id>.jsonl` suffix, `id` being a session id. */
6
15
  export function findSession(dir: string | undefined, id: string): string | undefined {
7
16
  if (dir === undefined) return undefined;
8
17
  let names: string[];
@@ -18,10 +27,33 @@ export function findSession(dir: string | undefined, id: string): string | undef
18
27
  export function locateSession(record: StartedRecorded, fallbackDir: string | undefined): string | undefined {
19
28
  const known = record.sessionPath ?? record.result?.sessionPath;
20
29
  if (known !== undefined) return known;
21
- const found = findSession(record.sessionDir ?? fallbackDir, record.id);
30
+ const found = findSession(record.sessionDir ?? fallbackDir, sessionIdOf(record));
22
31
  if (found !== undefined && isAbsolute(found)) record.sessionPath = found;
23
32
  return found;
24
33
  }
34
+ /** How much of a session file's start is read to find its header line. */
35
+ const HEADER_BYTES = 64 * 1024;
36
+
37
+ /** The working directory a session file's header stores, which Pi runs the session in; undefined when the file or the field is missing. */
38
+ export function sessionHeaderCwd(sessionPath: string): string | undefined {
39
+ let fd: number;
40
+ try {
41
+ fd = openSync(sessionPath, "r");
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ try {
46
+ const buffer = Buffer.alloc(HEADER_BYTES);
47
+ const text = buffer.subarray(0, readSync(fd, buffer, 0, HEADER_BYTES, 0)).toString("utf8");
48
+ const header = JSON.parse(text.split("\n", 1)[0]) as { type?: unknown; cwd?: unknown };
49
+ return header.type === "session" && typeof header.cwd === "string" && header.cwd !== "" ? header.cwd : undefined;
50
+ } catch {
51
+ return undefined;
52
+ } finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+
25
57
  /** The provider/model of the last assistant message in the delegate session, if available. */
26
58
  export function finishedModel(sessionPath: string | undefined): string | undefined {
27
59
  if (sessionPath === undefined) return undefined;
package/src/results.ts CHANGED
@@ -7,9 +7,9 @@
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
10
- import { readMarks } from "./limits.ts";
11
- import { cwdLine, modelLabel, renderMessage, snapshotLines, workspaceLine } from "./render.ts";
12
- import { finishedModel, locateSession } from "./result-sessions.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
13
  import {
14
14
  type AttemptState,
15
15
  isStarted,
@@ -20,7 +20,7 @@ import {
20
20
  import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
21
21
  import type { Workspace } from "./workspace.ts";
22
22
 
23
- export { findSession, finishedModel } from "./result-sessions.ts";
23
+ export { findSession, finishedModel, sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
24
24
  export type { AttemptState, Recorded, ResourceFacts, StartedRecorded, WorkspaceAttempt } from "./result-types.ts";
25
25
  export { isStarted, locateSession };
26
26
 
@@ -28,10 +28,6 @@ export { isStarted, locateSession };
28
28
  export const CUSTOM_TYPE = "delegate";
29
29
  /** The footer entry that counts delegations whose result has not arrived. */
30
30
  export const STATUS_KEY = "delegate";
31
- /** A task quoted into the result header is cut at this many UTF-8 bytes. */
32
- const TASK_CAP = 2 * 1024;
33
- /** A result body is cut at this many UTF-8 bytes. */
34
- const BODY_CAP = 32 * 1024;
35
31
  /** The `pi.events` channel a provider emits claimed envelopes on. */
36
32
  const INBOUND = "message:inbound";
37
33
 
@@ -56,6 +52,10 @@ export type Delegation = {
56
52
  sessionPath?: string;
57
53
  /** How its directory was chosen and its Git checkout at launch; absent in older records. */
58
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;
59
59
  };
60
60
 
61
61
  /**
@@ -99,6 +99,10 @@ type CommonDetails = {
99
99
  model: string;
100
100
  finishedModel?: string;
101
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;
102
106
  requestId: string;
103
107
  sessionPath?: string;
104
108
  cwd?: string;
@@ -125,8 +129,6 @@ export type MessageDetails =
125
129
  })
126
130
  | (CommonDetails & { kind: "gone"; windowName: string });
127
131
 
128
- type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
129
-
130
132
  /** The provider's payload on `message:inbound`. */
131
133
  export type Inbound = {
132
134
  envelope: Envelope;
@@ -198,102 +200,6 @@ export function deriveState(record: Recorded, window: WindowState): DelegationSt
198
200
  return "unknown";
199
201
  }
200
202
 
201
- /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
202
- function cap(text: string, max: number): string | undefined {
203
- const bytes = Buffer.from(text, "utf8");
204
- if (bytes.length <= max) return undefined;
205
- let end = max;
206
- while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
207
- return bytes.subarray(0, end).toString("utf8");
208
- }
209
-
210
- /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
211
- function limitLine(id: string, status: string): string | undefined {
212
- const now = Date.now();
213
- const mark = readMarks(now).find((item) => item.delegations.includes(id));
214
- if (mark === undefined) return undefined;
215
- const scope = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
216
- const until = new Date(mark.clearsAt).toISOString();
217
- return `Usage limit: the delegate hit a usage limit on ${scope}; ${
218
- mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
219
- }${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
220
- }
221
-
222
- /** The details of a settled delegation's result message. */
223
- function resultDetails(
224
- delegation: StartedRecorded,
225
- payload: Inbound,
226
- session: string | undefined,
227
- finished: string | undefined,
228
- snapshot: WorkspaceSnapshot,
229
- ): ResultDetails {
230
- const reply = payload.envelope;
231
- const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
232
- let task: TaskQuote;
233
- if (copy === undefined) {
234
- task = { note: `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.` };
235
- } else {
236
- const cut = cap(copy.envelope.body, TASK_CAP);
237
- const text =
238
- cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
239
- task = { text, path: copy.path };
240
- }
241
- const cut = cap(reply.body, BODY_CAP);
242
- const body =
243
- cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
244
- const limit = reply.status === "stopped" ? undefined : limitLine(delegation.id, reply.status);
245
- return {
246
- kind: "result",
247
- status: reply.status,
248
- name: recordedName(delegation),
249
- agent: delegation.agent,
250
- model: delegation.model,
251
- ...(finished === undefined ? {} : { finishedModel: finished }),
252
- id: delegation.id,
253
- requestId: delegation.requestId,
254
- envelopePath: payload.path,
255
- ...(session === undefined ? {} : { sessionPath: session }),
256
- ...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
257
- ...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
258
- ...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
259
- ...(limit === undefined ? {} : { limit }),
260
- task,
261
- body,
262
- workspaceSnapshot: snapshot,
263
- };
264
- }
265
-
266
- /** The snapshot lines of the result text: a heading line, then its path lists indented. */
267
- function checkoutText(snapshot: WorkspaceSnapshot): string[] {
268
- const [first, ...rest] = snapshotLines(snapshot);
269
- return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
270
- }
271
-
272
- /** The one message the parent model reads for a settled delegation. */
273
- function resultText(details: ResultDetails): string {
274
- const { task } = details;
275
- const quote =
276
- "note" in task
277
- ? task.note
278
- : `Task, quoted from ${task.path}:\n${task.text
279
- .split("\n")
280
- .map((line) => `> ${line}`)
281
- .join("\n")}`;
282
- return [
283
- `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}, request ${details.requestId}.`,
284
- `Status: ${details.status}`,
285
- ...(details.limit === undefined ? [] : [details.limit]),
286
- quote,
287
- `Working directory: ${cwdLine(details.cwd)}`,
288
- ...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
289
- ...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
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. */
@@ -308,12 +214,23 @@ 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 = (): StartedRecorded[] =>
313
- [...byId.values()].filter(
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(
314
226
  (record): record is StartedRecorded =>
315
- isStarted(record) && record.result === undefined && record.closed !== true && record.gone !== true,
227
+ isStarted(record) &&
228
+ record.result === undefined &&
229
+ record.closed !== true &&
230
+ record.gone !== true &&
231
+ !resumed.has(record.id),
316
232
  );
233
+ };
317
234
 
318
235
  /** The started record for `id`, if it is one. */
319
236
  const startedById = (id: unknown): StartedRecorded | undefined => {
@@ -421,6 +338,7 @@ export function createResults(pi: ExtensionAPI): Results {
421
338
  model: record.model,
422
339
  ...(finished === undefined ? {} : { finishedModel: finished }),
423
340
  id: record.id,
341
+ ...resumeDetails(record),
424
342
  requestId: record.requestId,
425
343
  ...(session === undefined ? {} : { sessionPath: session }),
426
344
  ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
@@ -433,11 +351,11 @@ export function createResults(pi: ExtensionAPI): Results {
433
351
  customType: CUSTOM_TYPE,
434
352
  details,
435
353
  content: [
436
- `[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
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.`,
437
355
  `Working directory: ${cwdLine(record.cwd)}`,
438
356
  ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
439
- `Delegate session: ${session ?? record.id}`,
440
- "A reply that arrives later is still delivered.",
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.",
441
359
  ].join("\n"),
442
360
  display: true,
443
361
  },
@@ -451,8 +369,11 @@ export function createResults(pi: ExtensionAPI): Results {
451
369
  if (payload?.handled === true) return;
452
370
  const reply = payload?.envelope;
453
371
  if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
454
- // Only the delegate's own reply counts: `from` must be the delegation's id.
455
- 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
+ });
456
377
  if (requestId === undefined) return;
457
378
  payload.handled = true;
458
379
  if (shown.has(reply.id)) return;
@@ -462,7 +383,7 @@ export function createResults(pi: ExtensionAPI): Results {
462
383
  const finished = finishedModel(session);
463
384
  // After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
464
385
  const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
465
- const details = resultDetails(delegation, payload, session, finished, snapshot);
386
+ const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
466
387
  // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
467
388
  pi.sendMessage(
468
389
  { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },