@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 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
- " keep the session running from its layout (service + 2-min watchdog;",
248
- " on Windows: Scheduled Tasks under \\zswarm\\ + an attached client window)",
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|check <session> what the service and the watchdog run",
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 two Scheduled Tasks under
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
- * zswarm-crew-<session>-watch `crew check` every two minutes, so the crew
11
- * restarts only when the session or its first
12
- * layout pane is gone, and the client window is
13
- * reopened when it is gone but the session lives
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
- watch: string;
37
+ legacyWatch: string;
38
38
  };
39
39
  export type CrewTaskState = {
40
- /** Which of the two crew tasks this is. */
41
- name: "start" | "watch";
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 two Scheduled Tasks under
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
- * zswarm-crew-<session>-watch `crew check` every two minutes, so the crew
14
- * restarts only when the session or its first
15
- * layout pane is gone, and the client window is
16
- * reopened when it is gone but the session lives
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. Registering two tasks is two calls. */
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, watch: `${start}-watch` };
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.watch}'`,
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
- "$startAction = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
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
- `$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${watchB64}'))`,
194
- "$watchAction = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
195
- // A once-trigger with a long repetition is the supported way to run an
196
- // interval task for a user; an AtLogOn trigger would only run once.
197
- `$watchTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes ${interval}) -RepetitionDuration (New-TimeSpan -Days 3650)`,
198
- `Register-ScheduledTask -TaskName $watchName -TaskPath '${CREW_TASK_FOLDER}' -Action $watchAction -Principal $principal -Trigger $watchTrigger -Settings $settings -Force | Out-Null`,
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
- " watchTask = $watchName",
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 }, @{ key = 'watch'; name = $watchName })) {",
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
- `$watchName = '${names.watch}'`,
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; watchTask = $watchName }",
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
- `$watchName = '${names.watch}'`,
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 the tree has to go with it:",
340
- " # /T reaches the attached `zellij` client that shares that console.",
341
- " & taskkill.exe /PID $clientPid /T /F 2>$null | Out-Null",
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
- session,
489
- startCommand: input.startCommand,
490
- watchCommand: input.watchCommand,
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.watch} registered and started`,
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}; the watchdog runs crew check every ${intervalMinutes} minute(s) and reopens that window when it is gone.`,
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`, `${names.folder}${names.watch} removed`],
538
+ lines: [`${names.folder}${names.start} removed`],
531
539
  tasks: inspected.tasks,
532
540
  };
533
541
  }
@@ -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
- /** Restarts a systemd user unit (the watchdog on Linux); injected in tests. */
26
- restartUnit?: (unit: string) => number;
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
- /** Watchdog: restart the session if its server died or its first pane is gone. */
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>/, install a oneshot service that starts
19
- * the session and a watchdog that restarts it every two minutes if
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
- * check what the watchdog runs: start again only if needed
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 services and kill the session
24
+ * down remove the service and kill the session
26
25
  *
27
- * On Windows there is no systemd or launchd: `up` registers two Scheduled
28
- * Tasks under `\zswarm\` (a start task and a two-minute watchdog running
29
- * `crew check`) and the start path keeps an attached client in a minimized
30
- * console window, because a background-only Windows session renders empty
31
- * panes and drops pasted input.
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 also what the watchdog reopens when it dies.
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
- if ((await panes(session, env)).some((p) => p.title === files.first)) {
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
- if ((await panes(session, env)).some((p) => p.title === files.first)) {
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
- /** Watchdog: restart the session if its server died or its first pane is gone. */
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
- if ((await sessions(env)).includes(session) && (await panes(session, env)).some((p) => p.title === files.first)) {
257
- if (account.platform === "win32") {
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: two Scheduled Tasks (a start task and a
368
- // two-minute watchdog) plus the minimized client window the start task
369
- // opens, because a background-only session renders empty panes.
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, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
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, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
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";
@@ -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
  };
@@ -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.id === client.selfPaneId && !isTrue(args.allowSelf)) {
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)
@@ -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(),
@@ -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
- export declare function assertNotSelf(client: ZellijClient, pane: ZellijPane, args: Record<string, unknown>, action: string): void;
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
@@ -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
- export function assertNotSelf(client, pane, args, action) {
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 (self && pane.id === self && !isTrue(args.allowSelf)) {
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
  }
@@ -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, {
@@ -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;
@@ -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,
@@ -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
@@ -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],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {