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/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
- readonly certainty: "not-opened" | "unknown";
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
- /** Open a detached window and return its id (for example `@3`). */
34
- openWindow(spec: WindowSpec): Promise<string>;
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
- * The real client. A window is opened in the session of the pane in
61
- * `TMUX_PANE`, not in whatever session a client happens to have attached, so
62
- * it works with several clients on one server.
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: string[]) => runTmux(args, timeoutMs);
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
- async openWindow(spec) {
80
- const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd];
81
- for (const [key, value] of Object.entries(spec.env)) args.push("-e", `${key}=${value}`);
82
- let session: string | undefined;
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
- session = await targetSession();
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
- if (session !== undefined) args.push("-t", session);
90
- args.push("--", ...spec.argv);
91
- let out: string;
92
- try {
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 windowId = out.trim();
99
- if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
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 out = await run(["list-windows", "-a", "-F", "#{window_id}\t#{pane_dead}\t#{window_name}"]);
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(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): 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
- return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot };
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.id} (no session file yet)`}`,
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({ record, state, name, sessionPath, lastActive, snapshot }: View): Record<string, unknown> {
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 views = records.map((record) => (isStarted(record) ? view(record, windows, ctx) : record));
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
+ }