pi-squire 0.10.0 → 0.12.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 +36 -1
- package/README.md +107 -13
- package/package.json +1 -1
- package/src/index.ts +231 -41
- package/src/render.ts +4 -3
- package/src/result-message.ts +120 -0
- package/src/result-sessions.ts +35 -3
- package/src/results.ts +37 -116
- package/src/resume.ts +472 -0
- package/src/tmux.ts +161 -37
- package/src/tracking-text.ts +41 -0
- package/src/tracking.ts +43 -51
- package/src/window-placement.ts +31 -0
package/src/tmux.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// fake. This client talks to tmux with `execFile` and no shell.
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
|
|
5
|
+
import type { WindowPlacement } from "./window-placement.ts";
|
|
6
|
+
|
|
5
7
|
/** One window to open: its name, working directory, command and environment. */
|
|
6
8
|
export type WindowSpec = {
|
|
7
9
|
name: string;
|
|
@@ -10,8 +12,41 @@ export type WindowSpec = {
|
|
|
10
12
|
env: Readonly<Record<string, string>>;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
|
-
/** One window as tmux lists it: its id, its name, and whether the program in its pane has exited. */
|
|
14
|
-
export type TmuxWindow = { id: string; name: string; exited: boolean };
|
|
15
|
+
/** One window as tmux lists it: its id, its session's name, its name, and whether the program in its pane has exited. */
|
|
16
|
+
export type TmuxWindow = { id: string; session: string; name: string; exited: boolean };
|
|
17
|
+
|
|
18
|
+
/** A window just opened: its id and the name of the session it is in, when known. */
|
|
19
|
+
export type OpenedWindow = { id: string; session: string | undefined };
|
|
20
|
+
|
|
21
|
+
/** The oldest tmux delegate windows need: `new-session -e` and `display-popup` arrived in 3.2. */
|
|
22
|
+
export const MIN_TMUX = { major: 3, minor: 2 } as const;
|
|
23
|
+
|
|
24
|
+
export type TmuxVersion = { text: string; major: number; minor: number };
|
|
25
|
+
|
|
26
|
+
/** Parse `tmux -V` output such as `tmux 3.7b` or `tmux next-3.4`; a build with no number (`tmux master`) counts as current. */
|
|
27
|
+
export function parseTmuxVersion(output: string): TmuxVersion {
|
|
28
|
+
const text = output.trim();
|
|
29
|
+
const match = /(\d+)\.(\d+)/.exec(text);
|
|
30
|
+
if (match === null) return { text, major: Number.POSITIVE_INFINITY, minor: 0 };
|
|
31
|
+
return { text, major: Number(match[1]), minor: Number(match[2]) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Refuse a tmux older than `MIN_TMUX`, naming the version found. */
|
|
35
|
+
export async function requireTmuxVersion(tmux: { version(): Promise<TmuxVersion> }): Promise<void> {
|
|
36
|
+
const found = await tmux.version();
|
|
37
|
+
if (found.major < MIN_TMUX.major || (found.major === MIN_TMUX.major && found.minor < MIN_TMUX.minor)) {
|
|
38
|
+
throw new Error(`delegate windows need tmux ${MIN_TMUX.major}.${MIN_TMUX.minor} or later; this is ${found.text}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The companion session for a parent in `parentSession`: its own when that is
|
|
44
|
+
* a companion session (marked by pi-squire when it made it), else the name
|
|
45
|
+
* plus `-squire`. The name alone says nothing: a user's session may end in it.
|
|
46
|
+
*/
|
|
47
|
+
export function companionName(parentSession: string, isCompanion: boolean): string {
|
|
48
|
+
return isCompanion ? parentSession : `${parentSession}-squire`;
|
|
49
|
+
}
|
|
15
50
|
|
|
16
51
|
/**
|
|
17
52
|
* Why a window could not be opened, and whether one may exist anyway:
|
|
@@ -19,7 +54,7 @@ export type TmuxWindow = { id: string; name: string; exited: boolean };
|
|
|
19
54
|
* Anything else, a timeout or a malformed answer included, is `unknown`.
|
|
20
55
|
*/
|
|
21
56
|
export class WindowOpenError extends Error {
|
|
22
|
-
|
|
57
|
+
certainty: "not-opened" | "unknown";
|
|
23
58
|
constructor(message: string, certainty: "not-opened" | "unknown") {
|
|
24
59
|
super(message);
|
|
25
60
|
this.name = "WindowOpenError";
|
|
@@ -30,8 +65,14 @@ export class WindowOpenError extends Error {
|
|
|
30
65
|
export interface TmuxClient {
|
|
31
66
|
/** Whether this process runs in a tmux pane. */
|
|
32
67
|
insideTmux(): boolean;
|
|
33
|
-
/**
|
|
34
|
-
|
|
68
|
+
/** The tmux version, asked once. */
|
|
69
|
+
version(): Promise<TmuxVersion>;
|
|
70
|
+
/**
|
|
71
|
+
* Open a detached window and return its id (for example `@3`) and session.
|
|
72
|
+
* `current` opens it in the parent's session; `companion` in
|
|
73
|
+
* `<parent's session>-squire`, created when it does not exist.
|
|
74
|
+
*/
|
|
75
|
+
openWindow(spec: WindowSpec, placement: WindowPlacement): Promise<OpenedWindow>;
|
|
35
76
|
/** Every window on the server, in one tmux call. */
|
|
36
77
|
listWindows(): Promise<TmuxWindow[]>;
|
|
37
78
|
/** Kill a window. */
|
|
@@ -56,59 +97,142 @@ function runTmux(args: string[], timeout: number): Promise<string> {
|
|
|
56
97
|
});
|
|
57
98
|
}
|
|
58
99
|
|
|
100
|
+
type Run = (args: string[]) => Promise<string>;
|
|
101
|
+
type Parent = { id: string; name: string; companion: boolean };
|
|
102
|
+
|
|
103
|
+
/** The user option that marks a session pi-squire made for delegate windows. */
|
|
104
|
+
const COMPANION_OPTION = "@squire-companion";
|
|
105
|
+
|
|
106
|
+
/** The id, name and companion mark of the session the pane in `TMUX_PANE` belongs to, when known. */
|
|
107
|
+
async function parentSession(run: Run): Promise<Parent | undefined> {
|
|
108
|
+
const pane = process.env.TMUX_PANE;
|
|
109
|
+
if (pane === undefined || pane === "") return undefined;
|
|
110
|
+
// Only the line's terminator goes: trailing spaces are legal in a session name.
|
|
111
|
+
const format = `#{session_id}\t#{${COMPANION_OPTION}}\t#{session_name}`;
|
|
112
|
+
const out = (await run(["display-message", "-p", "-t", pane, "-F", format])).replace(/\r?\n$/, "");
|
|
113
|
+
// The name comes last, so it is whatever follows the second tab.
|
|
114
|
+
const [id, marker, ...name] = out.split("\t");
|
|
115
|
+
return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const envArgs = (spec: WindowSpec): string[] =>
|
|
119
|
+
Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
120
|
+
|
|
121
|
+
/** Dispatch one create; after dispatch even an error or timeout may have left a window. */
|
|
122
|
+
async function create(run: Run, args: string[]): Promise<string> {
|
|
123
|
+
let out: string;
|
|
124
|
+
try {
|
|
125
|
+
out = await run(args);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new WindowOpenError((err as Error).message, "unknown");
|
|
128
|
+
}
|
|
129
|
+
const windowId = out.trim();
|
|
130
|
+
if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
|
|
131
|
+
return windowId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const failedWith = (err: unknown, text: string): boolean =>
|
|
135
|
+
err instanceof WindowOpenError && err.message.includes(text);
|
|
136
|
+
|
|
59
137
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
138
|
+
* Open a window in the companion session: `new-window`, then on a missing
|
|
139
|
+
* session one `new-session` that makes the delegate its first window and sets
|
|
140
|
+
* `detach-on-destroy off` and the companion mark on that session only, then `new-window` again when
|
|
141
|
+
* another launch created the session first.
|
|
142
|
+
*/
|
|
143
|
+
async function openCompanion(run: Run, spec: WindowSpec, session: string): Promise<string> {
|
|
144
|
+
const newWindow = [
|
|
145
|
+
"new-window",
|
|
146
|
+
"-d",
|
|
147
|
+
"-P",
|
|
148
|
+
"-F",
|
|
149
|
+
"#{window_id}",
|
|
150
|
+
"-n",
|
|
151
|
+
spec.name,
|
|
152
|
+
"-c",
|
|
153
|
+
spec.cwd,
|
|
154
|
+
...envArgs(spec),
|
|
155
|
+
"-t",
|
|
156
|
+
`=${session}:`,
|
|
157
|
+
"--",
|
|
158
|
+
...spec.argv,
|
|
159
|
+
];
|
|
160
|
+
try {
|
|
161
|
+
return await create(run, newWindow);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// tmux resolves the target before it creates anything, so nothing was dispatched that could create a window.
|
|
164
|
+
if (!failedWith(err, "can't find session")) throw err;
|
|
165
|
+
(err as WindowOpenError).certainty = "not-opened";
|
|
166
|
+
}
|
|
167
|
+
const newSession = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
|
|
168
|
+
newSession.push("-P", "-F", "#{window_id}", "--", ...spec.argv);
|
|
169
|
+
newSession.push(";", "set-option", "-t", `=${session}:`, "detach-on-destroy", "off");
|
|
170
|
+
newSession.push(";", "set-option", "-t", `=${session}:`, COMPANION_OPTION, "1");
|
|
171
|
+
try {
|
|
172
|
+
return await create(run, newSession);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (!failedWith(err, "duplicate session")) throw err;
|
|
175
|
+
}
|
|
176
|
+
return create(run, newWindow);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Open a window in the parent's own session, as delegates always did. */
|
|
180
|
+
function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Promise<string> {
|
|
181
|
+
const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
|
|
182
|
+
if (parent !== undefined) args.push("-t", parent.id);
|
|
183
|
+
args.push("--", ...spec.argv);
|
|
184
|
+
return create(run, args);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The real client. A window is opened relative to the session of the pane in
|
|
189
|
+
* `TMUX_PANE`, not whatever session a client happens to have attached, so it
|
|
190
|
+
* works with several clients on one server.
|
|
63
191
|
*/
|
|
64
192
|
export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClient {
|
|
65
193
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
66
|
-
const run = (args
|
|
67
|
-
|
|
68
|
-
/** The id of the session this pane belongs to, when known. */
|
|
69
|
-
async function targetSession(): Promise<string | undefined> {
|
|
70
|
-
const pane = process.env.TMUX_PANE;
|
|
71
|
-
if (pane === undefined || pane === "") return undefined;
|
|
72
|
-
const sessionId = (await run(["display-message", "-p", "-t", pane, "-F", "#{session_id}"])).trim();
|
|
73
|
-
return sessionId === "" ? undefined : sessionId;
|
|
74
|
-
}
|
|
194
|
+
const run: Run = (args) => runTmux(args, timeoutMs);
|
|
195
|
+
let cachedVersion: Promise<TmuxVersion> | undefined;
|
|
75
196
|
|
|
76
197
|
return {
|
|
77
198
|
insideTmux: () => (process.env.TMUX ?? "") !== "",
|
|
78
199
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
200
|
+
version() {
|
|
201
|
+
// The in-flight ask is shared; a failed one is forgotten so the next call asks again.
|
|
202
|
+
cachedVersion ??= run(["-V"]).then(parseTmuxVersion, (err: unknown) => {
|
|
203
|
+
cachedVersion = undefined;
|
|
204
|
+
throw err;
|
|
205
|
+
});
|
|
206
|
+
return cachedVersion;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async openWindow(spec, placement) {
|
|
210
|
+
let parent: Parent | undefined;
|
|
83
211
|
try {
|
|
84
|
-
|
|
212
|
+
parent = await parentSession(run);
|
|
85
213
|
} catch (err) {
|
|
86
214
|
// The lookup only reads; no window can exist yet.
|
|
87
215
|
throw new WindowOpenError((err as Error).message, "not-opened");
|
|
88
216
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
out = await run(args);
|
|
94
|
-
} catch (err) {
|
|
95
|
-
// After dispatch even an error or timeout may have left a window.
|
|
96
|
-
throw new WindowOpenError((err as Error).message, "unknown");
|
|
217
|
+
const known = parent === undefined || parent.name === "" ? undefined : parent.name;
|
|
218
|
+
if (placement === "current") return { id: await openCurrent(run, spec, parent), session: known };
|
|
219
|
+
if (known === undefined) {
|
|
220
|
+
throw new WindowOpenError("could not tell which tmux session this pane is in (TMUX_PANE)", "not-opened");
|
|
97
221
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
return windowId;
|
|
222
|
+
const session = companionName(known, parent?.companion === true);
|
|
223
|
+
return { id: await openCompanion(run, spec, session), session };
|
|
101
224
|
},
|
|
102
225
|
|
|
103
226
|
async listWindows() {
|
|
104
|
-
// The name goes last: it is the one field that could hold a tab.
|
|
105
|
-
const
|
|
227
|
+
// The window name goes last: it is the one field that could hold a tab.
|
|
228
|
+
const format = "#{window_id}\t#{pane_dead}\t#{session_name}\t#{window_name}";
|
|
229
|
+
const out = await run(["list-windows", "-a", "-F", format]);
|
|
106
230
|
return out
|
|
107
231
|
.split("\n")
|
|
108
232
|
.filter((line) => line.trim() !== "")
|
|
109
233
|
.map((line) => {
|
|
110
|
-
const [id, dead, ...name] = line.split("\t");
|
|
111
|
-
return { id, name: name.join("\t"), exited: dead === "1" };
|
|
234
|
+
const [id, dead, session, ...name] = line.split("\t");
|
|
235
|
+
return { id, session, name: name.join("\t"), exited: dead === "1" };
|
|
112
236
|
});
|
|
113
237
|
},
|
|
114
238
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// How `delegation_status` words a delegation's state, auto-exit and age.
|
|
2
|
+
import type { DelegationState, StartedRecorded } from "./results.ts";
|
|
3
|
+
|
|
4
|
+
/** Records from before auto-exit existed kept their windows open, so they read off. */
|
|
5
|
+
export function autoExitLine(record: StartedRecorded): string {
|
|
6
|
+
return record.autoExit === true
|
|
7
|
+
? "on — the delegate closes its window after a normal completion unless the user took over there"
|
|
8
|
+
: "off — the window stays open after the result";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The state, worded for the `state:` line. */
|
|
12
|
+
export function stateLine(state: DelegationState, record: StartedRecorded): string {
|
|
13
|
+
switch (state) {
|
|
14
|
+
case "running":
|
|
15
|
+
return "running — the window is open and no result has arrived";
|
|
16
|
+
case "gone":
|
|
17
|
+
return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
|
|
18
|
+
case "done":
|
|
19
|
+
return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
|
|
20
|
+
case "closed":
|
|
21
|
+
return "closed — delegation_close recorded it";
|
|
22
|
+
case "window-closed":
|
|
23
|
+
return "closed — the window is gone";
|
|
24
|
+
case "unknown":
|
|
25
|
+
return "unknown — tmux could not be asked whether the window is open";
|
|
26
|
+
case "planned":
|
|
27
|
+
case "created":
|
|
28
|
+
case "rolled-back":
|
|
29
|
+
case "retained":
|
|
30
|
+
case "interrupted":
|
|
31
|
+
return `${state} — no window id/start was recorded; a window may still exist`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
|
|
36
|
+
export function ago(ms: number): string {
|
|
37
|
+
const minutes = Math.floor(ms / 60_000);
|
|
38
|
+
if (minutes < 1) return "just now";
|
|
39
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
40
|
+
return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
|
|
41
|
+
}
|
package/src/tracking.ts
CHANGED
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
type Results,
|
|
19
19
|
recordedName,
|
|
20
20
|
type StartedRecorded,
|
|
21
|
+
sessionIdOf,
|
|
21
22
|
type WindowState,
|
|
22
23
|
type WorkspaceAttempt,
|
|
23
24
|
} from "./results.ts";
|
|
24
25
|
import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
|
|
25
26
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
26
27
|
import { AttemptSchema } from "./tracking-attempt.ts";
|
|
28
|
+
import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
|
|
27
29
|
import type { ReadingError } from "./usage.ts";
|
|
28
30
|
import { WorkspaceSchema } from "./workspace.ts";
|
|
29
31
|
|
|
@@ -69,8 +71,12 @@ const StartedSchema = Type.Object({
|
|
|
69
71
|
),
|
|
70
72
|
thinking: Type.String(),
|
|
71
73
|
autoExit: Type.Boolean(),
|
|
74
|
+
resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
|
|
72
75
|
windowId: Type.String(),
|
|
73
76
|
windowName: Type.String(),
|
|
77
|
+
windowSession: Type.Optional(
|
|
78
|
+
Type.String({ description: "The tmux session its window is in now; absent when the window is gone." }),
|
|
79
|
+
),
|
|
74
80
|
cwd: Type.Optional(
|
|
75
81
|
Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
|
|
76
82
|
),
|
|
@@ -81,7 +87,7 @@ const StartedSchema = Type.Object({
|
|
|
81
87
|
STARTED_STATES.map((state) => Type.Literal(state)),
|
|
82
88
|
{
|
|
83
89
|
description:
|
|
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.",
|
|
90
|
+
"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, though a later delegation_resume continues one as a new delegation; gone and window-closed turn done if a late reply arrives.",
|
|
85
91
|
},
|
|
86
92
|
),
|
|
87
93
|
closed: Type.Boolean(),
|
|
@@ -119,7 +125,7 @@ export const CloseSchema = Type.Object({
|
|
|
119
125
|
});
|
|
120
126
|
|
|
121
127
|
/** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
|
|
122
|
-
type ToolDef = {
|
|
128
|
+
export type ToolDef = {
|
|
123
129
|
name: string;
|
|
124
130
|
label: string;
|
|
125
131
|
description: string;
|
|
@@ -130,7 +136,7 @@ type ToolDef = {
|
|
|
130
136
|
};
|
|
131
137
|
|
|
132
138
|
/** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
|
|
133
|
-
async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
139
|
+
export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
134
140
|
try {
|
|
135
141
|
return await tmux.listWindows();
|
|
136
142
|
} catch {
|
|
@@ -139,48 +145,17 @@ async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined>
|
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
142
|
-
function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
148
|
+
export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
143
149
|
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
147
|
-
function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
153
|
+
export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
148
154
|
if (windows === undefined) return "unknown";
|
|
149
155
|
const own = ownWindow(windows, record);
|
|
150
156
|
return own !== undefined && !own.exited ? "open" : "gone";
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
/** Records from before auto-exit existed kept their windows open, so they read off. */
|
|
154
|
-
function autoExitLine(record: StartedRecorded): string {
|
|
155
|
-
return record.autoExit === true
|
|
156
|
-
? "on — the delegate closes its window after a normal completion unless the user took over there"
|
|
157
|
-
: "off — the window stays open after the result";
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The state, worded for the `state:` line. */
|
|
161
|
-
function stateLine(state: DelegationState, record: StartedRecorded): string {
|
|
162
|
-
switch (state) {
|
|
163
|
-
case "running":
|
|
164
|
-
return "running — the window is open and no result has arrived";
|
|
165
|
-
case "gone":
|
|
166
|
-
return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
|
|
167
|
-
case "done":
|
|
168
|
-
return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
|
|
169
|
-
case "closed":
|
|
170
|
-
return "closed — delegation_close recorded it";
|
|
171
|
-
case "window-closed":
|
|
172
|
-
return "closed — the window is gone";
|
|
173
|
-
case "unknown":
|
|
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`;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
159
|
/** One delegation as `delegation_status` reports it; its text and details both read from this. */
|
|
185
160
|
type View = {
|
|
186
161
|
record: StartedRecorded;
|
|
@@ -191,18 +166,19 @@ type View = {
|
|
|
191
166
|
lastActive: Date | undefined;
|
|
192
167
|
/** The checkout observed for this status call; the recorded result keeps its own. */
|
|
193
168
|
snapshot: WorkspaceSnapshot;
|
|
169
|
+
/** The delegation that resumed this one, if any. */
|
|
170
|
+
resumedAs: string | undefined;
|
|
171
|
+
/** The session its window is in now, looked up; undefined when the window is gone or unknown. */
|
|
172
|
+
windowSession: string | undefined;
|
|
194
173
|
};
|
|
195
174
|
|
|
196
|
-
/** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
|
|
197
|
-
function ago(ms: number): string {
|
|
198
|
-
const minutes = Math.floor(ms / 60_000);
|
|
199
|
-
if (minutes < 1) return "just now";
|
|
200
|
-
if (minutes < 60) return `${minutes} min ago`;
|
|
201
|
-
return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
175
|
/** The view of a delegation: its state and the fields the text and details share. */
|
|
205
|
-
function view(
|
|
176
|
+
function view(
|
|
177
|
+
record: StartedRecorded,
|
|
178
|
+
windows: TmuxWindow[] | undefined,
|
|
179
|
+
ctx: ExtensionContext,
|
|
180
|
+
resumedAs: string | undefined,
|
|
181
|
+
): View {
|
|
206
182
|
const state = deriveState(record, windowState(windows, record));
|
|
207
183
|
const sessionPath = locateSession(record, ctx.sessionManager.getSessionDir());
|
|
208
184
|
let lastActive: Date | undefined;
|
|
@@ -214,31 +190,42 @@ function view(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: E
|
|
|
214
190
|
}
|
|
215
191
|
}
|
|
216
192
|
const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
|
|
217
|
-
|
|
193
|
+
const windowSession = windows === undefined ? undefined : ownWindow(windows, record)?.session;
|
|
194
|
+
return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs, windowSession };
|
|
218
195
|
}
|
|
219
196
|
|
|
220
197
|
/** The text block for one delegation. */
|
|
221
|
-
function viewText({ record, state, name, sessionPath, lastActive, snapshot }: View): string {
|
|
198
|
+
function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs, windowSession }: View): string {
|
|
222
199
|
const [checkout, ...categories] = snapshotLines(snapshot);
|
|
223
200
|
const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
|
|
224
201
|
const lines = [
|
|
225
202
|
`${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
|
|
226
203
|
` name: ${name}`,
|
|
204
|
+
...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
|
|
205
|
+
...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
|
|
227
206
|
` state: ${stateLine(state, record)}${seen}`,
|
|
228
|
-
` window: ${record.windowName} (${record.windowId})`,
|
|
207
|
+
` window: ${record.windowName} (${record.windowId})${windowSession === undefined || windowSession === "" ? "" : ` in session ${windowSession}`}`,
|
|
229
208
|
` cwd: ${cwdLine(record.cwd)}`,
|
|
230
209
|
...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
|
|
231
210
|
` checkout now: ${checkout}`,
|
|
232
211
|
...categories.map((line) => ` ${line}`),
|
|
233
212
|
` auto-exit: ${autoExitLine(record)}`,
|
|
234
|
-
` session: ${sessionPath ?? `${record
|
|
213
|
+
` session: ${sessionPath ?? `${sessionIdOf(record)} (no session file yet)`}`,
|
|
235
214
|
];
|
|
236
215
|
if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
|
|
237
216
|
return lines.join("\n");
|
|
238
217
|
}
|
|
239
218
|
|
|
240
219
|
/** The structured details for one delegation. */
|
|
241
|
-
function viewDetails({
|
|
220
|
+
function viewDetails({
|
|
221
|
+
record,
|
|
222
|
+
state,
|
|
223
|
+
name,
|
|
224
|
+
sessionPath,
|
|
225
|
+
lastActive,
|
|
226
|
+
snapshot,
|
|
227
|
+
windowSession,
|
|
228
|
+
}: View): Record<string, unknown> {
|
|
242
229
|
return {
|
|
243
230
|
kind: "started",
|
|
244
231
|
id: record.id,
|
|
@@ -248,8 +235,10 @@ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }:
|
|
|
248
235
|
...(record.result?.finishedModel === undefined ? {} : { finishedModel: record.result.finishedModel }),
|
|
249
236
|
thinking: record.thinking,
|
|
250
237
|
autoExit: record.autoExit === true,
|
|
238
|
+
...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
|
|
251
239
|
windowId: record.windowId,
|
|
252
240
|
windowName: record.windowName,
|
|
241
|
+
...(windowSession === undefined || windowSession === "" ? {} : { windowSession }),
|
|
253
242
|
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
|
|
254
243
|
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
255
244
|
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
@@ -384,7 +373,10 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
384
373
|
): Promise<ReturnType<typeof toolResult>> {
|
|
385
374
|
const records = selectRecords(results, params.id);
|
|
386
375
|
const windows = await listWindows(tmux);
|
|
387
|
-
const
|
|
376
|
+
const resumedAs = (id: string) => results.list().find((item) => isStarted(item) && item.resumedFrom === id)?.id;
|
|
377
|
+
const views = records.map((record) =>
|
|
378
|
+
isStarted(record) ? view(record, windows, ctx, resumedAs(record.id)) : record,
|
|
379
|
+
);
|
|
388
380
|
const marks = activeMarks();
|
|
389
381
|
const errors = readingErrors();
|
|
390
382
|
const details = {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Call-time choice of where delegate windows open. `companion` (the default)
|
|
2
|
+
// opens them in a session of their own, `<parent's session>-squire`; `current`
|
|
3
|
+
// opens them in the parent's own session, as before. Read from
|
|
4
|
+
// `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export type WindowPlacement = "companion" | "current";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The placement for this call. An unknown value refuses with the file named.
|
|
12
|
+
* A file that cannot be read or parsed is left to the worktree settings,
|
|
13
|
+
* which report it when a launch needs them: it does not stop the launches
|
|
14
|
+
* that never read it, so those keep the default.
|
|
15
|
+
*/
|
|
16
|
+
export function windowPlacement(agentDir: string): WindowPlacement {
|
|
17
|
+
const file = join(agentDir, "squire", "settings.json");
|
|
18
|
+
let parsed: unknown;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return "companion";
|
|
23
|
+
}
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "companion";
|
|
25
|
+
const value = (parsed as Record<string, unknown>).windowPlacement;
|
|
26
|
+
if (value === undefined || value === "companion") return "companion";
|
|
27
|
+
if (value === "current") return "current";
|
|
28
|
+
throw new Error(
|
|
29
|
+
`invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(value)}`,
|
|
30
|
+
);
|
|
31
|
+
}
|