@zswarm/core 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host/cli.js 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",
@@ -468,10 +471,7 @@ export async function inspectCrewTasks(input) {
468
471
  ? data.clientCount
469
472
  : pids.length;
470
473
  return {
471
- tasks: [
472
- asTaskState("start", names.start, data.start),
473
- asTaskState("watch", names.watch, data.watch),
474
- ],
474
+ tasks: [asTaskState("start", names.start, data.start)],
475
475
  client: {
476
476
  present: data.clientPresent === true,
477
477
  pid: pids.length > 0 ? pids[0] : null,
@@ -482,18 +482,11 @@ export async function inspectCrewTasks(input) {
482
482
  export async function installCrewTasks(input) {
483
483
  const session = checkSession(input.session);
484
484
  const names = crewTaskNames(session);
485
- const intervalMinutes = Math.max(1, Math.floor(input.intervalMinutes ?? CREW_WATCH_MINUTES));
486
485
  const timeoutMs = crewTimeout(input.timeoutMs);
487
- const script = buildCrewTaskScript("register", {
488
- 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.
486
+ const script = buildCrewTaskScript("register", { session, startCommand: input.startCommand });
487
+ const registered = await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
488
+ // Start it now, the way the Linux oneshot unit is started on install: the
489
+ // start task builds the session and opens the client window.
497
490
  await crewRun(input.runPowerShell, buildCrewTaskScript("start", { session }), timeoutMs, input.signal);
498
491
  const inspected = await inspectCrewTasks({
499
492
  session,
@@ -504,11 +497,11 @@ export async function installCrewTasks(input) {
504
497
  return {
505
498
  lines: [
506
499
  `${names.folder}${names.start} registered and started`,
507
- `${names.folder}${names.watch} registered and started`,
500
+ ...(registered.removedLegacyWatch === true ? [`${names.folder}${names.legacyWatch} (old watchdog) removed`] : []),
508
501
  ],
509
502
  notes: [
510
503
  "A Windows scheduled task runs in the signed-in interactive desktop session, so the crew needs a logged-in user: a logoff ends the client window and the session it renders.",
511
- `The start task builds the session and keeps an attached client window titled ${names.start}; the watchdog runs crew check every ${intervalMinutes} minute(s) and reopens that window when it is gone.`,
504
+ `The start task builds the session and keeps an attached client window titled ${names.start}. Nothing restarts the crew by itself: if the session or that window goes, run zswarm crew start ${session}.`,
512
505
  ],
513
506
  tasks: inspected.tasks,
514
507
  client: inspected.client,
@@ -527,7 +520,7 @@ export async function removeCrewTasks(input) {
527
520
  signal: input.signal,
528
521
  });
529
522
  return {
530
- lines: [`${names.folder}${names.start} removed`, `${names.folder}${names.watch} removed`],
523
+ lines: [`${names.folder}${names.start} removed`],
531
524
  tasks: inspected.tasks,
532
525
  };
533
526
  }
@@ -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,13 +202,15 @@ export async function crewStart(session, deps = {}) {
159
202
  const panes = deps.panes ?? defaultPanes;
160
203
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
161
204
  const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
205
+ const record = crewRecorder(deps, env0);
206
+ const names = layoutPaneNames(readFileSync(crewFile(dir, files.layout), "utf8"));
162
207
  const config = files.config ? ["--config", crewFile(dir, files.config)] : [];
163
208
  if (account.platform === "win32") {
164
209
  // Windows: a session created with `attach --create-background` renders
165
210
  // empty panes and drops pasted input, so the session is created BY an
166
211
  // attached client instead — a minimized console window running
167
212
  // `zellij --layout ... attach --create`, which `crew client` starts. That
168
- // window is 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);
170
215
  for (let attempt = 1; attempt <= 3; attempt++) {
171
216
  zellij(["delete-session", session], env); // clears only a dead session of this name
@@ -175,16 +220,25 @@ export async function crewStart(session, deps = {}) {
175
220
  for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
176
221
  await sleep(1_000);
177
222
  await sleep(3_000);
178
- if ((await panes(session, env)).some((p) => p.title === files.first)) {
223
+ const check = checkLayout(await panes(session, env), files.first, names);
224
+ if (check.state === "ok") {
179
225
  log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
180
226
  return 0;
181
227
  }
182
- log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
183
228
  zellij(["kill-session", session], env);
184
229
  await client.close(session);
230
+ if (check.state === "foreign") {
231
+ const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
232
+ log(`session ${session} ${why}`);
233
+ record(session, false, `killed session; ${why}`);
234
+ return 1;
235
+ }
236
+ log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
237
+ record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
185
238
  await sleep(1_000);
186
239
  }
187
240
  log(`session ${session} never took its layout`);
241
+ record(session, false, "start failed: never took its layout after 3 attempts");
188
242
  return 1;
189
243
  }
190
244
  for (let attempt = 1; attempt <= 3; attempt++) {
@@ -193,15 +247,24 @@ export async function crewStart(session, deps = {}) {
193
247
  for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
194
248
  await sleep(500);
195
249
  await sleep(2_000);
196
- if ((await panes(session, env)).some((p) => p.title === files.first)) {
250
+ const check = checkLayout(await panes(session, env), files.first, names);
251
+ if (check.state === "ok") {
197
252
  log(`session ${session} up with its layout (attempt ${attempt})`);
198
253
  return 0;
199
254
  }
200
- log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
201
255
  zellij(["kill-session", session], env);
256
+ if (check.state === "foreign") {
257
+ const why = `stopped: came up with ${check.seen}, none from its layout. ${foreignLayoutHint(session)}`;
258
+ log(`session ${session} ${why}`);
259
+ record(session, false, `killed session; ${why}`);
260
+ return 1;
261
+ }
262
+ log(`session ${session} came up without its layout (attempt ${attempt}, ${check.seen}); recreating`);
263
+ record(session, false, `killed session: came up without its layout (attempt ${attempt}, ${check.seen}; expected ${files.first})`);
202
264
  await sleep(1_000);
203
265
  }
204
266
  log(`session ${session} never took its layout`);
267
+ record(session, false, "start failed: never took its layout after 3 attempts");
205
268
  return 1;
206
269
  }
207
270
  /**
@@ -243,36 +306,15 @@ export function crewClient(session, deps = {}) {
243
306
  process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
244
307
  return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
245
308
  }
246
- /** Watchdog: restart the session if its server died or its first pane is gone. */
309
+ /**
310
+ * `crew check` was the watchdog's command. The watchdog is gone; this stays a
311
+ * harmless no-op so a timer or task left by an older version does not fail
312
+ * every two minutes before `crew up` or `crew down` removes it.
313
+ */
247
314
  export async function crewCheck(session, deps = {}) {
248
- const env0 = deps.env ?? process.env;
249
- const account = deps.account ?? serviceAccount(undefined);
250
- const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
251
- const files = readCrewFiles(dir);
252
- const env = crewEnv(dir, files, env0);
253
- const sessions = deps.sessions ?? defaultSessions;
254
- const panes = deps.panes ?? defaultPanes;
255
315
  const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
256
- 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);
316
+ log(`crew check ${checkCrewSession(session)}: the crew watchdog was removed; run zswarm crew up ${session} again to drop its old task`);
317
+ return 0;
276
318
  }
277
319
  /** A file inside a crew dir, which is a Windows path for a win32 account. */
278
320
  function crewFile(dir, name) {
@@ -325,17 +367,12 @@ export function crewServiceSpecs(input) {
325
367
  // kills the session itself.
326
368
  killMode: "process",
327
369
  },
328
- {
329
- name: `crew-${session}-watch`,
330
- description: `zswarm crew: restart session ${session} if it died`,
331
- kind: "interval",
332
- intervalSec: CREW_WATCH_SEC,
333
- argv: [...cli, "check", session],
334
- env: account.platform === "linux" ? { ...base, [CREW_UNIT_ENV]: `zswarm-crew-${session}.service` } : base,
335
- workingDir: account.home,
336
- },
337
370
  ];
338
371
  }
372
+ /** The watchdog unit older versions installed; removed by `crew up` and `crew down`. */
373
+ function legacyWatchSpec(session) {
374
+ return { name: `crew-${session}-watch`, kind: "interval" };
375
+ }
339
376
  export async function crewUp(input) {
340
377
  const env = input.env ?? process.env;
341
378
  const run = input.run ?? defaultRunner;
@@ -364,14 +401,13 @@ export async function crewUp(input) {
364
401
  // crew.json is written last, so a half-copied crew never starts.
365
402
  (input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
366
403
  if (account.platform === "win32") {
367
- // Windows has no systemd/launchd: 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.
404
+ // Windows has no systemd/launchd: a Scheduled Task plus the minimized
405
+ // client window it opens, because a background-only session renders empty
406
+ // panes.
370
407
  const taskEnv = crewTaskEnv(env);
371
408
  const installed = await installCrewTasks({
372
409
  session,
373
410
  startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
374
- watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
375
411
  runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
376
412
  });
377
413
  const states = installed.tasks.map(taskStateView);
@@ -389,6 +425,8 @@ export async function crewUp(input) {
389
425
  };
390
426
  }
391
427
  const specs = crewServiceSpecs({ account, session, launch, env });
428
+ // Quietly drop the watchdog an older version installed.
429
+ removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
392
430
  const done = installServices({ account, run, fs: input.fs }, specs);
393
431
  const states = serviceStates({ account, run }, specs);
394
432
  return {
@@ -469,6 +507,7 @@ export async function crewDown(input) {
469
507
  }
470
508
  const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
471
509
  const lines = removeServices({ account, run, fs: input.fs }, specs);
510
+ removeServices({ account, run, fs: input.fs }, [legacyWatchSpec(session)]);
472
511
  // The Zellij server belongs to the account: kill it as that user.
473
512
  const zellij = env.ZSWARM_BIN?.trim() || "zellij";
474
513
  const kill = account.foreign
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecRes
4
4
  export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
5
5
  export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
6
6
  export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
7
- export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
7
+ export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, resolveSelfSession, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
8
8
  export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, type WaitRequest, } from "./zellij/args.js";
9
9
  export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
10
10
  export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
@@ -36,6 +36,7 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
36
36
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
37
37
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
38
38
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
39
- export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, 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, {
@@ -251,7 +251,8 @@ export async function restartPane(client, state, args, target, clock, opts = {})
251
251
  if (held && !isTrue(args.force)) {
252
252
  throw new ZellijError("pane_leased", `pane ${to} is leased by relay ${held.relayId} (from ${held.from}, since ${new Date(held.at).toISOString()}); pass force=true to restart it anyway`, { relayId: held.relayId, from: held.from, at: held.at, dir: held.dir });
253
253
  }
254
- const profile = resolveHarness(observed);
254
+ // --harness names the profile for wrapper-launched panes (exit recipe, ready and busy markers).
255
+ const profile = resolveHarness(observed, optionalString(args.harness));
255
256
  const kind = restartPaneKind(observed);
256
257
  const handoffSelf = isTrue(args.handoffSelf);
257
258
  const supplied = typeof args.handoff === "string" && args.handoff.length > 0 ? args.handoff : null;
@@ -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.8",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {