@zswarm/core 0.2.8 → 0.3.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/dist/host/cli.js +3 -3
- package/dist/host/crew-windows.d.ts +11 -15
- package/dist/host/crew-windows.js +39 -46
- package/dist/host/crew.d.ts +10 -6
- package/dist/host/crew.js +100 -61
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/ops/broadcast.d.ts +1 -1
- package/dist/ops/broadcast.js +4 -3
- package/dist/ops/dispatch.js +3 -3
- package/dist/ops/guards.d.ts +10 -2
- package/dist/ops/guards.js +21 -4
- package/dist/ops/restart.js +3 -2
- package/dist/zellij/client.d.ts +1 -0
- package/dist/zellij/client.js +4 -1
- package/dist/zellij/session.d.ts +6 -0
- package/dist/zellij/session.js +9 -0
- package/package.json +1 -1
package/dist/host/cli.js
CHANGED
|
@@ -244,11 +244,11 @@ const COMMANDS = {
|
|
|
244
244
|
crew: {
|
|
245
245
|
usage: [
|
|
246
246
|
"zswarm crew up <session> --layout L.kdl [--config C.kdl] [--env-file F] [--first-pane NAME] [--run-as USER]",
|
|
247
|
-
"
|
|
248
|
-
" on Windows: Scheduled
|
|
247
|
+
" start the session from its layout now and at boot/logon; nothing restarts",
|
|
248
|
+
" it by itself (on Windows: a Scheduled Task under \\zswarm\\ + an attached client window)",
|
|
249
249
|
"zswarm crew status <session> [--run-as USER] services, session, panes",
|
|
250
250
|
"zswarm crew down <session> [--run-as USER] remove the services and kill the session",
|
|
251
|
-
"zswarm crew start
|
|
251
|
+
"zswarm crew start <session> what the service runs; also brings a crew back by hand",
|
|
252
252
|
"zswarm crew client <session> what the minimized console window runs (Windows)",
|
|
253
253
|
"--run-as installs for another account and is Linux/macOS only; a Windows crew runs as the signed-in user",
|
|
254
254
|
].join("\n"),
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A background Zellij session started with `zellij attach --create-background`
|
|
5
5
|
* on Windows renders empty pane grids and drops pasted input, because nothing
|
|
6
|
-
* is attached in a real console. The fix is
|
|
6
|
+
* is attached in a real console. The fix is one Scheduled Task under
|
|
7
7
|
* `\zswarm\` plus a minimized attached-client console window:
|
|
8
8
|
*
|
|
9
|
-
* zswarm-crew-<session> starts the session (interactive, highest)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* zswarm-crew-<session> starts the session (interactive, highest) at
|
|
10
|
+
* logon and on `crew up`
|
|
11
|
+
*
|
|
12
|
+
* There is no watchdog: a crew that dies stays down until someone restarts it
|
|
13
|
+
* (`zswarm crew start <session>`). Older versions registered a
|
|
14
|
+
* `zswarm-crew-<session>-watch` task; `crew up` and `crew down` remove it.
|
|
14
15
|
*
|
|
15
16
|
* This module is pure PowerShell text (unit-tested by text) plus thin async
|
|
16
17
|
* wrappers. It deliberately mirrors `buildServeTaskScript` in
|
|
@@ -20,8 +21,6 @@
|
|
|
20
21
|
*/
|
|
21
22
|
/** Scheduled-task folder every crew task lives in. */
|
|
22
23
|
export declare const CREW_TASK_FOLDER = "\\zswarm\\";
|
|
23
|
-
/** Watchdog cadence, matching the Linux/macOS `crew-<s>-watch` timer. */
|
|
24
|
-
export declare const CREW_WATCH_MINUTES = 2;
|
|
25
24
|
export type PowerShellResult = {
|
|
26
25
|
code: number;
|
|
27
26
|
stdout: string;
|
|
@@ -31,14 +30,15 @@ export type PowerShellExec = (script: string, options: {
|
|
|
31
30
|
timeoutMs: number;
|
|
32
31
|
signal?: AbortSignal;
|
|
33
32
|
}) => Promise<PowerShellResult>;
|
|
33
|
+
/** `legacyWatch`: the watchdog task older versions registered, removed on sight. */
|
|
34
34
|
export type CrewTaskNames = {
|
|
35
35
|
folder: string;
|
|
36
36
|
start: string;
|
|
37
|
-
|
|
37
|
+
legacyWatch: string;
|
|
38
38
|
};
|
|
39
39
|
export type CrewTaskState = {
|
|
40
|
-
/**
|
|
41
|
-
name: "start"
|
|
40
|
+
/** The crew's task. */
|
|
41
|
+
name: "start";
|
|
42
42
|
/** Task name only, e.g. `zswarm-crew-crew`. */
|
|
43
43
|
task: string;
|
|
44
44
|
/** `\zswarm\zswarm-crew-crew`. */
|
|
@@ -74,8 +74,6 @@ export declare function crewTaskCommand(input: {
|
|
|
74
74
|
export declare function buildCrewTaskScript(action: "register" | "inspect" | "start" | "unregister", params: {
|
|
75
75
|
session: string;
|
|
76
76
|
startCommand?: string;
|
|
77
|
-
watchCommand?: string;
|
|
78
|
-
intervalMinutes?: number;
|
|
79
77
|
}): string;
|
|
80
78
|
/**
|
|
81
79
|
* The command line the client window runs. The marker is both the window title
|
|
@@ -111,8 +109,6 @@ export declare function inspectCrewTasks(input: {
|
|
|
111
109
|
export declare function installCrewTasks(input: {
|
|
112
110
|
session: string;
|
|
113
111
|
startCommand: string;
|
|
114
|
-
watchCommand: string;
|
|
115
|
-
intervalMinutes?: number;
|
|
116
112
|
runPowerShell: PowerShellExec;
|
|
117
113
|
timeoutMs?: number;
|
|
118
114
|
signal?: AbortSignal;
|
|
@@ -6,14 +6,15 @@ import { encodePowerShellCommand } from "../zellij/ipc.js";
|
|
|
6
6
|
*
|
|
7
7
|
* A background Zellij session started with `zellij attach --create-background`
|
|
8
8
|
* on Windows renders empty pane grids and drops pasted input, because nothing
|
|
9
|
-
* is attached in a real console. The fix is
|
|
9
|
+
* is attached in a real console. The fix is one Scheduled Task under
|
|
10
10
|
* `\zswarm\` plus a minimized attached-client console window:
|
|
11
11
|
*
|
|
12
|
-
* zswarm-crew-<session> starts the session (interactive, highest)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* zswarm-crew-<session> starts the session (interactive, highest) at
|
|
13
|
+
* logon and on `crew up`
|
|
14
|
+
*
|
|
15
|
+
* There is no watchdog: a crew that dies stays down until someone restarts it
|
|
16
|
+
* (`zswarm crew start <session>`). Older versions registered a
|
|
17
|
+
* `zswarm-crew-<session>-watch` task; `crew up` and `crew down` remove it.
|
|
17
18
|
*
|
|
18
19
|
* This module is pure PowerShell text (unit-tested by text) plus thin async
|
|
19
20
|
* wrappers. It deliberately mirrors `buildServeTaskScript` in
|
|
@@ -23,11 +24,9 @@ import { encodePowerShellCommand } from "../zellij/ipc.js";
|
|
|
23
24
|
*/
|
|
24
25
|
/** Scheduled-task folder every crew task lives in. */
|
|
25
26
|
export const CREW_TASK_FOLDER = "\\zswarm\\";
|
|
26
|
-
/** Watchdog cadence, matching the Linux/macOS `crew-<s>-watch` timer. */
|
|
27
|
-
export const CREW_WATCH_MINUTES = 2;
|
|
28
27
|
/** Same shape the `zswarm crew` session validator uses; duplicated to avoid a cycle. */
|
|
29
28
|
const CREW_SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
30
|
-
/** Overall helper deadline when `timeoutMs` is omitted.
|
|
29
|
+
/** Overall helper deadline when `timeoutMs` is omitted. */
|
|
31
30
|
const DEFAULT_CREW_TIMEOUT_MS = 20_000;
|
|
32
31
|
function checkSession(session) {
|
|
33
32
|
const text = session?.trim() ?? "";
|
|
@@ -39,7 +38,7 @@ function checkSession(session) {
|
|
|
39
38
|
/** `zswarm-crew-<session>`: task names, and the client window's title marker. */
|
|
40
39
|
export function crewTaskNames(session) {
|
|
41
40
|
const start = `zswarm-crew-${checkSession(session)}`;
|
|
42
|
-
return { folder: CREW_TASK_FOLDER, start,
|
|
41
|
+
return { folder: CREW_TASK_FOLDER, start, legacyWatch: `${start}-watch` };
|
|
43
42
|
}
|
|
44
43
|
/** The token that proves a console window is this crew's client: its title and
|
|
45
44
|
* the string in its launcher's command line. */
|
|
@@ -166,13 +165,11 @@ export function buildCrewTaskScript(action, params) {
|
|
|
166
165
|
// Base64 UTF-8 round-trip, exactly like serve's register: neither the
|
|
167
166
|
// command nor its quoting ever appears in the script text.
|
|
168
167
|
const startB64 = Buffer.from(params.startCommand ?? "", "utf8").toString("base64");
|
|
169
|
-
const watchB64 = Buffer.from(params.watchCommand ?? "", "utf8").toString("base64");
|
|
170
|
-
const interval = Math.max(1, Math.floor(params.intervalMinutes ?? CREW_WATCH_MINUTES));
|
|
171
168
|
return [
|
|
172
169
|
PS_PREAMBLE,
|
|
173
170
|
`$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${startB64}'))`,
|
|
174
171
|
`$startName = '${names.start}'`,
|
|
175
|
-
`$watchName = '${names.
|
|
172
|
+
`$watchName = '${names.legacyWatch}'`,
|
|
176
173
|
"$identity = Get-ZswarmIdentity",
|
|
177
174
|
"foreach ($name in @($startName, $watchName)) {",
|
|
178
175
|
" try {",
|
|
@@ -187,15 +184,23 @@ export function buildCrewTaskScript(action, params) {
|
|
|
187
184
|
// and its Zellij console window.
|
|
188
185
|
"$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest",
|
|
189
186
|
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable",
|
|
190
|
-
|
|
187
|
+
// A console program started by an Interactive task gets a visible console
|
|
188
|
+
// window. Run the action under a headless console host instead (the
|
|
189
|
+
// client window the crew opens on purpose is a separate Start-Process
|
|
190
|
+
// and keeps its own).
|
|
191
|
+
"$startAction = New-ScheduledTaskAction -Execute 'conhost.exe' -Argument ('--headless cmd.exe /c ' + $cmd)",
|
|
191
192
|
"$startTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId",
|
|
192
193
|
`Register-ScheduledTask -TaskName $startName -TaskPath '${CREW_TASK_FOLDER}' -Action $startAction -Principal $principal -Trigger $startTrigger -Settings $settings -Force | Out-Null`,
|
|
193
|
-
|
|
194
|
-
"$
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`
|
|
194
|
+
// The watchdog task of older versions goes: the crew no longer has one.
|
|
195
|
+
"$removedWatch = $false",
|
|
196
|
+
"try {",
|
|
197
|
+
" [void](Get-ZswarmTask $watchName)",
|
|
198
|
+
` try { Stop-ScheduledTask -TaskName $watchName -TaskPath '${CREW_TASK_FOLDER}' } catch { if (-not (Test-ZswarmMissing $_)) { throw } }`,
|
|
199
|
+
` Unregister-ScheduledTask -TaskName $watchName -TaskPath '${CREW_TASK_FOLDER}' -Confirm:$false`,
|
|
200
|
+
" $removedWatch = $true",
|
|
201
|
+
"} catch {",
|
|
202
|
+
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
203
|
+
"}",
|
|
199
204
|
"Write-ZswarmJson @{",
|
|
200
205
|
" registered = $true",
|
|
201
206
|
" userId = $userId",
|
|
@@ -204,7 +209,7 @@ export function buildCrewTaskScript(action, params) {
|
|
|
204
209
|
" runLevel = 'Highest'",
|
|
205
210
|
` folder = '${CREW_TASK_FOLDER}'`,
|
|
206
211
|
" startTask = $startName",
|
|
207
|
-
"
|
|
212
|
+
" removedLegacyWatch = $removedWatch",
|
|
208
213
|
"}",
|
|
209
214
|
].join("\n");
|
|
210
215
|
}
|
|
@@ -213,10 +218,9 @@ export function buildCrewTaskScript(action, params) {
|
|
|
213
218
|
return [
|
|
214
219
|
PS_PREAMBLE,
|
|
215
220
|
`$startName = '${names.start}'`,
|
|
216
|
-
`$watchName = '${names.watch}'`,
|
|
217
221
|
`$marker = '${marker}'`,
|
|
218
222
|
"$tasks = @{}",
|
|
219
|
-
"foreach ($pair in @(@{ key = 'start'; name = $startName }
|
|
223
|
+
"foreach ($pair in @(@{ key = 'start'; name = $startName })) {",
|
|
220
224
|
" try {",
|
|
221
225
|
" $task = Get-ZswarmTask $pair.name",
|
|
222
226
|
" } catch {",
|
|
@@ -246,7 +250,6 @@ export function buildCrewTaskScript(action, params) {
|
|
|
246
250
|
psClientPidLine(marker),
|
|
247
251
|
"Write-ZswarmJson @{",
|
|
248
252
|
" start = $tasks['start']",
|
|
249
|
-
" watch = $tasks['watch']",
|
|
250
253
|
" clientPresent = ($clientPids.Count -gt 0)",
|
|
251
254
|
" clientPids = @($clientPids)",
|
|
252
255
|
" clientCount = $clientPids.Count",
|
|
@@ -257,8 +260,7 @@ export function buildCrewTaskScript(action, params) {
|
|
|
257
260
|
return [
|
|
258
261
|
PS_PREAMBLE,
|
|
259
262
|
`$startName = '${names.start}'`,
|
|
260
|
-
|
|
261
|
-
"foreach ($name in @($startName, $watchName)) {",
|
|
263
|
+
"foreach ($name in @($startName)) {",
|
|
262
264
|
" try {",
|
|
263
265
|
" $task = Get-ZswarmTask $name",
|
|
264
266
|
" [void](Assert-ZswarmCurrentOwner $task)",
|
|
@@ -267,13 +269,14 @@ export function buildCrewTaskScript(action, params) {
|
|
|
267
269
|
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
268
270
|
" }",
|
|
269
271
|
"}",
|
|
270
|
-
"Write-ZswarmJson @{ started = $true; startTask = $startName
|
|
272
|
+
"Write-ZswarmJson @{ started = $true; startTask = $startName }",
|
|
271
273
|
].join("\n");
|
|
272
274
|
}
|
|
273
275
|
return [
|
|
274
276
|
PS_PREAMBLE,
|
|
275
277
|
`$startName = '${names.start}'`,
|
|
276
|
-
|
|
278
|
+
// Also the watchdog task older versions registered.
|
|
279
|
+
`$watchName = '${names.legacyWatch}'`,
|
|
277
280
|
"foreach ($name in @($startName, $watchName)) {",
|
|
278
281
|
" try {",
|
|
279
282
|
" $task = Get-ZswarmTask $name",
|
|
@@ -468,10 +471,7 @@ export async function inspectCrewTasks(input) {
|
|
|
468
471
|
? data.clientCount
|
|
469
472
|
: pids.length;
|
|
470
473
|
return {
|
|
471
|
-
tasks: [
|
|
472
|
-
asTaskState("start", names.start, data.start),
|
|
473
|
-
asTaskState("watch", names.watch, data.watch),
|
|
474
|
-
],
|
|
474
|
+
tasks: [asTaskState("start", names.start, data.start)],
|
|
475
475
|
client: {
|
|
476
476
|
present: data.clientPresent === true,
|
|
477
477
|
pid: pids.length > 0 ? pids[0] : null,
|
|
@@ -482,18 +482,11 @@ export async function inspectCrewTasks(input) {
|
|
|
482
482
|
export async function installCrewTasks(input) {
|
|
483
483
|
const session = checkSession(input.session);
|
|
484
484
|
const names = crewTaskNames(session);
|
|
485
|
-
const intervalMinutes = Math.max(1, Math.floor(input.intervalMinutes ?? CREW_WATCH_MINUTES));
|
|
486
485
|
const timeoutMs = crewTimeout(input.timeoutMs);
|
|
487
|
-
const script = buildCrewTaskScript("register", {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
intervalMinutes,
|
|
492
|
-
});
|
|
493
|
-
await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
|
|
494
|
-
// Start both now, the way the Linux oneshot unit is started on install: the
|
|
495
|
-
// start task builds the session and opens the client window; the watchdog's
|
|
496
|
-
// first run is a no-op while that session is healthy.
|
|
486
|
+
const script = buildCrewTaskScript("register", { session, startCommand: input.startCommand });
|
|
487
|
+
const registered = await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
|
|
488
|
+
// Start it now, the way the Linux oneshot unit is started on install: the
|
|
489
|
+
// start task builds the session and opens the client window.
|
|
497
490
|
await crewRun(input.runPowerShell, buildCrewTaskScript("start", { session }), timeoutMs, input.signal);
|
|
498
491
|
const inspected = await inspectCrewTasks({
|
|
499
492
|
session,
|
|
@@ -504,11 +497,11 @@ export async function installCrewTasks(input) {
|
|
|
504
497
|
return {
|
|
505
498
|
lines: [
|
|
506
499
|
`${names.folder}${names.start} registered and started`,
|
|
507
|
-
`${names.folder}${names.
|
|
500
|
+
...(registered.removedLegacyWatch === true ? [`${names.folder}${names.legacyWatch} (old watchdog) removed`] : []),
|
|
508
501
|
],
|
|
509
502
|
notes: [
|
|
510
503
|
"A Windows scheduled task runs in the signed-in interactive desktop session, so the crew needs a logged-in user: a logoff ends the client window and the session it renders.",
|
|
511
|
-
`The start task builds the session and keeps an attached client window titled ${names.start}
|
|
504
|
+
`The start task builds the session and keeps an attached client window titled ${names.start}. Nothing restarts the crew by itself: if the session or that window goes, run zswarm crew start ${session}.`,
|
|
512
505
|
],
|
|
513
506
|
tasks: inspected.tasks,
|
|
514
507
|
client: inspected.client,
|
|
@@ -527,7 +520,7 @@ export async function removeCrewTasks(input) {
|
|
|
527
520
|
signal: input.signal,
|
|
528
521
|
});
|
|
529
522
|
return {
|
|
530
|
-
lines: [`${names.folder}${names.start} removed
|
|
523
|
+
lines: [`${names.folder}${names.start} removed`],
|
|
531
524
|
tasks: inspected.tasks,
|
|
532
525
|
};
|
|
533
526
|
}
|
package/dist/host/crew.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { type LogEntry } from "../state.js";
|
|
1
2
|
import { type PowerShellExec } from "./crew-windows.js";
|
|
2
3
|
import { type Runner, type ServiceAccount, type ServiceHost, type ServiceSpec } from "./services.js";
|
|
3
|
-
export declare const CREW_WATCH_SEC = 120;
|
|
4
4
|
export type CrewDeps = {
|
|
5
5
|
env?: NodeJS.ProcessEnv;
|
|
6
6
|
/** The crew directory; defaults to ~/.zswarm/crews/<session>. */
|
|
@@ -22,8 +22,8 @@ export type CrewDeps = {
|
|
|
22
22
|
}>>;
|
|
23
23
|
sleep?: (ms: number) => Promise<void>;
|
|
24
24
|
log?: (line: string) => void;
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/** Appends to zswarm's op log (~/.zswarm/log.jsonl); injected in tests. */
|
|
26
|
+
record?: (entry: LogEntry) => void;
|
|
27
27
|
/** The account the crew runs for; injected in tests (a win32 account cannot run real tasks in CI). */
|
|
28
28
|
account?: ServiceAccount;
|
|
29
29
|
/** Runs the crew's scheduled-task PowerShell on Windows; injected in tests. */
|
|
@@ -43,12 +43,12 @@ export type CrewDeps = {
|
|
|
43
43
|
close: (session: string) => Promise<number[]>;
|
|
44
44
|
};
|
|
45
45
|
};
|
|
46
|
-
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
47
|
-
export declare const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
48
46
|
export declare function checkCrewSession(session: string | undefined): string;
|
|
49
47
|
export declare function crewDir(account: ServiceAccount, session: string, env?: NodeJS.ProcessEnv): string;
|
|
50
48
|
/** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
|
|
51
49
|
export declare function firstLayoutPane(kdl: string): string | undefined;
|
|
50
|
+
/** Every named `pane` in a layout, in order (comments skipped). */
|
|
51
|
+
export declare function layoutPaneNames(kdl: string): string[];
|
|
52
52
|
/** KEY=VALUE lines (optional `export`, quotes, comments) into an object. */
|
|
53
53
|
export declare function parseEnvFile(text: string): Record<string, string>;
|
|
54
54
|
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
@@ -60,7 +60,11 @@ export declare function crewStart(session: string, deps?: CrewDeps): Promise<num
|
|
|
60
60
|
* session render — a background-only session leaves pane grids empty.
|
|
61
61
|
*/
|
|
62
62
|
export declare function crewClient(session: string, deps?: CrewDeps): number;
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* `crew check` was the watchdog's command. The watchdog is gone; this stays a
|
|
65
|
+
* harmless no-op so a timer or task left by an older version does not fail
|
|
66
|
+
* every two minutes before `crew up` or `crew down` removes it.
|
|
67
|
+
*/
|
|
64
68
|
export declare function crewCheck(session: string, deps?: CrewDeps): Promise<number>;
|
|
65
69
|
export type CrewUpInput = {
|
|
66
70
|
session: string;
|
package/dist/host/crew.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, posix, win32 } from "node:path";
|
|
|
4
4
|
import { ZellijError } from "../errors.js";
|
|
5
5
|
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
6
6
|
import { resolveServeCliLaunch } from "../ops/serve-install.js";
|
|
7
|
-
import { defaultStateDir } from "../state.js";
|
|
7
|
+
import { createStateStore, defaultStateDir } from "../state.js";
|
|
8
8
|
import { resolveZellijBinary } from "../zellij/binary.js";
|
|
9
9
|
import { closeCrewClient, crewClientCommand, crewClientMarker, crewTaskCommand, defaultPowerShellExec, ensureCrewClient, inspectCrewTasks, installCrewTasks, removeCrewTasks, } from "./crew-windows.js";
|
|
10
10
|
import { defaultRunner, ensureAccountDir, installServices, removeServices, serviceAccount, serviceStates, writeAccountFile, writeSecretFile, } from "./services.js";
|
|
@@ -15,27 +15,28 @@ const { join } = posix;
|
|
|
15
15
|
* `zswarm crew`: keep a Zellij session built from a layout running on a host.
|
|
16
16
|
*
|
|
17
17
|
* up copy the layout (and optional config and env file) into
|
|
18
|
-
* ~/.zswarm/crews/<session
|
|
19
|
-
* the session
|
|
20
|
-
* its server died or its layout did not take
|
|
18
|
+
* ~/.zswarm/crews/<session>/ and install a oneshot service that
|
|
19
|
+
* starts the session (at boot/logon and now)
|
|
21
20
|
* start what the service runs: start the session, prove the first layout
|
|
22
|
-
* pane exists, retry up to three times
|
|
23
|
-
*
|
|
21
|
+
* pane exists, retry up to three times; also the way to bring a
|
|
22
|
+
* crew back by hand
|
|
24
23
|
* status services, session and panes
|
|
25
|
-
* down remove the
|
|
24
|
+
* down remove the service and kill the session
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* There is no watchdog: nothing restarts a crew by itself. Older versions
|
|
27
|
+
* installed a `crew-<session>-watch` timer/job/task running `crew check`;
|
|
28
|
+
* `up` and `down` remove it, and `check` is a no-op until they have.
|
|
29
|
+
*
|
|
30
|
+
* On Windows there is no systemd or launchd: `up` registers a Scheduled
|
|
31
|
+
* Task under `\zswarm\` and the start path keeps an attached client in a
|
|
32
|
+
* minimized console window, because a background-only Windows session renders
|
|
33
|
+
* empty panes and drops pasted input.
|
|
32
34
|
*
|
|
33
35
|
* Zellij now and then starts `attach --create-background --layout` as a
|
|
34
36
|
* plain one-pane session; the first-pane check is what catches that.
|
|
35
37
|
*/
|
|
36
38
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
37
39
|
const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
|
|
38
|
-
export const CREW_WATCH_SEC = 120;
|
|
39
40
|
/** The Windows client window: real PowerShell, or the test seam. */
|
|
40
41
|
function windowsClient(deps, env) {
|
|
41
42
|
if (deps.client)
|
|
@@ -55,8 +56,6 @@ function windowsClient(deps, env) {
|
|
|
55
56
|
function taskStateView(task) {
|
|
56
57
|
return { name: task.name, unit: task.path, state: task.state, active: task.active };
|
|
57
58
|
}
|
|
58
|
-
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
59
|
-
export const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
60
59
|
export function checkCrewSession(session) {
|
|
61
60
|
const s = session?.trim() ?? "";
|
|
62
61
|
if (!SESSION.test(s))
|
|
@@ -84,6 +83,50 @@ export function firstLayoutPane(kdl) {
|
|
|
84
83
|
}
|
|
85
84
|
return undefined;
|
|
86
85
|
}
|
|
86
|
+
/** Every named `pane` in a layout, in order (comments skipped). */
|
|
87
|
+
export function layoutPaneNames(kdl) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const line of kdl.split("\n")) {
|
|
90
|
+
const code = line.replace(/\/\/.*$/, "");
|
|
91
|
+
if (!/^\s*pane\b/.test(code))
|
|
92
|
+
continue;
|
|
93
|
+
const m = /\bname\s*=?\s*"([^"]+)"/.exec(code);
|
|
94
|
+
if (m)
|
|
95
|
+
out.push(m[1]);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Did the session take its layout? `foreign`: it has panes, but none carries a
|
|
101
|
+
* name from the layout, so Zellij built it from something else (usually a saved
|
|
102
|
+
* session of the same name it resurrected). Recreating would only repeat that.
|
|
103
|
+
* A single unnamed pane is the plain shell of a layout that did not load yet.
|
|
104
|
+
*/
|
|
105
|
+
function checkLayout(panes, first, names) {
|
|
106
|
+
const titles = panes.map((p) => p.title);
|
|
107
|
+
const seen = `${titles.length} pane(s)${titles.length ? `: ${titles.slice(0, 8).join(", ")}${titles.length > 8 ? ", ..." : ""}` : ""}`;
|
|
108
|
+
if (titles.includes(first))
|
|
109
|
+
return { state: "ok", seen };
|
|
110
|
+
if (titles.length > 1 && !titles.some((t) => names.includes(t)))
|
|
111
|
+
return { state: "foreign", seen };
|
|
112
|
+
return { state: "missing", seen };
|
|
113
|
+
}
|
|
114
|
+
function foreignLayoutHint(session) {
|
|
115
|
+
return (`Zellij restored a different layout for ${session}: a saved or still-running session holds that name ` +
|
|
116
|
+
`(on Windows often a renamed session whose server kept its old name). Stop the server that holds it ` +
|
|
117
|
+
`(a zellij.exe --server process), run \`zellij delete-session ${session}\`, then \`zswarm crew start ${session}\``);
|
|
118
|
+
}
|
|
119
|
+
function crewRecorder(deps, env) {
|
|
120
|
+
const record = deps.record ?? ((entry) => createStateStore({ env }).appendLog(entry));
|
|
121
|
+
return (session, ok, detail) => {
|
|
122
|
+
try {
|
|
123
|
+
record({ at: Date.now(), op: "crew", session, ok, detail });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// The log is an aid, never a reason to fail the crew.
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
87
130
|
/** KEY=VALUE lines (optional `export`, quotes, comments) into an object. */
|
|
88
131
|
export function parseEnvFile(text) {
|
|
89
132
|
const out = {};
|
|
@@ -159,13 +202,15 @@ export async function crewStart(session, deps = {}) {
|
|
|
159
202
|
const panes = deps.panes ?? defaultPanes;
|
|
160
203
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
161
204
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
205
|
+
const record = crewRecorder(deps, env0);
|
|
206
|
+
const names = layoutPaneNames(readFileSync(crewFile(dir, files.layout), "utf8"));
|
|
162
207
|
const config = files.config ? ["--config", crewFile(dir, files.config)] : [];
|
|
163
208
|
if (account.platform === "win32") {
|
|
164
209
|
// Windows: a session created with `attach --create-background` renders
|
|
165
210
|
// empty panes and drops pasted input, so the session is created BY an
|
|
166
211
|
// attached client instead — a minimized console window running
|
|
167
212
|
// `zellij --layout ... attach --create`, which `crew client` starts. That
|
|
168
|
-
// window is
|
|
213
|
+
// window is what `crew start` reopens when it is gone.
|
|
169
214
|
const client = windowsClient(deps, env);
|
|
170
215
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
171
216
|
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
@@ -175,16 +220,25 @@ export async function crewStart(session, deps = {}) {
|
|
|
175
220
|
for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
|
|
176
221
|
await sleep(1_000);
|
|
177
222
|
await sleep(3_000);
|
|
178
|
-
|
|
223
|
+
const check = checkLayout(await panes(session, env), files.first, names);
|
|
224
|
+
if (check.state === "ok") {
|
|
179
225
|
log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
|
|
180
226
|
return 0;
|
|
181
227
|
}
|
|
182
|
-
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
183
228
|
zellij(["kill-session", session], env);
|
|
184
229
|
await client.close(session);
|
|
230
|
+
if (check.state === "foreign") {
|
|
231
|
+
const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
|
|
232
|
+
log(`session ${session} ${why}`);
|
|
233
|
+
record(session, false, `killed session; ${why}`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
|
|
237
|
+
record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
|
|
185
238
|
await sleep(1_000);
|
|
186
239
|
}
|
|
187
240
|
log(`session ${session} never took its layout`);
|
|
241
|
+
record(session, false, "start failed: never took its layout after 3 attempts");
|
|
188
242
|
return 1;
|
|
189
243
|
}
|
|
190
244
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -193,15 +247,24 @@ export async function crewStart(session, deps = {}) {
|
|
|
193
247
|
for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
|
|
194
248
|
await sleep(500);
|
|
195
249
|
await sleep(2_000);
|
|
196
|
-
|
|
250
|
+
const check = checkLayout(await panes(session, env), files.first, names);
|
|
251
|
+
if (check.state === "ok") {
|
|
197
252
|
log(`session ${session} up with its layout (attempt ${attempt})`);
|
|
198
253
|
return 0;
|
|
199
254
|
}
|
|
200
|
-
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
201
255
|
zellij(["kill-session", session], env);
|
|
256
|
+
if (check.state === "foreign") {
|
|
257
|
+
const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
|
|
258
|
+
log(`session ${session} ${why}`);
|
|
259
|
+
record(session, false, `killed session; ${why}`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
|
|
263
|
+
record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
|
|
202
264
|
await sleep(1_000);
|
|
203
265
|
}
|
|
204
266
|
log(`session ${session} never took its layout`);
|
|
267
|
+
record(session, false, "start failed: never took its layout after 3 attempts");
|
|
205
268
|
return 1;
|
|
206
269
|
}
|
|
207
270
|
/**
|
|
@@ -243,36 +306,15 @@ export function crewClient(session, deps = {}) {
|
|
|
243
306
|
process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
|
|
244
307
|
return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
|
|
245
308
|
}
|
|
246
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* `crew check` was the watchdog's command. The watchdog is gone; this stays a
|
|
311
|
+
* harmless no-op so a timer or task left by an older version does not fail
|
|
312
|
+
* every two minutes before `crew up` or `crew down` removes it.
|
|
313
|
+
*/
|
|
247
314
|
export async function crewCheck(session, deps = {}) {
|
|
248
|
-
const env0 = deps.env ?? process.env;
|
|
249
|
-
const account = deps.account ?? serviceAccount(undefined);
|
|
250
|
-
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
251
|
-
const files = readCrewFiles(dir);
|
|
252
|
-
const env = crewEnv(dir, files, env0);
|
|
253
|
-
const sessions = deps.sessions ?? defaultSessions;
|
|
254
|
-
const panes = deps.panes ?? defaultPanes;
|
|
255
315
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// The session surviving is not enough on Windows: without the attached
|
|
259
|
-
// client window its panes render empty, so the watchdog reopens it.
|
|
260
|
-
const opened = await windowsClient(deps, env).ensure(session);
|
|
261
|
-
if (opened.opened)
|
|
262
|
-
log(`client window reopened for ${session}`);
|
|
263
|
-
}
|
|
264
|
-
return 0;
|
|
265
|
-
}
|
|
266
|
-
log(`session ${session} is gone or lost its layout; restarting`);
|
|
267
|
-
(deps.zellij ?? defaultZellij)(["kill-session", session], env);
|
|
268
|
-
const unit = env0[CREW_UNIT_ENV]?.trim();
|
|
269
|
-
if (unit) {
|
|
270
|
-
// Under systemd a server started here would live in the watchdog's cgroup
|
|
271
|
-
// and die when this check exits; the crew unit owns the session instead.
|
|
272
|
-
const restart = deps.restartUnit ?? ((u) => spawnSync("systemctl", ["--user", "restart", u], { stdio: "inherit" }).status ?? 1);
|
|
273
|
-
return restart(unit);
|
|
274
|
-
}
|
|
275
|
-
return await crewStart(session, deps);
|
|
316
|
+
log(`crew check ${checkCrewSession(session)}: the crew watchdog was removed; run zswarm crew up ${session} again to drop its old task`);
|
|
317
|
+
return 0;
|
|
276
318
|
}
|
|
277
319
|
/** A file inside a crew dir, which is a Windows path for a win32 account. */
|
|
278
320
|
function crewFile(dir, name) {
|
|
@@ -325,17 +367,12 @@ export function crewServiceSpecs(input) {
|
|
|
325
367
|
// kills the session itself.
|
|
326
368
|
killMode: "process",
|
|
327
369
|
},
|
|
328
|
-
{
|
|
329
|
-
name: `crew-${session}-watch`,
|
|
330
|
-
description: `zswarm crew: restart session ${session} if it died`,
|
|
331
|
-
kind: "interval",
|
|
332
|
-
intervalSec: CREW_WATCH_SEC,
|
|
333
|
-
argv: [...cli, "check", session],
|
|
334
|
-
env: account.platform === "linux" ? { ...base, [CREW_UNIT_ENV]: `zswarm-crew-${session}.service` } : base,
|
|
335
|
-
workingDir: account.home,
|
|
336
|
-
},
|
|
337
370
|
];
|
|
338
371
|
}
|
|
372
|
+
/** The watchdog unit older versions installed; removed by `crew up` and `crew down`. */
|
|
373
|
+
function legacyWatchSpec(session) {
|
|
374
|
+
return { name: `crew-${session}-watch`, kind: "interval" };
|
|
375
|
+
}
|
|
339
376
|
export async function crewUp(input) {
|
|
340
377
|
const env = input.env ?? process.env;
|
|
341
378
|
const run = input.run ?? defaultRunner;
|
|
@@ -364,14 +401,13 @@ export async function crewUp(input) {
|
|
|
364
401
|
// crew.json is written last, so a half-copied crew never starts.
|
|
365
402
|
(input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
|
|
366
403
|
if (account.platform === "win32") {
|
|
367
|
-
// Windows has no systemd/launchd:
|
|
368
|
-
//
|
|
369
|
-
//
|
|
404
|
+
// Windows has no systemd/launchd: a Scheduled Task plus the minimized
|
|
405
|
+
// client window it opens, because a background-only session renders empty
|
|
406
|
+
// panes.
|
|
370
407
|
const taskEnv = crewTaskEnv(env);
|
|
371
408
|
const installed = await installCrewTasks({
|
|
372
409
|
session,
|
|
373
410
|
startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
|
|
374
|
-
watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
|
|
375
411
|
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
376
412
|
});
|
|
377
413
|
const states = installed.tasks.map(taskStateView);
|
|
@@ -389,6 +425,8 @@ export async function crewUp(input) {
|
|
|
389
425
|
};
|
|
390
426
|
}
|
|
391
427
|
const specs = crewServiceSpecs({ account, session, launch, env });
|
|
428
|
+
// Quietly drop the watchdog an older version installed.
|
|
429
|
+
removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
|
|
392
430
|
const done = installServices({ account, run, fs: input.fs }, specs);
|
|
393
431
|
const states = serviceStates({ account, run }, specs);
|
|
394
432
|
return {
|
|
@@ -469,6 +507,7 @@ export async function crewDown(input) {
|
|
|
469
507
|
}
|
|
470
508
|
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
471
509
|
const lines = removeServices({ account, run, fs: input.fs }, specs);
|
|
510
|
+
removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
|
|
472
511
|
// The Zellij server belongs to the account: kill it as that user.
|
|
473
512
|
const zellij = env.ZSWARM_BIN?.trim() || "zellij";
|
|
474
513
|
const kill = account.foreign
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecRes
|
|
|
4
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
|
|
5
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
|
|
6
6
|
export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
|
|
7
|
-
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
|
|
7
|
+
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, resolveSelfSession, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
|
|
8
8
|
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, type WaitRequest, } from "./zellij/args.js";
|
|
9
9
|
export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
|
|
@@ -36,6 +36,7 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
|
|
|
36
36
|
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
37
37
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
|
|
38
38
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
|
|
39
|
-
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane,
|
|
39
|
+
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, layoutPaneNames, parseEnvFile, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
|
|
40
40
|
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
|
|
41
41
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
|
|
42
|
+
export { isSelfPane } from "./ops/guards.js";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
|
|
|
4
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
|
|
5
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, } from "./zellij/binary.js";
|
|
6
6
|
export { normalizePaneId, parsePaneList, resolvePane, } from "./zellij/panes.js";
|
|
7
|
-
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
|
|
7
|
+
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, resolveSelfSession, } from "./zellij/session.js";
|
|
8
8
|
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, } from "./zellij/args.js";
|
|
9
9
|
export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, } from "./ops/bus.js";
|
|
@@ -34,6 +34,7 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
|
|
|
34
34
|
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
35
35
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
|
|
36
36
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
|
|
37
|
-
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane,
|
|
37
|
+
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, layoutPaneNames, parseEnvFile, } from "./host/crew.js";
|
|
38
38
|
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
|
|
39
39
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
|
|
40
|
+
export { isSelfPane } from "./ops/guards.js";
|
package/dist/ops/broadcast.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type Skipped = {
|
|
|
17
17
|
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
18
18
|
* every terminal pane — then drop the ones it must not write to.
|
|
19
19
|
*/
|
|
20
|
-
export declare function selectTargets(client: ZellijClient, panes: ZellijPane[], args: Record<string, unknown
|
|
20
|
+
export declare function selectTargets(client: ZellijClient, panes: ZellijPane[], args: Record<string, unknown>, session?: string | null): {
|
|
21
21
|
targets: ZellijPane[];
|
|
22
22
|
skipped: Skipped[];
|
|
23
23
|
};
|
package/dist/ops/broadcast.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { assertPaneAllowed } from "../policy.js";
|
|
3
3
|
import { deliverTo, selfPaneTitle, withSenderLabel } from "./delivery.js";
|
|
4
|
+
import { isSelfPane } from "./guards.js";
|
|
4
5
|
import { mapPool } from "./status.js";
|
|
5
6
|
import { isTrue, optionalString } from "./util.js";
|
|
6
7
|
/**
|
|
@@ -13,7 +14,7 @@ export const BROADCAST_CONCURRENCY = 3;
|
|
|
13
14
|
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
14
15
|
* every terminal pane — then drop the ones it must not write to.
|
|
15
16
|
*/
|
|
16
|
-
export function selectTargets(client, panes, args) {
|
|
17
|
+
export function selectTargets(client, panes, args, session) {
|
|
17
18
|
const skipped = [];
|
|
18
19
|
const list = optionalString(args.to);
|
|
19
20
|
const tab = optionalString(args.tab);
|
|
@@ -59,7 +60,7 @@ export function selectTargets(client, panes, args) {
|
|
|
59
60
|
skipped.push({ id: pane.id, reason: "pane_is_plugin" });
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
62
|
-
if (pane
|
|
63
|
+
if (isSelfPane(client, pane, session) && !isTrue(args.allowSelf)) {
|
|
63
64
|
skipped.push({ id: pane.id, reason: "self_target" });
|
|
64
65
|
continue;
|
|
65
66
|
}
|
|
@@ -82,7 +83,7 @@ export async function broadcast(client, state, args, clock, policy, env = proces
|
|
|
82
83
|
env,
|
|
83
84
|
selfTitle: selfPaneTitle(client, panes),
|
|
84
85
|
});
|
|
85
|
-
const { targets: selected, skipped } = selectTargets(client, panes, labeled);
|
|
86
|
+
const { targets: selected, skipped } = selectTargets(client, panes, labeled, session);
|
|
86
87
|
// Policy narrows the selection rather than failing the whole broadcast.
|
|
87
88
|
const targets = selected.filter((pane) => {
|
|
88
89
|
if (!policy)
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -396,7 +396,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
396
396
|
if (!body.trim())
|
|
397
397
|
throw new ZellijError("missing_body", "body required");
|
|
398
398
|
const { session, pane, panes, firstCallMs, timeoutMs } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
399
|
-
assertWritable(client, pane, args, "send");
|
|
399
|
+
assertWritable(client, pane, args, "send", session);
|
|
400
400
|
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
401
401
|
if (isTrue(args.ifIdle)) {
|
|
402
402
|
// Before delivery starts: a live pane lease (a relay working it) is a
|
|
@@ -451,7 +451,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
451
451
|
case "keys":
|
|
452
452
|
case "interrupt": {
|
|
453
453
|
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
454
|
-
assertWritable(client, pane, args, op);
|
|
454
|
+
assertWritable(client, pane, args, op, session);
|
|
455
455
|
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
456
456
|
const chars = typeof args.chars === "string" ? args.chars : "";
|
|
457
457
|
if (op === "keys" && chars) {
|
|
@@ -682,7 +682,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
682
682
|
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
683
683
|
// Not assertWritable: closing an exited pane is the point of close.
|
|
684
684
|
assertNotPlugin(pane, "close");
|
|
685
|
-
assertNotSelf(client, pane, args, "close");
|
|
685
|
+
assertNotSelf(client, pane, args, "close", session);
|
|
686
686
|
const closed = await client.closePane({ session, paneId: pane.id });
|
|
687
687
|
state().appendLog({
|
|
688
688
|
at: clock.now(),
|
package/dist/ops/guards.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { ZellijClient } from "../zellij/client.js";
|
|
2
2
|
import type { ZellijPane } from "../zellij/panes.js";
|
|
3
3
|
export declare function assertNotPlugin(pane: ZellijPane, action: string): void;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Is this the caller's own pane? The pane id must match AND the target session
|
|
6
|
+
* must be the caller's session when both are known: `terminal_1` in another
|
|
7
|
+
* session (or on another machine) is a different pane. A shared MCP server (one
|
|
8
|
+
* service for many clients) can carry another pane's ZELLIJ_* env, so the id
|
|
9
|
+
* alone is not proof. Unknown session: stay conservative and treat it as self.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isSelfPane(client: Pick<ZellijClient, "selfPaneId" | "selfSession" | "remote">, pane: Pick<ZellijPane, "id">, session?: string | null): boolean;
|
|
12
|
+
export declare function assertNotSelf(client: ZellijClient, pane: ZellijPane, args: Record<string, unknown>, action: string, session?: string | null): void;
|
|
5
13
|
/**
|
|
6
14
|
* Refuse writes that would loop back into the caller's own pane, land in a
|
|
7
15
|
* dead pane, or hit a plugin pane. `allowSelf` / `force` opt out.
|
|
8
16
|
*/
|
|
9
|
-
export declare function assertWritable(client: ZellijClient, pane: ZellijPane, args: Record<string, unknown>, action: string): void;
|
|
17
|
+
export declare function assertWritable(client: ZellijClient, pane: ZellijPane, args: Record<string, unknown>, action: string, session?: string | null): void;
|
|
10
18
|
/**
|
|
11
19
|
* Opt-in check that the pane still looks like the intended agent, not a
|
|
12
20
|
* shell that would execute the body as a command. Substring only — a regex
|
package/dist/ops/guards.js
CHANGED
|
@@ -5,9 +5,26 @@ export function assertNotPlugin(pane, action) {
|
|
|
5
5
|
throw new ZellijError("pane_is_plugin", `${pane.id} is a plugin pane; ${action} targets terminal panes`);
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Is this the caller's own pane? The pane id must match AND the target session
|
|
10
|
+
* must be the caller's session when both are known: `terminal_1` in another
|
|
11
|
+
* session (or on another machine) is a different pane. A shared MCP server (one
|
|
12
|
+
* service for many clients) can carry another pane's ZELLIJ_* env, so the id
|
|
13
|
+
* alone is not proof. Unknown session: stay conservative and treat it as self.
|
|
14
|
+
*/
|
|
15
|
+
export function isSelfPane(client, pane, session) {
|
|
16
|
+
if (client.remote)
|
|
17
|
+
return false;
|
|
18
|
+
const self = client.selfPaneId;
|
|
19
|
+
if (!self || pane.id !== self)
|
|
20
|
+
return false;
|
|
21
|
+
if (client.selfSession && session && session !== client.selfSession)
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
export function assertNotSelf(client, pane, args, action, session) {
|
|
9
26
|
const self = client.selfPaneId;
|
|
10
|
-
if (
|
|
27
|
+
if (isSelfPane(client, pane, session) && !isTrue(args.allowSelf)) {
|
|
11
28
|
throw new ZellijError("self_target", `refusing to ${action} into zswarm's own pane (${self}); pass allowSelf=true to override`);
|
|
12
29
|
}
|
|
13
30
|
}
|
|
@@ -15,9 +32,9 @@ export function assertNotSelf(client, pane, args, action) {
|
|
|
15
32
|
* Refuse writes that would loop back into the caller's own pane, land in a
|
|
16
33
|
* dead pane, or hit a plugin pane. `allowSelf` / `force` opt out.
|
|
17
34
|
*/
|
|
18
|
-
export function assertWritable(client, pane, args, action) {
|
|
35
|
+
export function assertWritable(client, pane, args, action, session) {
|
|
19
36
|
assertNotPlugin(pane, action);
|
|
20
|
-
assertNotSelf(client, pane, args, action);
|
|
37
|
+
assertNotSelf(client, pane, args, action, session);
|
|
21
38
|
if (pane.exited && !isTrue(args.force)) {
|
|
22
39
|
throw new ZellijError("pane_exited", `${pane.id} has exited; pass force=true to write to it anyway`);
|
|
23
40
|
}
|
package/dist/ops/restart.js
CHANGED
|
@@ -240,7 +240,7 @@ export async function restartPane(client, state, args, target, clock, opts = {})
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
assertNotPlugin(observed, "restart");
|
|
243
|
-
assertNotSelf(client, observed, args, "restart");
|
|
243
|
+
assertNotSelf(client, observed, args, "restart", session);
|
|
244
244
|
// A relay working the pane owns it; restarting under it would kill the task.
|
|
245
245
|
const to = String(args.to ?? "").trim();
|
|
246
246
|
const held = findLease(state, {
|
|
@@ -251,7 +251,8 @@ export async function restartPane(client, state, args, target, clock, opts = {})
|
|
|
251
251
|
if (held && !isTrue(args.force)) {
|
|
252
252
|
throw new ZellijError("pane_leased", `pane ${to} is leased by relay ${held.relayId} (from ${held.from}, since ${new Date(held.at).toISOString()}); pass force=true to restart it anyway`, { relayId: held.relayId, from: held.from, at: held.at, dir: held.dir });
|
|
253
253
|
}
|
|
254
|
-
|
|
254
|
+
// --harness names the profile for wrapper-launched panes (exit recipe, ready and busy markers).
|
|
255
|
+
const profile = resolveHarness(observed, optionalString(args.harness));
|
|
255
256
|
const kind = restartPaneKind(observed);
|
|
256
257
|
const handoffSelf = isTrue(args.handoffSelf);
|
|
257
258
|
const supplied = typeof args.handoff === "string" && args.handoff.length > 0 ? args.handoff : null;
|
package/dist/zellij/client.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type ZellijTransport = {
|
|
|
32
32
|
export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
33
33
|
zellijPath: string;
|
|
34
34
|
selfPaneId: string | null;
|
|
35
|
+
selfSession: string | null;
|
|
35
36
|
contextKey: () => string;
|
|
36
37
|
invalidateListings: (session: string) => void;
|
|
37
38
|
observeManifest: (session: string, revision: string) => void;
|
package/dist/zellij/client.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseTabList, resolveTab } from "./tabs.js";
|
|
|
6
6
|
import { createSshExec } from "../exec.js";
|
|
7
7
|
import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijProbes, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, zellijExecDetails, zellijMissingError, } from "./binary.js";
|
|
8
8
|
import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
|
|
9
|
-
import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
|
|
9
|
+
import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, resolveSelfSession, sessionFromEnv, sessionFromList, } from "./session.js";
|
|
10
10
|
/** Zellij wrapper with bounded caches of completed discovery observations. */
|
|
11
11
|
export function createZellijClient(options = {}) {
|
|
12
12
|
const env = options.env ?? process.env;
|
|
@@ -28,6 +28,8 @@ export function createZellijClient(options = {}) {
|
|
|
28
28
|
const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
|
|
29
29
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
30
|
const selfPaneId = resolveSelfPaneId(env);
|
|
31
|
+
// Over SSH the target is another machine's Zellij: no pane there is ours.
|
|
32
|
+
const selfSession = ssh ? null : resolveSelfSession(env);
|
|
31
33
|
const probeKey = JSON.stringify([identityCacheKey(zellijPath, ssh), routingEnvironment(env)]);
|
|
32
34
|
const now = options.now ?? Date.now;
|
|
33
35
|
const cache = options.cache instanceof ListingCache ? options.cache : sharedListings;
|
|
@@ -368,6 +370,7 @@ export function createZellijClient(options = {}) {
|
|
|
368
370
|
return {
|
|
369
371
|
zellijPath,
|
|
370
372
|
selfPaneId,
|
|
373
|
+
selfSession,
|
|
371
374
|
contextKey,
|
|
372
375
|
invalidateListings,
|
|
373
376
|
observeManifest,
|
package/dist/zellij/session.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export type ZellijSession = {
|
|
|
19
19
|
* `ZSWARM_SELF_PANE` wins; Zellij exports `ZELLIJ_PANE_ID` inside a pane.
|
|
20
20
|
*/
|
|
21
21
|
export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* The Zellij session the caller runs in (ZSWARM_SELF_SESSION, else
|
|
24
|
+
* ZELLIJ_SESSION_NAME); null when unknown. A pane id is only "self" inside
|
|
25
|
+
* this session: terminal_1 of another session is somebody else's pane.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveSelfSession(env?: NodeJS.ProcessEnv): string | null;
|
|
22
28
|
/**
|
|
23
29
|
* Zellij's human created age (`"1month 16days 2h 30m 5s ago"`) as seconds.
|
|
24
30
|
* Handles the units Zellij prints (s, m, h, days, months, years, singular and
|
package/dist/zellij/session.js
CHANGED
|
@@ -13,6 +13,15 @@ export function resolveSelfPaneId(env = process.env) {
|
|
|
13
13
|
return raw.toLowerCase();
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The Zellij session the caller runs in (ZSWARM_SELF_SESSION, else
|
|
18
|
+
* ZELLIJ_SESSION_NAME); null when unknown. A pane id is only "self" inside
|
|
19
|
+
* this session: terminal_1 of another session is somebody else's pane.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveSelfSession(env = process.env) {
|
|
22
|
+
const raw = (env.ZSWARM_SELF_SESSION ?? env.ZELLIJ_SESSION_NAME ?? "").trim();
|
|
23
|
+
return raw ? raw : null;
|
|
24
|
+
}
|
|
16
25
|
const AGE_UNITS = [
|
|
17
26
|
[/^years?$|^yrs?$|^y$/, 31_536_000],
|
|
18
27
|
[/^months?$|^mons?$|^mo$/, 2_592_000],
|