@zswarm/core 0.2.9 → 0.3.1
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 +57 -49
- package/dist/host/crew.d.ts +10 -6
- package/dist/host/crew.js +108 -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 +1 -1
- 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",
|
|
@@ -334,11 +337,26 @@ export function buildCrewClientScript(action, params) {
|
|
|
334
337
|
PS_CLIENT_PREAMBLE,
|
|
335
338
|
`$marker = '${marker}'`,
|
|
336
339
|
psClientPidLine(marker),
|
|
340
|
+
"$all = @(Get-CimInstance Win32_Process)",
|
|
337
341
|
"$closed = @()",
|
|
338
342
|
"foreach ($clientPid in $clientPids) {",
|
|
339
|
-
" # The window is the launcher's console, so
|
|
340
|
-
" #
|
|
341
|
-
"
|
|
343
|
+
" # The window is the launcher's console, so its tree goes with it: the",
|
|
344
|
+
" # attached `zellij` client shares that console. The Zellij server is in the",
|
|
345
|
+
" # tree too (the client that created the session spawned it) and must stay,",
|
|
346
|
+
" # or the session dies with its window: skip it and everything under it.",
|
|
347
|
+
" # A child is never older than its parent, so a reused parent PID never",
|
|
348
|
+
" # links an unrelated process.",
|
|
349
|
+
" $tree = New-Object System.Collections.Generic.List[object]",
|
|
350
|
+
" $queue = New-Object System.Collections.Generic.Queue[object]",
|
|
351
|
+
" $queue.Enqueue(($all | Where-Object { $_.ProcessId -eq $clientPid } | Select-Object -First 1))",
|
|
352
|
+
" while ($queue.Count -gt 0) {",
|
|
353
|
+
" $proc = $queue.Dequeue()",
|
|
354
|
+
" if (-not $proc) { continue }",
|
|
355
|
+
" if ($proc.Name -eq 'zellij.exe' -and $proc.CommandLine -match '(^|\\s)--server(\\s|=)') { continue }",
|
|
356
|
+
" $tree.Add($proc)",
|
|
357
|
+
" foreach ($child in @($all | Where-Object { $_.ParentProcessId -eq $proc.ProcessId -and $_.CreationDate -ge $proc.CreationDate })) { $queue.Enqueue($child) }",
|
|
358
|
+
" }",
|
|
359
|
+
" for ($i = $tree.Count - 1; $i -ge 0; $i--) { Stop-Process -Id $tree[$i].ProcessId -Force -ErrorAction SilentlyContinue }",
|
|
342
360
|
" $closed += $clientPid",
|
|
343
361
|
"}",
|
|
344
362
|
"Write-ZswarmJson @{ closed = @($closed); count = $closed.Count }",
|
|
@@ -468,10 +486,7 @@ export async function inspectCrewTasks(input) {
|
|
|
468
486
|
? data.clientCount
|
|
469
487
|
: pids.length;
|
|
470
488
|
return {
|
|
471
|
-
tasks: [
|
|
472
|
-
asTaskState("start", names.start, data.start),
|
|
473
|
-
asTaskState("watch", names.watch, data.watch),
|
|
474
|
-
],
|
|
489
|
+
tasks: [asTaskState("start", names.start, data.start)],
|
|
475
490
|
client: {
|
|
476
491
|
present: data.clientPresent === true,
|
|
477
492
|
pid: pids.length > 0 ? pids[0] : null,
|
|
@@ -482,18 +497,11 @@ export async function inspectCrewTasks(input) {
|
|
|
482
497
|
export async function installCrewTasks(input) {
|
|
483
498
|
const session = checkSession(input.session);
|
|
484
499
|
const names = crewTaskNames(session);
|
|
485
|
-
const intervalMinutes = Math.max(1, Math.floor(input.intervalMinutes ?? CREW_WATCH_MINUTES));
|
|
486
500
|
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.
|
|
501
|
+
const script = buildCrewTaskScript("register", { session, startCommand: input.startCommand });
|
|
502
|
+
const registered = await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
|
|
503
|
+
// Start it now, the way the Linux oneshot unit is started on install: the
|
|
504
|
+
// start task builds the session and opens the client window.
|
|
497
505
|
await crewRun(input.runPowerShell, buildCrewTaskScript("start", { session }), timeoutMs, input.signal);
|
|
498
506
|
const inspected = await inspectCrewTasks({
|
|
499
507
|
session,
|
|
@@ -504,11 +512,11 @@ export async function installCrewTasks(input) {
|
|
|
504
512
|
return {
|
|
505
513
|
lines: [
|
|
506
514
|
`${names.folder}${names.start} registered and started`,
|
|
507
|
-
`${names.folder}${names.
|
|
515
|
+
...(registered.removedLegacyWatch === true ? [`${names.folder}${names.legacyWatch} (old watchdog) removed`] : []),
|
|
508
516
|
],
|
|
509
517
|
notes: [
|
|
510
518
|
"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}
|
|
519
|
+
`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
520
|
],
|
|
513
521
|
tasks: inspected.tasks,
|
|
514
522
|
client: inspected.client,
|
|
@@ -527,7 +535,7 @@ export async function removeCrewTasks(input) {
|
|
|
527
535
|
signal: input.signal,
|
|
528
536
|
});
|
|
529
537
|
return {
|
|
530
|
-
lines: [`${names.folder}${names.start} removed
|
|
538
|
+
lines: [`${names.folder}${names.start} removed`],
|
|
531
539
|
tasks: inspected.tasks,
|
|
532
540
|
};
|
|
533
541
|
}
|
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,14 +202,24 @@ 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);
|
|
215
|
+
// A live session that already has its layout is the crew: keep it and only
|
|
216
|
+
// make sure its window is there. Rebuilding would restart every pane.
|
|
217
|
+
if ((await sessions(env)).includes(session)
|
|
218
|
+
&& checkLayout(await panes(session, env), files.first, names).state === "ok") {
|
|
219
|
+
const opened = await client.ensure(session);
|
|
220
|
+
log(`session ${session} already up; client window ${opened.opened ? "opened" : "already open"}`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
170
223
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
171
224
|
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
172
225
|
await client.close(session); // a window left over from the previous attempt
|
|
@@ -175,16 +228,25 @@ export async function crewStart(session, deps = {}) {
|
|
|
175
228
|
for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
|
|
176
229
|
await sleep(1_000);
|
|
177
230
|
await sleep(3_000);
|
|
178
|
-
|
|
231
|
+
const check = checkLayout(await panes(session, env), files.first, names);
|
|
232
|
+
if (check.state === "ok") {
|
|
179
233
|
log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
|
|
180
234
|
return 0;
|
|
181
235
|
}
|
|
182
|
-
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
183
236
|
zellij(["kill-session", session], env);
|
|
184
237
|
await client.close(session);
|
|
238
|
+
if (check.state === "foreign") {
|
|
239
|
+
const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
|
|
240
|
+
log(`session ${session} ${why}`);
|
|
241
|
+
record(session, false, `killed session; ${why}`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
|
|
245
|
+
record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
|
|
185
246
|
await sleep(1_000);
|
|
186
247
|
}
|
|
187
248
|
log(`session ${session} never took its layout`);
|
|
249
|
+
record(session, false, "start failed: never took its layout after 3 attempts");
|
|
188
250
|
return 1;
|
|
189
251
|
}
|
|
190
252
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -193,15 +255,24 @@ export async function crewStart(session, deps = {}) {
|
|
|
193
255
|
for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
|
|
194
256
|
await sleep(500);
|
|
195
257
|
await sleep(2_000);
|
|
196
|
-
|
|
258
|
+
const check = checkLayout(await panes(session, env), files.first, names);
|
|
259
|
+
if (check.state === "ok") {
|
|
197
260
|
log(`session ${session} up with its layout (attempt ${attempt})`);
|
|
198
261
|
return 0;
|
|
199
262
|
}
|
|
200
|
-
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
201
263
|
zellij(["kill-session", session], env);
|
|
264
|
+
if (check.state === "foreign") {
|
|
265
|
+
const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
|
|
266
|
+
log(`session ${session} ${why}`);
|
|
267
|
+
record(session, false, `killed session; ${why}`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
|
|
271
|
+
record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
|
|
202
272
|
await sleep(1_000);
|
|
203
273
|
}
|
|
204
274
|
log(`session ${session} never took its layout`);
|
|
275
|
+
record(session, false, "start failed: never took its layout after 3 attempts");
|
|
205
276
|
return 1;
|
|
206
277
|
}
|
|
207
278
|
/**
|
|
@@ -243,36 +314,15 @@ export function crewClient(session, deps = {}) {
|
|
|
243
314
|
process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
|
|
244
315
|
return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
|
|
245
316
|
}
|
|
246
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* `crew check` was the watchdog's command. The watchdog is gone; this stays a
|
|
319
|
+
* harmless no-op so a timer or task left by an older version does not fail
|
|
320
|
+
* every two minutes before `crew up` or `crew down` removes it.
|
|
321
|
+
*/
|
|
247
322
|
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
323
|
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);
|
|
324
|
+
log(`crew check ${checkCrewSession(session)}: the crew watchdog was removed; run zswarm crew up ${session} again to drop its old task`);
|
|
325
|
+
return 0;
|
|
276
326
|
}
|
|
277
327
|
/** A file inside a crew dir, which is a Windows path for a win32 account. */
|
|
278
328
|
function crewFile(dir, name) {
|
|
@@ -325,17 +375,12 @@ export function crewServiceSpecs(input) {
|
|
|
325
375
|
// kills the session itself.
|
|
326
376
|
killMode: "process",
|
|
327
377
|
},
|
|
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
378
|
];
|
|
338
379
|
}
|
|
380
|
+
/** The watchdog unit older versions installed; removed by `crew up` and `crew down`. */
|
|
381
|
+
function legacyWatchSpec(session) {
|
|
382
|
+
return { name: `crew-${session}-watch`, kind: "interval" };
|
|
383
|
+
}
|
|
339
384
|
export async function crewUp(input) {
|
|
340
385
|
const env = input.env ?? process.env;
|
|
341
386
|
const run = input.run ?? defaultRunner;
|
|
@@ -364,14 +409,13 @@ export async function crewUp(input) {
|
|
|
364
409
|
// crew.json is written last, so a half-copied crew never starts.
|
|
365
410
|
(input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
|
|
366
411
|
if (account.platform === "win32") {
|
|
367
|
-
// Windows has no systemd/launchd:
|
|
368
|
-
//
|
|
369
|
-
//
|
|
412
|
+
// Windows has no systemd/launchd: a Scheduled Task plus the minimized
|
|
413
|
+
// client window it opens, because a background-only session renders empty
|
|
414
|
+
// panes.
|
|
370
415
|
const taskEnv = crewTaskEnv(env);
|
|
371
416
|
const installed = await installCrewTasks({
|
|
372
417
|
session,
|
|
373
418
|
startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
|
|
374
|
-
watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
|
|
375
419
|
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
376
420
|
});
|
|
377
421
|
const states = installed.tasks.map(taskStateView);
|
|
@@ -389,6 +433,8 @@ export async function crewUp(input) {
|
|
|
389
433
|
};
|
|
390
434
|
}
|
|
391
435
|
const specs = crewServiceSpecs({ account, session, launch, env });
|
|
436
|
+
// Quietly drop the watchdog an older version installed.
|
|
437
|
+
removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
|
|
392
438
|
const done = installServices({ account, run, fs: input.fs }, specs);
|
|
393
439
|
const states = serviceStates({ account, run }, specs);
|
|
394
440
|
return {
|
|
@@ -469,6 +515,7 @@ export async function crewDown(input) {
|
|
|
469
515
|
}
|
|
470
516
|
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
471
517
|
const lines = removeServices({ account, run, fs: input.fs }, specs);
|
|
518
|
+
removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
|
|
472
519
|
// The Zellij server belongs to the account: kill it as that user.
|
|
473
520
|
const zellij = env.ZSWARM_BIN?.trim() || "zellij";
|
|
474
521
|
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, {
|
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],
|