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/render.ts CHANGED
@@ -18,13 +18,106 @@ import { Box, type Component, Container, Markdown, Spacer, Text, TruncatedText }
18
18
  import type { Static } from "typebox";
19
19
 
20
20
  import type { DelegateSchema } from "./index.ts";
21
- import type { MessageDetails } from "./results.ts";
21
+ import type { MessageDetails, ResourceFacts, WorkspaceAttempt } from "./results.ts";
22
+ import type { WorkspaceSnapshot } from "./snapshot.ts";
23
+ import type { PathSummary, Workspace } from "./workspace.ts";
22
24
 
23
25
  /** The launch model, or `launch → finished` when the delegate finished on another one. */
24
26
  export function modelLabel(model: string, finished: string | undefined): string {
25
27
  return finished === undefined || finished === model ? model : `${model} → ${finished}`;
26
28
  }
27
29
 
30
+ const displayReason = (reason: string): string => JSON.stringify(reason).slice(1, -1);
31
+
32
+ /** Where a delegation ran, or that its record, from before cwd was kept, does not say. */
33
+ export function cwdLine(cwd: string | undefined): string {
34
+ return cwd ?? "unknown (not recorded)";
35
+ }
36
+
37
+ /** How a delegation's directory was chosen and its Git checkout at launch, on one line. */
38
+ export function workspaceLine(workspace: Workspace): string {
39
+ if (workspace.mode === "managed") {
40
+ const { createdBranch, requestedBase, baseCommit, source, cwd } = workspace;
41
+ return `managed worktree ${cwd}, new branch ${createdBranch} created from ${requestedBase} (${baseCommit}) of ${source.checkoutRoot}; ${dirtyNotice(workspace.sourceDirty)}`;
42
+ }
43
+ const { mode, git } = workspace;
44
+ const how = mode === "inherited" ? "the parent's directory" : "existing directory";
45
+ if (git.kind === "non-git") return `${how}; not a Git checkout`;
46
+ if (git.kind === "unknown") return `${how}; Git state unknown (${displayReason(git.reason)})`;
47
+ const { mainRoot, checkoutRoot } = git.identity;
48
+ const linked = checkoutRoot === mainRoot ? "" : ` (linked worktree ${checkoutRoot})`;
49
+ const at = git.position.detached ? "detached HEAD" : `branch ${git.position.branch}`;
50
+ return `${how}; Git checkout ${mainRoot}${linked}, ${at}, launch commit ${git.baseline ?? "none (no commits yet)"}`;
51
+ }
52
+
53
+ /** The source's uncommitted paths a worktree does not carry, bounded to a few names. */
54
+ export function dirtyNotice({ paths, total }: PathSummary): string {
55
+ if (total === 0) return "the source had no uncommitted files";
56
+ const shown = paths.slice(0, 5).map((path) => JSON.stringify(path));
57
+ const rest = total > shown.length ? `, and ${total - shown.length} more` : "";
58
+ return `${total} uncommitted source path${total === 1 ? " is" : "s are"} not copied into it (${shown.join(", ")}${rest})`;
59
+ }
60
+
61
+ /** What is known to exist for an attempt, in words. */
62
+ export function resourceLine(resources: ResourceFacts): string {
63
+ switch (resources.kind) {
64
+ case "none":
65
+ return "nothing created by this attempt remains";
66
+ case "branch":
67
+ return `the branch ${resources.branch} remains at ${resources.head}; its worktree is gone`;
68
+ case "worktree":
69
+ return `the worktree ${resources.cwd} and its branch ${resources.branch} (at ${resources.head}) remain`;
70
+ case "unknown":
71
+ return `unknown: ${displayReason(resources.reason)}; observed paths ${JSON.stringify(resources.observedPaths)}, refs ${JSON.stringify(resources.observedRefs)}`;
72
+ }
73
+ }
74
+
75
+ /** The lines that describe an attempt without a window: its plan, what exists, why it stopped and recovery. */
76
+ export function attemptLines({ plan, attempt, requestId }: WorkspaceAttempt): string[] {
77
+ let why: string[] = [];
78
+ if ("cause" in attempt) why = [`cause: ${attempt.cause}`];
79
+ else if ("reason" in attempt) why = [`reason: ${attempt.reason}`];
80
+ return [
81
+ `source: ${plan.sourceCwd} (repository ${plan.source.mainRoot})`,
82
+ `worktree: ${plan.destination} on new branch ${plan.branch} from ${plan.requestedBase} (${plan.baseCommit})`,
83
+ `resources: ${resourceLine(attempt.resources)}`,
84
+ ...why,
85
+ ...(attempt.phase === "retained" && attempt.recovery.length > 0
86
+ ? ["recovery (non-forced; check each command's result):", ...attempt.recovery.map((line) => ` ${line}`)]
87
+ : []),
88
+ ...(requestId === undefined
89
+ ? []
90
+ : [`request: ${requestId} (the task was mailed; no window id/start was recorded and a window may still exist)`]),
91
+ ];
92
+ }
93
+
94
+ /** A path list as data: each name JSON-quoted, so spaces, quotes and newlines stay inside one line. */
95
+ function pathList({ paths, total }: PathSummary): string {
96
+ if (total === 0) return "none";
97
+ const more = total > paths.length ? `, and ${total - paths.length} more not listed` : "";
98
+ return `${total} path${total === 1 ? "" : "s"}: ${paths.map((path) => JSON.stringify(path)).join(", ")}${more}`;
99
+ }
100
+
101
+ /**
102
+ * A checkout observation: a summary line, then for Git one line per path
103
+ * category. Committed changes are counted from the launch commit; pending
104
+ * ones are the index and working tree now.
105
+ */
106
+ export function snapshotLines(snapshot: WorkspaceSnapshot): string[] {
107
+ if (snapshot.kind === "non-git") return ["not a Git checkout"];
108
+ if (snapshot.kind === "unknown") return [`Git state unknown (${displayReason(snapshot.reason)})`];
109
+ const { position, head, baseline, ahead, workingState, committed, pending } = snapshot;
110
+ const at = position.detached ? "detached HEAD" : `branch ${position.branch}`;
111
+ return [
112
+ `${at}, head ${head}, launch commit ${baseline}, ${ahead} commit${ahead === 1 ? "" : "s"} since launch, working tree ${workingState}`,
113
+ `committed since launch: ${pathList(committed)}`,
114
+ `staged: ${pathList(pending.staged)}`,
115
+ `unstaged: ${pathList(pending.unstaged)}`,
116
+ `untracked: ${pathList(pending.untracked)}`,
117
+ `conflicted: ${pathList(pending.conflicted)}`,
118
+ ];
119
+ }
120
+
28
121
  /** Lines of a result the collapsed message previews. */
29
122
  const PREVIEW_LINES = 5;
30
123
  /** Characters of the task's first line the call row shows. */
@@ -61,10 +154,17 @@ function plainLines(text: string): string[] {
61
154
 
62
155
  function idLines(details: MessageDetails): string[] {
63
156
  const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
157
+ if (details.resumedFrom !== undefined) lines.splice(1, 0, `resumed from ${details.resumedFrom}`);
64
158
  if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
65
159
  else lines.push(`window ${details.windowName}`);
160
+ lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
161
+ if (details.workspace !== undefined) lines.push(`workspace ${workspaceLine(details.workspace)}`);
162
+ if (details.kind === "result" && details.workspaceSnapshot !== undefined) {
163
+ const [first, ...rest] = snapshotLines(details.workspaceSnapshot);
164
+ lines.push(`checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`));
165
+ }
66
166
  lines.push(
67
- `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
167
+ `session ${details.sessionPath === undefined ? `${details.sessionId ?? details.id} (no session file found)` : tilde(details.sessionPath)}`,
68
168
  );
69
169
  return lines;
70
170
  }
@@ -194,6 +294,12 @@ export function renderDelegateResult(
194
294
  ...started.notices,
195
295
  `delegation ${started.id}`,
196
296
  `window ${started.windowName} (${started.windowId})`,
297
+ // Results saved before cwd was recorded have none.
298
+ `cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
299
+ // Results saved before workspaces were recorded have none.
300
+ ...((started.workspace as Workspace | undefined) === undefined
301
+ ? []
302
+ : [`workspace ${workspaceLine(started.workspace)}`]),
197
303
  `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
198
304
  ].map((line) => theme.fg("dim", line)),
199
305
  );
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import { closeSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import type { StartedRecorded } from "./result-types.ts";
4
+
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. */
15
+ export function findSession(dir: string | undefined, id: string): string | undefined {
16
+ if (dir === undefined) return undefined;
17
+ let names: string[];
18
+ try {
19
+ names = readdirSync(dir);
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
24
+ return name === undefined ? undefined : join(dir, name);
25
+ }
26
+ /** Use a recorded absolute session path, otherwise search its recorded session directory. */
27
+ export function locateSession(record: StartedRecorded, fallbackDir: string | undefined): string | undefined {
28
+ const known = record.sessionPath ?? record.result?.sessionPath;
29
+ if (known !== undefined) return known;
30
+ const found = findSession(record.sessionDir ?? fallbackDir, sessionIdOf(record));
31
+ if (found !== undefined && isAbsolute(found)) record.sessionPath = found;
32
+ return found;
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
+
57
+ /** The provider/model of the last assistant message in the delegate session, if available. */
58
+ export function finishedModel(sessionPath: string | undefined): string | undefined {
59
+ if (sessionPath === undefined) return undefined;
60
+ let lines: string[];
61
+ try {
62
+ lines = readFileSync(sessionPath, "utf8").split("\n");
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
68
+ try {
69
+ entry = JSON.parse(lines[i]);
70
+ } catch {
71
+ continue;
72
+ }
73
+ const message = entry?.message;
74
+ if (entry?.type !== "message" || message?.role !== "assistant") continue;
75
+ if (typeof message.provider === "string" && typeof message.model === "string")
76
+ return `${message.provider}/${message.model}`;
77
+ }
78
+ return undefined;
79
+ }
@@ -0,0 +1,36 @@
1
+ import type { Delegation } from "./results.ts";
2
+ import type { WorktreePlan } from "./worktree.ts";
3
+
4
+ /** One started delegation as it stands: what was recorded at its start, plus what came after. */
5
+ export type StartedRecorded = Delegation & { result?: import("./results.ts").Result; closed?: boolean; gone?: boolean };
6
+ /** What is known to exist for an attempt; facts, not authorization to remove anything. */
7
+ export type ResourceFacts =
8
+ | { kind: "none" }
9
+ | { kind: "branch"; branch: string; head: string }
10
+ | { kind: "worktree"; cwd: string; branch: string; head: string }
11
+ | { kind: "unknown"; reason: string; observedPaths: string[]; observedRefs: string[] };
12
+ /** Where a managed launch stands before, or instead of, a window. */
13
+ export type AttemptState =
14
+ | { phase: "planned"; resources: ResourceFacts }
15
+ | { phase: "created"; resources: ResourceFacts }
16
+ | { phase: "rolled-back"; resources: { kind: "none" }; cause: string }
17
+ | { phase: "retained"; resources: ResourceFacts; cause: string; recovery: string[] }
18
+ | { phase: "interrupted"; resources: ResourceFacts; lastPhase: "planned" | "created"; reason: string };
19
+ /** A managed launch with no window; its request id exists only if mail returned one. */
20
+ export type WorkspaceAttempt = {
21
+ kind: "workspace-attempt";
22
+ id: string;
23
+ parentId: string;
24
+ agent: string;
25
+ model: string;
26
+ thinking: string;
27
+ name?: string;
28
+ sessionDir: string;
29
+ plan: WorktreePlan;
30
+ attempt: AttemptState;
31
+ requestId?: string;
32
+ };
33
+ export type Recorded = StartedRecorded | WorkspaceAttempt;
34
+ export function isStarted(record: Recorded): record is StartedRecorded {
35
+ return !("kind" in record && record.kind === "workspace-attempt");
36
+ }