pi-squire 0.15.0 → 0.16.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +323 -1
  2. package/README.md +918 -117
  3. package/package.json +2 -1
  4. package/src/agents.ts +57 -36
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +35 -41
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +358 -56
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/poll.ts +1 -0
  16. package/src/render.ts +4 -1
  17. package/src/result-close.ts +37 -0
  18. package/src/result-restore.ts +95 -0
  19. package/src/result-types.ts +29 -1
  20. package/src/results-presentation.ts +47 -0
  21. package/src/results.ts +174 -108
  22. package/src/resume-checks.ts +14 -2
  23. package/src/resume-picker.ts +9 -2
  24. package/src/resume.ts +62 -13
  25. package/src/session-owner.ts +279 -0
  26. package/src/task-replies.ts +247 -0
  27. package/src/task-settlement.ts +81 -0
  28. package/src/task-store.ts +26 -0
  29. package/src/tmux.ts +134 -54
  30. package/src/tool-ceiling.ts +125 -0
  31. package/src/tracking.ts +72 -65
  32. package/src/tree-close.ts +236 -0
  33. package/src/tree-fence.ts +51 -0
  34. package/src/tree-lock.ts +77 -0
  35. package/src/tree-notice.ts +124 -0
  36. package/src/tree-registry.ts +365 -0
  37. package/src/tree-settings.ts +31 -0
  38. package/src/tree-types.ts +32 -0
  39. package/src/tree-view.ts +246 -0
  40. package/src/trust-control.ts +214 -0
  41. package/src/trust-prompt.ts +163 -0
  42. package/src/trust-wire.ts +203 -0
  43. package/src/widget-activity-join.ts +24 -0
  44. package/src/widget-activity-runtime.ts +104 -0
  45. package/src/widget-activity.ts +104 -0
  46. package/src/widget-ancestors.ts +142 -0
  47. package/src/widget-current.ts +41 -0
  48. package/src/widget-data.ts +147 -0
  49. package/src/widget-focus.ts +133 -0
  50. package/src/widget-live-process.ts +31 -0
  51. package/src/widget-live.ts +356 -0
  52. package/src/widget-render.ts +181 -0
  53. package/src/widget-selection.ts +58 -0
  54. package/src/widget-settings.ts +29 -0
  55. package/src/widget-summary.ts +51 -0
  56. package/src/widget-title-file.ts +127 -0
  57. package/src/widget-title.ts +118 -0
  58. package/src/widget.ts +272 -0
  59. package/src/window-placement.ts +30 -5
  60. package/src/workspace.ts +38 -90
@@ -0,0 +1,247 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { settleTask, type TaskEnd, type TaskReply, type TaskState, taskEnd } from "./task-settlement.ts";
3
+ import { readTaskState, taskStatePath, writeTaskState } from "./task-store.ts";
4
+
5
+ type Inbound = {
6
+ envelope?: { id?: unknown; from?: unknown; kind?: unknown; body?: unknown };
7
+ handled?: boolean;
8
+ };
9
+ type ReplyPayload = {
10
+ to: string;
11
+ inReplyTo: string[];
12
+ body: string;
13
+ status: TaskReply["status"];
14
+ envelope?: unknown;
15
+ error?: string;
16
+ };
17
+ export type TaskReplies = { handled(): boolean };
18
+
19
+ const STARTUP_RETRIES = 2;
20
+ const STARTUP_RETRY_MS = 100;
21
+
22
+ class Answers {
23
+ readonly pi: ExtensionAPI;
24
+ readonly parent: () => string | undefined;
25
+ readonly canComplete: () => boolean;
26
+ readonly coordinates: (ctx: ExtensionContext) => boolean;
27
+ readonly terminal: (ctx: ExtensionContext, ids: readonly string[]) => void;
28
+ state: TaskState | undefined;
29
+ path: string | undefined;
30
+ current: ExtensionContext | undefined;
31
+ end: TaskEnd | undefined;
32
+ failed = false;
33
+ unreadable = false;
34
+ retry: ReturnType<typeof setImmediate> | undefined;
35
+ startupRetry: ReturnType<typeof setTimeout> | undefined;
36
+
37
+ constructor(
38
+ pi: ExtensionAPI,
39
+ parent: () => string | undefined,
40
+ canComplete: () => boolean,
41
+ coordinates: (ctx: ExtensionContext) => boolean,
42
+ terminal: (ctx: ExtensionContext, ids: readonly string[]) => void,
43
+ ) {
44
+ this.pi = pi;
45
+ this.parent = parent;
46
+ this.canComplete = canComplete;
47
+ this.coordinates = coordinates;
48
+ this.terminal = terminal;
49
+ }
50
+
51
+ warn(error: unknown, advice?: string): void {
52
+ this.failed = true;
53
+ const retained: string = this.state?.outbox.length
54
+ ? "The answer is retained"
55
+ : "Task reply handling did not complete";
56
+ const recovery: string = advice ?? `${retained}; retry on reload/reopen or the next settled run.`;
57
+ this.current?.ui.notify(
58
+ `delegate: could not handle the task reply: ${String(error)}. ${recovery} Auto-exit is blocked.`,
59
+ "error",
60
+ );
61
+ }
62
+
63
+ save(next: TaskState): void {
64
+ if (this.path === undefined) throw new Error("no task reply state path");
65
+ writeTaskState(this.path, next);
66
+ this.state = next;
67
+ }
68
+
69
+ flush(startupRetries = 0): void {
70
+ if (this.startupRetry !== undefined) clearTimeout(this.startupRetry);
71
+ this.startupRetry = undefined;
72
+ if (this.state === undefined) return;
73
+ try {
74
+ while (this.state.outbox.length > 0) {
75
+ const reply: TaskReply = this.state.outbox[0] as TaskReply;
76
+ const payload: ReplyPayload = {
77
+ to: this.state.parent,
78
+ inReplyTo: reply.ids,
79
+ body: reply.body,
80
+ status: reply.status,
81
+ };
82
+ if (!this.current)
83
+ throw new Error("task reply context is unavailable; reopen the session to publish the retained answer");
84
+ this.terminal(this.current, reply.ids);
85
+ this.pi.events.emit("message:reply", payload);
86
+ if (payload.error !== undefined || payload.envelope === undefined)
87
+ throw new Error(payload.error ?? "no message:reply provider installed");
88
+ // A crash here may resend. Parent correlation suppresses repeated finals.
89
+ const sent: Set<string> = new Set(reply.ids);
90
+ this.save({
91
+ ...this.state,
92
+ requests: this.state.requests.filter((request) => !sent.has(request.id)),
93
+ outbox: this.state.outbox.slice(1),
94
+ });
95
+ }
96
+ this.failed = false;
97
+ } catch (error) {
98
+ this.failed = true;
99
+ if (startupRetries === 0) this.warn(error);
100
+ else this.startupRetry = setTimeout(() => this.flush(startupRetries - 1), STARTUP_RETRY_MS).unref();
101
+ }
102
+ }
103
+
104
+ start(ctx: ExtensionContext): void {
105
+ this.current = ctx;
106
+ this.path = taskStatePath(ctx.sessionManager.getSessionId());
107
+ this.end = undefined;
108
+ this.failed = false;
109
+ this.unreadable = false;
110
+ try {
111
+ this.state = readTaskState(this.path);
112
+ } catch (error) {
113
+ this.state = undefined;
114
+ this.unreadable = true;
115
+ this.warn(error);
116
+ }
117
+ if (!this.state?.outbox.length) return;
118
+ // Async session_start handlers between squire and mail can delay provider
119
+ // initialization past this turn. Retry actual replies briefly, not probes.
120
+ this.retry = setImmediate(() => {
121
+ this.retry = undefined;
122
+ this.flush(STARTUP_RETRIES);
123
+ });
124
+ }
125
+
126
+ stop(): void {
127
+ if (this.retry !== undefined) clearImmediate(this.retry);
128
+ this.retry = undefined;
129
+ if (this.startupRetry !== undefined) clearTimeout(this.startupRetry);
130
+ this.startupRetry = undefined;
131
+ this.current = undefined;
132
+ }
133
+
134
+ takeover(payload: Inbound): void {
135
+ const envelope: Inbound["envelope"] = payload?.envelope;
136
+ const owner: string | undefined = this.state?.parent ?? this.parent();
137
+ if (
138
+ this.unreadable ||
139
+ this.current === undefined ||
140
+ payload.handled ||
141
+ envelope?.kind !== "request" ||
142
+ !owner ||
143
+ envelope.from !== owner ||
144
+ typeof envelope.id !== "string" ||
145
+ envelope.id === ""
146
+ )
147
+ return;
148
+ try {
149
+ // Role lookup can fail on invalid configuration. Wording must not
150
+ // claim a request without prompting it; launch admission stays strict.
151
+ let coordinates = false;
152
+ try {
153
+ coordinates = this.coordinates(this.current);
154
+ } catch {
155
+ /* Keep the original leaf wording. */
156
+ }
157
+ const ending: string = coordinates
158
+ ? "Your final task report goes back to it automatically after your child tasks finish and you process their outcomes."
159
+ : "Your final message this turn goes back to it automatically.";
160
+ const text: string = `[delegate] Your task, from the session that started you (${owner}). ${ending}\n\n${String(envelope.body ?? "")}`;
161
+ const base: TaskState = this.state ?? { parent: owner, requests: [], outbox: [] };
162
+ if (base.requests.some((request) => request.id === envelope.id)) {
163
+ payload.handled = true;
164
+ return;
165
+ }
166
+ this.save({ ...base, requests: [...base.requests, { id: envelope.id, held: false, seen: true }] });
167
+ payload.handled = true;
168
+ this.prompt(text, envelope.id);
169
+ } catch (error) {
170
+ this.warn(
171
+ error,
172
+ "Task ownership was not recorded and squire did not start the task; fix the storage error and send it again.",
173
+ );
174
+ }
175
+ }
176
+
177
+ prompt(text: string, id: string): void {
178
+ const rejected = (error: unknown): void => {
179
+ this.unseen(id);
180
+ this.current?.ui.notify(`delegate: could not start the task: ${String(error)}`, "error");
181
+ };
182
+ try {
183
+ Promise.resolve(this.pi.sendUserMessage(text, { deliverAs: "steer" })).catch(rejected);
184
+ } catch (error) {
185
+ rejected(error);
186
+ }
187
+ }
188
+
189
+ unseen(id: unknown): void {
190
+ if (this.state === undefined) return;
191
+ try {
192
+ this.save({
193
+ ...this.state,
194
+ requests: this.state.requests.map((request) => (request.id === id ? { ...request, seen: false } : request)),
195
+ });
196
+ } catch (error) {
197
+ this.warn(error);
198
+ }
199
+ }
200
+
201
+ settle(): void {
202
+ if (this.state === undefined || this.end === undefined) return;
203
+ // A stop must still mark held requests at intermediate settles. A
204
+ // failed intermediate run is otherwise discarded, so recovery's final
205
+ // run (not an earlier failure) determines the answer.
206
+ if (!this.end.stopped && !this.canComplete()) {
207
+ this.end = undefined;
208
+ return;
209
+ }
210
+ try {
211
+ this.save(settleTask(this.state, this.end));
212
+ this.end = undefined;
213
+ this.flush();
214
+ } catch (error) {
215
+ this.warn(error);
216
+ }
217
+ }
218
+
219
+ handled(): boolean {
220
+ return !this.failed && this.canComplete() && (this.state === undefined || this.state.requests.length === 0);
221
+ }
222
+ }
223
+
224
+ /** Restore owed answers even when a session is reopened without launch env. */
225
+ export function registerTaskReplies(
226
+ pi: ExtensionAPI,
227
+ parent: string | (() => string | undefined) | undefined = undefined,
228
+ canComplete: () => boolean = () => true,
229
+ coordinates: (ctx: ExtensionContext) => boolean = () => false,
230
+ terminal: (ctx: ExtensionContext, ids: readonly string[]) => void = () => {},
231
+ ): TaskReplies {
232
+ const answers: Answers = new Answers(
233
+ pi,
234
+ typeof parent === "function" ? parent : (): string | undefined => parent,
235
+ canComplete,
236
+ coordinates,
237
+ terminal,
238
+ );
239
+ pi.on("session_start", (_event, ctx) => answers.start(ctx));
240
+ pi.on("session_shutdown", () => answers.stop());
241
+ pi.events.on("message:inbound", (data) => answers.takeover(data as Inbound));
242
+ pi.on("agent_end", (event, ctx) => {
243
+ answers.end = taskEnd(event.messages, ctx.signal?.aborted === true);
244
+ });
245
+ pi.on("agent_settled", () => answers.settle());
246
+ return answers;
247
+ }
@@ -0,0 +1,81 @@
1
+ // Request settlement belongs to squire; asks remain the mail provider's work.
2
+ // Wording and held-through-failure semantics follow pi-session-mail v0.5.0.
3
+ export const NO_ANSWER = "(The session settled with no answer text.)";
4
+ export const TOOK_OVER =
5
+ "(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
6
+ export const UNSEEN =
7
+ "(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
8
+
9
+ export type TaskRequest = { id: string; held: boolean; seen: boolean };
10
+ export type TaskReply = { ids: string[]; body: string; status: "done" | "failed" | "stopped" };
11
+ export type TaskState = { parent: string; requests: TaskRequest[]; outbox: TaskReply[] };
12
+ type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
13
+ export type TaskEnd = { stopped: boolean; error?: string; answer?: string };
14
+
15
+ function textOf(message: Message): string | undefined {
16
+ const content = message.content;
17
+ let text = "";
18
+ if (typeof content === "string") text = content;
19
+ else if (Array.isArray(content))
20
+ text = content
21
+ .filter((part): part is { type: "text"; text: string } => part?.type === "text" && typeof part.text === "string")
22
+ .map((part) => part.text)
23
+ .join("\n");
24
+ return text.trim() === "" ? undefined : text;
25
+ }
26
+
27
+ export function taskEnd(messages: readonly unknown[], aborted: boolean): TaskEnd {
28
+ const assistants: Message[] = (messages as Message[]).filter((message) => message?.role === "assistant");
29
+ const last: Message | undefined = assistants.at(-1);
30
+ const stopped: boolean = aborted || last?.stopReason === "aborted";
31
+ let error: string | undefined;
32
+ if (!stopped && last?.stopReason === "error")
33
+ error =
34
+ typeof last.errorMessage === "string" && last.errorMessage.trim() !== ""
35
+ ? last.errorMessage.trim().replace(/\.+$/, "")
36
+ : "unknown error";
37
+ const answer: string | undefined =
38
+ stopped || error !== undefined
39
+ ? assistants.map(textOf).findLast((text) => text !== undefined)
40
+ : last && textOf(last);
41
+ return { stopped, error, answer };
42
+ }
43
+
44
+ /** Freeze the final body before transport, batching at the provider's 50-id limit. */
45
+ export function settleTask(state: TaskState, end: TaskEnd): TaskState {
46
+ const requests: TaskRequest[] = [];
47
+ const groups: Map<string, TaskReply> = new Map();
48
+ const queued: Set<string> = new Set(state.outbox.flatMap((reply) => reply.ids));
49
+ for (const request of state.requests) {
50
+ if (queued.has(request.id)) {
51
+ requests.push(request);
52
+ continue;
53
+ }
54
+ if (request.seen && (end.stopped || (request.held && end.error !== undefined))) {
55
+ requests.push({ ...request, held: true });
56
+ continue;
57
+ }
58
+ const reply: TaskReply = describe(request, end);
59
+ const key: string = JSON.stringify([reply.status, reply.body]);
60
+ const group: TaskReply = groups.get(key) ?? { ...reply, ids: [] };
61
+ group.ids.push(request.id);
62
+ groups.set(key, group);
63
+ requests.push(request);
64
+ }
65
+ const outbox: TaskReply[] = [...state.outbox];
66
+ for (const group of groups.values())
67
+ for (let start = 0; start < group.ids.length; start += 50)
68
+ outbox.push({ ...group, ids: group.ids.slice(start, start + 50) });
69
+ return { ...state, requests, outbox };
70
+ }
71
+
72
+ function describe(request: TaskRequest, end: TaskEnd): TaskReply {
73
+ const ids: string[] = [request.id];
74
+ if (!request.seen) return { ids, body: UNSEEN, status: "failed" };
75
+ if (request.held) return { ids, body: `${TOOK_OVER}\n\n${end.answer ?? NO_ANSWER}`, status: "done" };
76
+ if (end.error !== undefined) {
77
+ const opening: string = `(The run ended on an error before it finished: ${end.error}. The text that follows, if any, is partial.)`;
78
+ return { ids, body: opening + (end.answer === undefined ? "" : `\n\n${end.answer}`), status: "failed" };
79
+ }
80
+ return { ids, body: end.answer ?? NO_ANSWER, status: "done" };
81
+ }
@@ -0,0 +1,26 @@
1
+ // Independent of Pi's transcript buffering: a task can arrive before its first
2
+ // assistant message. Atomic replacement preserves either the old or new outbox.
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import type { TaskState } from "./task-settlement.ts";
7
+
8
+ export function taskStatePath(session: string): string {
9
+ return join(getAgentDir(), "squire", "task-replies", `${encodeURIComponent(session)}.json`);
10
+ }
11
+
12
+ export function readTaskState(path: string): TaskState | undefined {
13
+ try {
14
+ return JSON.parse(readFileSync(path, "utf8")) as TaskState;
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ export function writeTaskState(path: string, state: TaskState): void {
22
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
23
+ const temporary: string = `${path}.${process.pid}.tmp`;
24
+ writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 });
25
+ renameSync(temporary, path);
26
+ }
package/src/tmux.ts CHANGED
@@ -39,13 +39,23 @@ export async function requireTmuxVersion(tmux: { version(): Promise<TmuxVersion>
39
39
  }
40
40
  }
41
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`;
42
+ /** Pick a managed candidate before any free name, in numeric candidate order. */
43
+ export function companionCandidate(base: string, sessions: ReadonlyMap<string, boolean>): string {
44
+ const candidates = [...sessions.keys()]
45
+ .filter(
46
+ (name) =>
47
+ name === base || (name.startsWith(`${base}-`) && /^[2-9]$|^[1-9][0-9]+$/.test(name.slice(base.length + 1))),
48
+ )
49
+ .sort((a, b) => candidateNumber(base, a) - candidateNumber(base, b));
50
+ const managed = candidates.find((name) => sessions.get(name) === true);
51
+ if (managed !== undefined) return managed;
52
+ let number = 1;
53
+ while (sessions.has(number === 1 ? base : `${base}-${number}`)) number++;
54
+ return number === 1 ? base : `${base}-${number}`;
55
+ }
56
+
57
+ function candidateNumber(base: string, name: string): number {
58
+ return name === base ? 1 : Number(name.slice(base.length + 1));
49
59
  }
50
60
 
51
61
  /**
@@ -65,6 +75,17 @@ export class WindowOpenError extends Error {
65
75
  /** The session the parent's pane is in: its id (`$N`) and name. */
66
76
  export type OwnSession = { id: string; name: string };
67
77
 
78
+ /** A positively inspected pane on one server incarnation; names are not routing identity. */
79
+ export type PaneLocator = {
80
+ socket: string;
81
+ serverPid: number;
82
+ serverStarted: string;
83
+ paneId: string;
84
+ windowId: string;
85
+ windowName: string;
86
+ exited: boolean;
87
+ };
88
+
68
89
  export interface TmuxClient {
69
90
  /** Whether this process runs in a tmux pane. */
70
91
  insideTmux(): boolean;
@@ -73,9 +94,13 @@ export interface TmuxClient {
73
94
  /**
74
95
  * Open a detached window and return its id (for example `@3`) and session.
75
96
  * `current` opens it in the parent's session; `companion` in
76
- * `<parent's session>-squire`, created when it does not exist.
97
+ * the root project's managed companion, created when it does not exist.
77
98
  */
78
- openWindow(spec: WindowSpec, placement: WindowPlacement): Promise<OpenedWindow>;
99
+ openWindow(spec: WindowSpec, placement: WindowPlacement, companion: string): Promise<OpenedWindow>;
100
+ /** This process's pane window, for recovering an ambiguous launch at child startup. */
101
+ currentWindow?(): Promise<{ id: string; name: string } | undefined>;
102
+ /** Inspect exactly a pane on this client's server; omitted pane means this process's TMUX_PANE. */
103
+ inspectPane?(paneId?: string): Promise<PaneLocator | undefined>;
79
104
  /** Every window on the server, in one tmux call. */
80
105
  listWindows(): Promise<TmuxWindow[]>;
81
106
  /** Kill a window. */
@@ -109,21 +134,21 @@ function runTmux(args: string[], timeout: number): Promise<string> {
109
134
  }
110
135
 
111
136
  type Run = (args: string[]) => Promise<string>;
112
- type Parent = { id: string; name: string; companion: boolean };
137
+ type Parent = OwnSession;
113
138
 
114
139
  /** The user option that marks a session pi-squire made for delegate windows. */
115
140
  const COMPANION_OPTION = "@squire-companion";
116
141
 
117
- /** The id, name and companion mark of the session the pane in `TMUX_PANE` belongs to, when known. */
142
+ /** The id and name of the session the pane in `TMUX_PANE` belongs to, when known. */
118
143
  async function parentSession(run: Run): Promise<Parent | undefined> {
119
144
  const pane = process.env.TMUX_PANE;
120
145
  if (pane === undefined || pane === "") return undefined;
121
146
  // Only the line's terminator goes: trailing spaces are legal in a session name.
122
- const format = `#{session_id}\t#{${COMPANION_OPTION}}\t#{session_name}`;
147
+ const format = "#{session_id}\t#{session_name}";
123
148
  const out = (await run(["display-message", "-p", "-t", pane, "-F", format])).replace(/\r?\n$/, "");
124
- // The name comes last, so it is whatever follows the second tab.
125
- const [id, marker, ...name] = out.split("\t");
126
- return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
149
+ // The name comes last, so any tabs within it stay in the name.
150
+ const [id, ...name] = out.split("\t");
151
+ return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t") };
127
152
  }
128
153
 
129
154
  const envArgs = (spec: WindowSpec): string[] =>
@@ -145,46 +170,62 @@ async function create(run: Run, args: string[]): Promise<string> {
145
170
  const failedWith = (err: unknown, text: string): boolean =>
146
171
  err instanceof WindowOpenError && err.message.includes(text);
147
172
 
148
- /**
149
- * Open a window in the companion session: `new-window`, then on a missing
150
- * session one `new-session` that makes the delegate its first window and sets
151
- * `detach-on-destroy off` and the companion mark on that session only, then `new-window` again when
152
- * another launch created the session first.
153
- */
154
- async function openCompanion(run: Run, spec: WindowSpec, session: string): Promise<string> {
155
- const newWindow = [
156
- "new-window",
157
- "-d",
158
- "-P",
159
- "-F",
160
- "#{window_id}",
161
- "-n",
162
- spec.name,
163
- "-c",
164
- spec.cwd,
165
- ...envArgs(spec),
166
- "-t",
167
- `=${session}:`,
168
- "--",
169
- ...spec.argv,
170
- ];
173
+ /** Read ownership before choosing a name; exact names are compared in JavaScript. */
174
+ async function companionSessions(
175
+ run: Run,
176
+ certainty: "not-opened" | "unknown",
177
+ ): Promise<Map<string, { id: string; managed: boolean }>> {
178
+ let out: string;
171
179
  try {
172
- return await create(run, newWindow);
180
+ out = await run(["list-sessions", "-F", `#{session_id}\t#{${COMPANION_OPTION}}\t#{session_name}`]);
173
181
  } catch (err) {
174
- // tmux resolves the target before it creates anything, so nothing was dispatched that could create a window.
175
- if (!failedWith(err, "can't find session")) throw err;
176
- (err as WindowOpenError).certainty = "not-opened";
182
+ throw new WindowOpenError((err as Error).message, certainty);
177
183
  }
178
- const newSession = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
179
- newSession.push("-P", "-F", "#{window_id}", "--", ...spec.argv);
180
- newSession.push(";", "set-option", "-t", `=${session}:`, "detach-on-destroy", "off");
181
- newSession.push(";", "set-option", "-t", `=${session}:`, COMPANION_OPTION, "1");
182
- try {
183
- return await create(run, newSession);
184
- } catch (err) {
185
- if (!failedWith(err, "duplicate session")) throw err;
184
+ const sessions = new Map<string, { id: string; managed: boolean }>();
185
+ for (const line of out.replace(/\r?\n$/, "").split("\n")) {
186
+ const [id, marker, ...name] = line.split("\t");
187
+ if (id !== "") sessions.set(name.join("\t"), { id, managed: marker === "1" });
188
+ }
189
+ return sessions;
190
+ }
191
+
192
+ /**
193
+ * A new-session and its options run in one synchronous tmux command queue.
194
+ * A duplicate aborts that queue before options run. Re-read ownership after
195
+ * that race, never treating a duplicate as permission to adopt the winner.
196
+ * Retry only proven duplicate-name or missing-target failures, not ambiguous task launches.
197
+ */
198
+ async function openCompanion(run: Run, spec: WindowSpec, base: string): Promise<OpenedWindow> {
199
+ let certainty: "not-opened" | "unknown" = "not-opened";
200
+ while (true) {
201
+ const sessions = await companionSessions(run, certainty);
202
+ const session = companionCandidate(base, new Map([...sessions].map(([name, value]) => [name, value.managed])));
203
+ const owned = sessions.get(session);
204
+ if (owned !== undefined) {
205
+ const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
206
+ // Target the verified session's id, so a replacement with the same name cannot be adopted.
207
+ args.push("-t", owned.id, "--", ...spec.argv);
208
+ try {
209
+ return { id: await create(run, args), session };
210
+ } catch (err) {
211
+ if (!failedWith(err, "can't find session")) throw err;
212
+ // Its last window may have exited since the lookup. Target resolution
213
+ // failed before tmux could open anything, so select placement again.
214
+ certainty = "not-opened";
215
+ continue;
216
+ }
217
+ }
218
+ const args = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
219
+ args.push("-P", "-F", "#{window_id}", "--", ...spec.argv);
220
+ args.push(";", "set-option", "-t", `=${session}:`, COMPANION_OPTION, "1");
221
+ args.push(";", "set-option", "-t", `=${session}:`, "detach-on-destroy", "off");
222
+ certainty = "unknown";
223
+ try {
224
+ return { id: await create(run, args), session };
225
+ } catch (err) {
226
+ if (!failedWith(err, "duplicate session")) throw err;
227
+ }
186
228
  }
187
- return create(run, newWindow);
188
229
  }
189
230
 
190
231
  /** Open a window in the parent's own session, as delegates always did. */
@@ -225,6 +266,32 @@ function navigation(run: Run): Pick<TmuxClient, "ownSession" | "clientFor" | "sw
225
266
  };
226
267
  }
227
268
 
269
+ async function inspectPane(run: Run, paneId = process.env.TMUX_PANE): Promise<PaneLocator | undefined> {
270
+ if (!paneId || !/^%[0-9]+$/.test(paneId)) return undefined;
271
+ const format = "#{socket_path}\t#{pid}\t#{start_time}\t#{pane_id}\t#{window_id}\t#{pane_dead}\t#{window_name}";
272
+ const out = (await run(["display-message", "-p", "-t", paneId, "-F", format])).replace(/\r?\n$/, "");
273
+ const [socket, pid, started, pane, window, dead, ...name] = out.split("\t");
274
+ if (
275
+ !socket ||
276
+ !/^\d+$/.test(pid) ||
277
+ !/^\d+$/.test(started) ||
278
+ pane !== paneId ||
279
+ !/^@[0-9]+$/.test(window) ||
280
+ !["0", "1"].includes(dead) ||
281
+ !name.length
282
+ )
283
+ return undefined;
284
+ return {
285
+ socket,
286
+ serverPid: Number(pid),
287
+ serverStarted: started,
288
+ paneId: pane,
289
+ windowId: window,
290
+ windowName: name.join("\t"),
291
+ exited: dead === "1",
292
+ };
293
+ }
294
+
228
295
  /**
229
296
  * The real client. A window is opened relative to the session of the pane in
230
297
  * `TMUX_PANE`, not whatever session a client happens to have attached, so it
@@ -247,7 +314,7 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
247
314
  return cachedVersion;
248
315
  },
249
316
 
250
- async openWindow(spec, placement) {
317
+ async openWindow(spec, placement, companion) {
251
318
  let parent: Parent | undefined;
252
319
  try {
253
320
  parent = await parentSession(run);
@@ -260,8 +327,21 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
260
327
  if (known === undefined) {
261
328
  throw new WindowOpenError("could not tell which tmux session this pane is in (TMUX_PANE)", "not-opened");
262
329
  }
263
- const session = companionName(known, parent?.companion === true);
264
- return { id: await openCompanion(run, spec, session), session };
330
+ return openCompanion(run, spec, companion);
331
+ },
332
+
333
+ inspectPane: (paneId) => inspectPane(run, paneId),
334
+
335
+ async currentWindow() {
336
+ const pane = process.env.TMUX_PANE;
337
+ if (!pane) return undefined;
338
+ const out = (await run(["display-message", "-p", "-t", pane, "-F", "#{window_id}\t#{window_name}"])).replace(
339
+ /\r?\n$/,
340
+ "",
341
+ );
342
+ const [id, ...name] = out.split("\t");
343
+ if (!/^@[0-9]+$/.test(id) || !name.length) return undefined;
344
+ return { id, name: name.join("\t") };
265
345
  },
266
346
 
267
347
  async listWindows() {