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 +19 -1
- package/README.md +38 -3
- package/package.json +1 -1
- package/src/index.ts +47 -13
- package/src/render.ts +2 -2
- package/src/resume.ts +33 -9
- package/src/tmux.ts +161 -37
- package/src/tracking.ts +19 -4
- package/src/window-placement.ts +31 -0
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.
|
|
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.
|
|
20
|
-
first version with `new-
|
|
21
|
-
|
|
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
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<
|
|
661
|
-
const {
|
|
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
|
|
723
|
+
let opened: OpenedWindow;
|
|
703
724
|
try {
|
|
704
|
-
|
|
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
|
|
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 } =
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
+
}
|