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.
- package/CHANGELOG.md +323 -1
- package/README.md +918 -117
- package/package.json +2 -1
- package/src/agents.ts +57 -36
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +35 -41
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +358 -56
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +174 -108
- package/src/resume-checks.ts +14 -2
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +62 -13
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
125
|
-
const [id,
|
|
126
|
-
return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t")
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
180
|
+
out = await run(["list-sessions", "-F", `#{session_id}\t#{${COMPANION_OPTION}}\t#{session_name}`]);
|
|
173
181
|
} catch (err) {
|
|
174
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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() {
|