pi-squire 0.11.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 CHANGED
@@ -6,6 +6,23 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-10-26
10
+
11
+ ### Changed
12
+
13
+ - Delegates open in a companion tmux session, `<your session>-squire`, instead
14
+ of your own session, created on demand and removed by tmux with its last
15
+ window. pi-squire marks the session it creates (tmux session option
16
+ `@squire-companion`), so only a marked session counts as a companion: your
17
+ own session called `pi-squire` gets `pi-squire-squire`. Set
18
+ `"windowPlacement": "current"` in `<agent dir>/squire/settings.json` to open
19
+ them in your session as before; any other value refuses the launch.
20
+ The start message, `delegation_status` and the delegate card name the session,
21
+ and the `delegate` and `delegation_resume` results and the status details gain
22
+ an optional `windowSession`. `session:launch` is unchanged.
23
+ - tmux 3.2 or later is now required (it was 3.0); `/delegate`, `/delegate-resume`
24
+ and the tools refuse an older tmux with its version.
25
+
9
26
  ## [0.11.0] - 2026-10-26
10
27
 
11
28
  ### Added
@@ -474,7 +491,8 @@ author's dotfiles; the changes below are against that copy.
474
491
 
475
492
  - The published file list includes `child.ts`, which `index.ts` imports.
476
493
 
477
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...HEAD
494
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.12.0...HEAD
495
+ [0.12.0]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...v0.12.0
478
496
  [0.11.0]: https://github.com/gvanderclay/pi-squire/compare/v0.10.0...v0.11.0
479
497
  [0.10.0]: https://github.com/gvanderclay/pi-squire/compare/v0.9.5...v0.10.0
480
498
  [0.9.5]: https://github.com/gvanderclay/pi-squire/compare/v0.9.4...v0.9.5
package/README.md CHANGED
@@ -16,9 +16,11 @@ You need:
16
16
  options the parent guidance uses, and the extension APIs the package relies
17
17
  on. Pi supplies the peer dependencies `@earendil-works/pi-coding-agent` and
18
18
  `typebox`.
19
- - tmux 3.0 or later, with the Pi session running in a tmux pane. That is the
20
- first version with `new-window -e`. A window is opened in the pane's
21
- session, so a client attached elsewhere does not matter.
19
+ - tmux 3.2 or later, with the Pi session running in a tmux pane. That is the
20
+ first version with `new-session -e` and `display-popup`. Windows are placed
21
+ relative to the pane's session (see [Where delegate windows
22
+ open](#where-delegate-windows-open)), so a client attached elsewhere does
23
+ not matter.
22
24
  - A provider of `message:*`;
23
25
  [pi-session-mail](https://github.com/gvanderclay/pi-session-mail) is one
24
26
  example. Without a provider there is no way to send the task or get an
@@ -130,6 +132,39 @@ temporary folder, not as text on the command line.
130
132
  missing. In print mode (`pi -p`) Pi does not show the refusal notices, so
131
133
  the refusal is silent there.
132
134
 
135
+ ## Where delegate windows open
136
+
137
+ By default a delegate's window opens in a **companion session** named after
138
+ yours with `-squire` added: from the session `work`, delegates go to
139
+ `work-squire`. Your own session stays as it is, even when its name already
140
+ ends in `-squire`: a session called `pi-squire` gets `pi-squire-squire`.
141
+ pi-squire marks each companion session it creates with the tmux session
142
+ option `@squire-companion`; a parent that runs in a marked session uses it, so
143
+ a delegate that delegates does not make `work-squire-squire`.
144
+
145
+ - The session is created on demand, by the launch that needs it, and tmux
146
+ removes it with its last window, so it does not outlive its delegates.
147
+ - `detach-on-destroy` is set to `off` (and the mark set) on that session only: if you are
148
+ looking at it when its last delegate exits, tmux moves you to your most
149
+ recent other session instead of detaching you. Your sessions and tmux's
150
+ global options are never changed.
151
+ - The start message, the delegate card and the `delegate` and
152
+ `delegation_resume` results show the session the window opened in, as the
153
+ optional `windowSession`; they keep that name. `delegation_status` looks it
154
+ up each time, so it follows a window you move to another session, which is
155
+ still found, closed and resumed.
156
+
157
+ To open windows in your own session, as before, set `windowPlacement` in
158
+ `<agent dir>/squire/settings.json`, beside `worktreeRoot`:
159
+
160
+ ```json
161
+ { "windowPlacement": "current" }
162
+ ```
163
+
164
+ The values are `companion` (the default when the setting is absent) and
165
+ `current`. The file is read at each launch. Any other value refuses the launch
166
+ with an error naming the file, before the task is sent or a window opens.
167
+
133
168
  ## Working directory
134
169
 
135
170
  `--cwd <dir>` (the `delegate` tool's `cwd`) runs the delegate in an existing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -56,9 +56,10 @@ import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
56
56
  import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
57
57
  import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
58
58
  import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
59
- import { createTmuxClient, type TmuxClient, WindowOpenError } from "./tmux.ts";
59
+ import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
60
60
  import { createTracking, toolResult } from "./tracking.ts";
61
61
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
62
+ import { type WindowPlacement, windowPlacement } from "./window-placement.ts";
62
63
  import {
63
64
  normalizeWorkspaceValue,
64
65
  prepareDirectory,
@@ -149,6 +150,9 @@ export const DelegateSchema = Type.Object({
149
150
  autoExit: Type.Boolean(),
150
151
  windowId: Type.String(),
151
152
  windowName: Type.String(),
153
+ windowSession: Type.Optional(
154
+ Type.String({ description: "The tmux session the window opened in, as tmux named it then." }),
155
+ ),
152
156
  cwd: Type.String({ description: "The directory the delegate's window opened in." }),
153
157
  sessionDir: Type.String({ description: "The session-storage directory the delegate was told to use." }),
154
158
  workspace: WorkspaceSchema,
@@ -216,6 +220,8 @@ type Launched = {
216
220
  name: string;
217
221
  windowId: string;
218
222
  windowName: string;
223
+ /** The session the window opened in; undefined when tmux could not say. */
224
+ windowSession: string | undefined;
219
225
  requestId: string;
220
226
  cwd: string;
221
227
  sessionDir: string;
@@ -640,7 +646,7 @@ export default function delegate(
640
646
 
641
647
  /**
642
648
  * Build the delegate's command line and environment, emit `session:launch`
643
- * and open its window; returns the window id. A launch and a resume share
649
+ * and open its window; returns the window id and session. A launch and a resume share
644
650
  * it and differ only in `session`, the arguments that choose the session:
645
651
  * `--session-id <id> --name <name>` for a new one, `--session <path>` to reopen.
646
652
  */
@@ -656,9 +662,24 @@ export default function delegate(
656
662
  /** The tool call that started this launch or resume; undefined from a command. */
657
663
  toolCallId: string | undefined;
658
664
  ctx: ExtensionContext;
665
+ /** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
666
+ placement: WindowPlacement;
659
667
  onWindowOpened?: () => void;
660
- }): Promise<string> {
661
- const { id, name, session, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
668
+ }): Promise<OpenedWindow> {
669
+ const {
670
+ id,
671
+ name,
672
+ session,
673
+ workspace,
674
+ trusted,
675
+ cwd,
676
+ sessionDir,
677
+ start,
678
+ toolCallId,
679
+ ctx,
680
+ placement,
681
+ onWindowOpened,
682
+ } = input;
662
683
  const parent = ctx.sessionManager.getSessionId();
663
684
  const managedContext =
664
685
  workspace.mode === "managed"
@@ -699,9 +720,9 @@ export default function delegate(
699
720
  // Listeners may only append to `args` and add to `env`; there is no veto.
700
721
  const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd, toolCallId };
701
722
  pi.events.emit(LAUNCH, payload);
702
- let windowId: string;
723
+ let opened: OpenedWindow;
703
724
  try {
704
- windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
725
+ opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
705
726
  onWindowOpened?.();
706
727
  } catch (err) {
707
728
  if (err instanceof WindowOpenError) {
@@ -709,7 +730,7 @@ export default function delegate(
709
730
  }
710
731
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
711
732
  }
712
- return windowId;
733
+ return opened;
713
734
  }
714
735
 
715
736
  async function launchWindow(input: {
@@ -722,11 +743,13 @@ export default function delegate(
722
743
  start: Start;
723
744
  toolCallId: string | undefined;
724
745
  ctx: ExtensionContext;
746
+ placement: WindowPlacement;
725
747
  onWindowOpened: () => void;
726
748
  }): Promise<Launched> {
727
- const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
749
+ const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
750
+ input;
728
751
  const name = delegateName(start.agent.name, id, start.label);
729
- const windowId = await openChild({
752
+ const { id: windowId, session: windowSession } = await openChild({
730
753
  id,
731
754
  name,
732
755
  session: ["--session-id", id, "--name", name],
@@ -737,6 +760,7 @@ export default function delegate(
737
760
  start,
738
761
  toolCallId,
739
762
  ctx,
763
+ placement,
740
764
  onWindowOpened,
741
765
  });
742
766
  // No task text in the record: the request copy in `sent/` has it.
@@ -758,7 +782,7 @@ export default function delegate(
758
782
  ctx,
759
783
  );
760
784
  tracking.startPolling();
761
- return { id, name, windowId, windowName: name, requestId, cwd, sessionDir, workspace };
785
+ return { id, name, windowId, windowName: name, windowSession, requestId, cwd, sessionDir, workspace };
762
786
  }
763
787
 
764
788
  async function launch(
@@ -768,6 +792,9 @@ export default function delegate(
768
792
  toolCallId?: string,
769
793
  ): Promise<Launched> {
770
794
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
795
+ await requireTmuxVersion(tmux);
796
+ // Read once, before anything is created or mailed; the launch keeps this value.
797
+ const placement = windowPlacement(getAgentDir());
771
798
  const sessionDir = ctx.sessionManager.getSessionDir();
772
799
  if (!isAbsolute(sessionDir))
773
800
  throw new Error(
@@ -878,6 +905,7 @@ export default function delegate(
878
905
  start,
879
906
  toolCallId,
880
907
  ctx,
908
+ placement,
881
909
  onWindowOpened: () => {
882
910
  windowOpened = true;
883
911
  },
@@ -949,12 +977,13 @@ export default function delegate(
949
977
  delegateSchema: DelegateSchema,
950
978
  notInTmux: NOT_IN_TMUX,
951
979
  startPolling: tracking.startPolling,
980
+ placement: () => windowPlacement(getAgentDir()),
952
981
  roster: rosterFor,
953
982
  chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
954
983
  thinkingProblem,
955
984
  prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
956
985
  sendTask,
957
- open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx }) =>
986
+ open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement }) =>
958
987
  openChild({
959
988
  id,
960
989
  name,
@@ -966,15 +995,19 @@ export default function delegate(
966
995
  start,
967
996
  toolCallId,
968
997
  ctx,
998
+ placement,
969
999
  }),
970
1000
  });
971
1001
 
1002
+ /** The session a window opened in, for the start text. */
1003
+ const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
1004
+
972
1005
  /** Where a selected directory put the delegate, for the start text; inherited starts stay compact. */
973
1006
  const placedIn = (launched: Launched): string =>
974
1007
  launched.workspace.mode !== "inherited" ? ` in ${launched.cwd}` : "";
975
1008
 
976
1009
  const started = (launched: Launched, start: Start): string =>
977
- `delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
1010
+ `delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${inSession(launched.windowSession)}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
978
1011
 
979
1012
  async function command(input: string, ctx: ExtensionContext): Promise<void> {
980
1013
  const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
@@ -1115,7 +1148,7 @@ export default function delegate(
1115
1148
  const launched = await launch(start, ctx, signal, toolCallId);
1116
1149
  ctx.ui.notify(started(launched, start), "info");
1117
1150
  return toolResult(
1118
- `Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}${placedIn(launched)}.${launched.workspace.mode === "managed" ? ` Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""} It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
1151
+ `Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}${inSession(launched.windowSession)}${placedIn(launched)}.${launched.workspace.mode === "managed" ? ` Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""} It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
1119
1152
  {
1120
1153
  outcome: "started",
1121
1154
  id: launched.id,
@@ -1127,6 +1160,7 @@ export default function delegate(
1127
1160
  autoExit: start.autoExit,
1128
1161
  windowId: launched.windowId,
1129
1162
  windowName: launched.windowName,
1163
+ ...(launched.windowSession === undefined ? {} : { windowSession: launched.windowSession }),
1130
1164
  cwd: launched.cwd,
1131
1165
  sessionDir: launched.sessionDir,
1132
1166
  workspace: launched.workspace,
package/src/render.ts CHANGED
@@ -282,7 +282,7 @@ export function renderDelegateResult(
282
282
  const lines = [
283
283
  `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
284
284
  "dim",
285
- ` (${started.model}) — started`,
285
+ `${started.windowSession === undefined ? "" : ` in session ${started.windowSession}`} (${started.model}) — started`,
286
286
  )}`,
287
287
  ];
288
288
  const task = taskLine(context.args.task, theme);
@@ -293,7 +293,7 @@ export function renderDelegateResult(
293
293
  ...[
294
294
  ...started.notices,
295
295
  `delegation ${started.id}`,
296
- `window ${started.windowName} (${started.windowId})`,
296
+ `window ${started.windowName} (${started.windowId})${started.windowSession === undefined ? "" : ` in session ${started.windowSession}`}`,
297
297
  // Results saved before cwd was recorded have none.
298
298
  `cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
299
299
  // Results saved before workspaces were recorded have none.
package/src/resume.ts CHANGED
@@ -22,8 +22,9 @@ import {
22
22
  recordedName,
23
23
  type StartedRecorded,
24
24
  } from "./results.ts";
25
- import type { TmuxClient } from "./tmux.ts";
25
+ import { type OpenedWindow, requireTmuxVersion, type TmuxClient } from "./tmux.ts";
26
26
  import { listWindows, ownWindow, type ToolDef, toolResult, windowState } from "./tracking.ts";
27
+ import type { WindowPlacement } from "./window-placement.ts";
27
28
  import type { Workspace } from "./workspace.ts";
28
29
 
29
30
  /** What a resume asks for; the fields after `task` are optional overrides. */
@@ -49,6 +50,8 @@ export type ResumeDeps = {
49
50
  /** The refusal text for a session outside tmux. */
50
51
  notInTmux: string;
51
52
  startPolling(): void;
53
+ /** The window placement for this call; throws on an invalid setting. */
54
+ placement(): WindowPlacement;
52
55
  roster(ctx: ExtensionContext): Roster;
53
56
  chooseModel(
54
57
  explicit: string | undefined,
@@ -65,7 +68,7 @@ export type ResumeDeps = {
65
68
  ): Promise<{ workspace: Workspace; trusted: boolean }>;
66
69
  /** Mail the task to a session id; returns the request id. */
67
70
  sendTask(to: string, task: string): string;
68
- /** Open the window with `--session <path>`; returns its id. */
71
+ /** Open the window with `--session <path>`; returns its id and session. */
69
72
  open(input: {
70
73
  id: string;
71
74
  name: string;
@@ -77,7 +80,8 @@ export type ResumeDeps = {
77
80
  start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
78
81
  toolCallId: string | undefined;
79
82
  ctx: ExtensionContext;
80
- }): Promise<string>;
83
+ placement: WindowPlacement;
84
+ }): Promise<OpenedWindow>;
81
85
  };
82
86
 
83
87
  /** A started resume: what the tool and the command report. */
@@ -92,6 +96,8 @@ export type Resumed = {
92
96
  autoExit: boolean;
93
97
  windowId: string;
94
98
  windowName: string;
99
+ /** The session the window opened in, when tmux could say. */
100
+ windowSession?: string;
95
101
  cwd: string;
96
102
  sessionDir: string;
97
103
  workspace: Workspace;
@@ -273,15 +279,22 @@ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, t
273
279
 
274
280
  async function startResume(
275
281
  deps: ResumeDeps,
276
- input: { record: StartedRecorded; session: SessionFile; plan: Plan; task: string; toolCallId: string | undefined },
282
+ input: {
283
+ record: StartedRecorded;
284
+ session: SessionFile;
285
+ plan: Plan;
286
+ task: string;
287
+ toolCallId: string | undefined;
288
+ placement: WindowPlacement;
289
+ },
277
290
  ctx: ExtensionContext,
278
291
  ): Promise<Resumed> {
279
- const { record, session, plan, task, toolCallId } = input;
292
+ const { record, session, plan, task, toolCallId, placement } = input;
280
293
  const { start, chosen, workspace } = plan;
281
294
  const id = randomUUID();
282
295
  const sessionId = sessionIdOf(record);
283
296
  const name = recordedName(record);
284
- const windowId = await deps.open({
297
+ const { id: windowId, session: windowSession } = await deps.open({
285
298
  id,
286
299
  name,
287
300
  sessionPath: session.path,
@@ -292,6 +305,7 @@ async function startResume(
292
305
  start,
293
306
  toolCallId,
294
307
  ctx,
308
+ placement,
295
309
  });
296
310
  const requestId = await mailTask(deps, windowId, sessionId, task);
297
311
  deps.results.recordStart(
@@ -325,6 +339,7 @@ async function startResume(
325
339
  autoExit: start.autoExit,
326
340
  windowId,
327
341
  windowName: name,
342
+ ...(windowSession === undefined ? {} : { windowSession }),
328
343
  cwd: workspace.cwd,
329
344
  sessionDir: session.dir,
330
345
  workspace,
@@ -346,6 +361,8 @@ async function resume(
346
361
  signal: AbortSignal | undefined,
347
362
  ): Promise<Resumed> {
348
363
  if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
364
+ await requireTmuxVersion(deps.tmux);
365
+ const placement = deps.placement(); // read once, before the exited window is killed or anything is mailed
349
366
  if (request.task.trim() === "") throw new Error("a task is required");
350
367
  const record = newestStarted(deps.results, request.id);
351
368
  await ensureExited(record, deps.tmux);
@@ -360,15 +377,22 @@ async function resume(
360
377
  inFlight.add(record.id);
361
378
  try {
362
379
  const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
363
- return await startResume(deps, { record, session, plan, task: request.task, toolCallId: request.toolCallId }, ctx);
380
+ return await startResume(
381
+ deps,
382
+ { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement },
383
+ ctx,
384
+ );
364
385
  } finally {
365
386
  inFlight.delete(record.id);
366
387
  }
367
388
  }
368
389
 
390
+ /** The session a window opened in, for the start text. */
391
+ const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
392
+
369
393
  /** The one line the command and the tool tell the user. */
370
394
  export function startedLine(resumed: Resumed): string {
371
- return `delegate: resumed ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} in window ${resumed.windowName}${resumed.fallbackNote === "" ? "" : `. ${resumed.fallbackNote}`}${resumed.notices.map((line) => `. ${line}`).join("")}`;
395
+ return `delegate: resumed ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} in window ${resumed.windowName}${inSession(resumed.windowSession)}${resumed.fallbackNote === "" ? "" : `. ${resumed.fallbackNote}`}${resumed.notices.map((line) => `. ${line}`).join("")}`;
372
396
  }
373
397
 
374
398
  /** The `delegation_resume` tool. */
@@ -410,7 +434,7 @@ function resumeTool(deps: ResumeDeps): ToolDef {
410
434
  const { fallbackNote, ...details } = resumed;
411
435
  ctx.ui.notify(startedLine(resumed), "info");
412
436
  return toolResult(
413
- `Resumed delegation ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} (${resumed.model}, thinking ${resumed.thinking}) in window ${resumed.windowName}. It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote === "" ? "" : ` ${fallbackNote}`}${resumed.notices.map((line) => ` ${line}`).join("")}`,
437
+ `Resumed delegation ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} (${resumed.model}, thinking ${resumed.thinking}) in window ${resumed.windowName}${inSession(resumed.windowSession)}. It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote === "" ? "" : ` ${fallbackNote}`}${resumed.notices.map((line) => ` ${line}`).join("")}`,
414
438
  { outcome: "started", ...details },
415
439
  );
416
440
  },
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
 
package/src/tracking.ts CHANGED
@@ -74,6 +74,9 @@ const StartedSchema = Type.Object({
74
74
  resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
75
75
  windowId: Type.String(),
76
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
+ ),
77
80
  cwd: Type.Optional(
78
81
  Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
79
82
  ),
@@ -165,6 +168,8 @@ type View = {
165
168
  snapshot: WorkspaceSnapshot;
166
169
  /** The delegation that resumed this one, if any. */
167
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;
168
173
  };
169
174
 
170
175
  /** The view of a delegation: its state and the fields the text and details share. */
@@ -185,11 +190,12 @@ function view(
185
190
  }
186
191
  }
187
192
  const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
188
- return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs };
193
+ const windowSession = windows === undefined ? undefined : ownWindow(windows, record)?.session;
194
+ return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs, windowSession };
189
195
  }
190
196
 
191
197
  /** The text block for one delegation. */
192
- function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs }: View): string {
198
+ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs, windowSession }: View): string {
193
199
  const [checkout, ...categories] = snapshotLines(snapshot);
194
200
  const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
195
201
  const lines = [
@@ -198,7 +204,7 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
198
204
  ...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
199
205
  ...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
200
206
  ` state: ${stateLine(state, record)}${seen}`,
201
- ` window: ${record.windowName} (${record.windowId})`,
207
+ ` window: ${record.windowName} (${record.windowId})${windowSession === undefined || windowSession === "" ? "" : ` in session ${windowSession}`}`,
202
208
  ` cwd: ${cwdLine(record.cwd)}`,
203
209
  ...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
204
210
  ` checkout now: ${checkout}`,
@@ -211,7 +217,15 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
211
217
  }
212
218
 
213
219
  /** The structured details for one delegation. */
214
- 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> {
215
229
  return {
216
230
  kind: "started",
217
231
  id: record.id,
@@ -224,6 +238,7 @@ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }:
224
238
  ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
225
239
  windowId: record.windowId,
226
240
  windowName: record.windowName,
241
+ ...(windowSession === undefined || windowSession === "" ? {} : { windowSession }),
227
242
  ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
228
243
  ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
229
244
  ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
@@ -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
+ }