@zswarm/core 0.2.6 → 0.2.7

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.
@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url";
7
7
  import { ZellijError } from "../errors.js";
8
8
  import { encodePowerShellCommand } from "../zellij/ipc.js";
9
9
  import { coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, } from "./doctor.js";
10
- import { callServe, isLoopbackHost, parseListenAddress, probeServe, redactServeSecret, SERVE_CORE_VERSION, SERVE_HELLO_TIMEOUT_MS, SERVE_LAUNCH_ID_ENV, SERVE_PROTOCOL, SERVE_TASK_NAME, serveLogonCommand, } from "./serve.js";
10
+ import { callServe, isLoopbackHost, parseListenAddress, probeServe, redactServeSecret, SERVE_CORE_VERSION, SERVE_HELLO_TIMEOUT_MS, SERVE_LAUNCH_ID_ENV, SERVE_PROTOCOL, SERVE_TASK_NAME, serveLogonCommand, serveTaskTarget, } from "./serve.js";
11
11
  import { authorizeServeListen, SERVE_BIND_REMEDY, SERVE_TAILSCALE_BIN_ENV, } from "./serve-bind.js";
12
12
  import { throwIfAborted } from "./util.js";
13
13
  /** Overall install/readiness deadline when `timeoutMs` is omitted. */
@@ -90,11 +90,45 @@ const PS_PREAMBLE = [
90
90
  " $json = $obj | ConvertTo-Json -Compress -Depth 6",
91
91
  " [Console]::Out.Write($json)",
92
92
  "}",
93
- `function Get-ZswarmTask { Get-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -ErrorAction Stop }`,
94
93
  "function Test-ZswarmMissing($err) {",
95
94
  " return $err.CategoryInfo.Category -eq 'ObjectNotFound' -or ([string]$err.FullyQualifiedErrorId -match 'ObjectNotFound')",
96
95
  "}",
97
96
  ].join("\n");
97
+ /** A literal `Get-ScheduledTask` lookup; a missing task or folder is `$null`. */
98
+ function scheduledTaskLookup(name, folder) {
99
+ const path = folder && folder !== "\\" ? ` -TaskPath '${folder}'` : "";
100
+ return `Get-ScheduledTask -TaskName '${name}'${path} -ErrorAction SilentlyContinue`;
101
+ }
102
+ /**
103
+ * WHY: a legacy install lives at the Task Scheduler root as `zswarm-serve` and
104
+ * records the session it serves as `set "ZSWARM_SESSION=<session>"` in its action.
105
+ * A named session may adopt that task, but only for the session it records:
106
+ * without the marker, `--clear --session other` would stop an unrelated serve.
107
+ * The per-session name is always looked up first so a real per-session task wins.
108
+ */
109
+ function serveTargetResolver(target) {
110
+ const folder = target.folder ?? "";
111
+ const session = target.session ?? "";
112
+ const lines = [
113
+ "function Get-ZswarmTarget {",
114
+ ` $found = ${scheduledTaskLookup(target.name, folder)}`,
115
+ " if ($found) { return @{ task = $found; name = [string]$found.TaskName; path = [string]$found.TaskPath; legacy = $false } }",
116
+ ];
117
+ if (session) {
118
+ lines.push(` $legacyTask = ${scheduledTaskLookup(SERVE_TASK_NAME, "")}`, ` if ($legacyTask -and ([string]$legacyTask.Actions[0].Arguments).Contains('set "ZSWARM_SESSION=${session}"')) {`, " return @{ task = $legacyTask; name = [string]$legacyTask.TaskName; path = [string]$legacyTask.TaskPath; legacy = $true }", " }");
119
+ }
120
+ lines.push(` return @{ task = $null; name = '${target.name}'; path = '${folder}'; legacy = $false }`, "}");
121
+ return lines;
122
+ }
123
+ /** Preamble + the target-aware resolver; every action resolves before it mutates. */
124
+ function buildPreamble(target) {
125
+ return [PS_PREAMBLE, ...serveTargetResolver(target)].join("\n");
126
+ }
127
+ /** Splat only when the resolved task really sits in a folder. */
128
+ const TASK_PATH_SPLAT = [
129
+ "$taskPath = @{}",
130
+ "if (-not [string]::IsNullOrEmpty($target.path) -and $target.path -ne '\\') { $taskPath = @{ TaskPath = $target.path } }",
131
+ ];
98
132
  const REMEDY = {
99
133
  notReady: "Registration/start was accepted, but authenticated hello and host inspection did not prove this installation is ready. Retry serve --install or inspect with zswarm doctor; serve --clear removes the owned task. Install does not kill unrelated processes.",
100
134
  stale: "A listener answered on this port but did not present this installation's launch identity. Install does not kill arbitrary node processes or port owners. Stop the owned zswarm-serve task (serve --clear) or free the port, then retry.",
@@ -249,19 +283,17 @@ function privilegedSid(sid) {
249
283
  return WELL_KNOWN_SIDS.has(sid.trim().toUpperCase());
250
284
  }
251
285
  export function buildServeTaskScript(action, params = {}) {
286
+ const target = params.target ?? serveTaskTarget();
252
287
  if (action === "inspect") {
253
288
  return [
254
- PS_PREAMBLE,
255
- "try {",
256
- " $task = Get-ZswarmTask",
257
- "} catch {",
258
- " if (Test-ZswarmMissing $_) {",
259
- " $identity = Get-ZswarmIdentity",
260
- " Write-ZswarmJson @{ exists = $false; currentUser = $identity.name; currentSid = $identity.sid }",
261
- " exit 0",
262
- " }",
263
- " throw",
289
+ buildPreamble(target),
290
+ "$target = Get-ZswarmTarget",
291
+ "if (-not $target.task) {",
292
+ " $identity = Get-ZswarmIdentity",
293
+ " Write-ZswarmJson @{ exists = $false; currentUser = $identity.name; currentSid = $identity.sid; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
294
+ " exit 0",
264
295
  "}",
296
+ "$task = $target.task",
265
297
  "$identity = Get-ZswarmIdentity",
266
298
  "$action = @($task.Actions)[0]",
267
299
  "$principal = $task.Principal",
@@ -270,6 +302,8 @@ export function buildServeTaskScript(action, params = {}) {
270
302
  " currentUser = $identity.name",
271
303
  " currentSid = $identity.sid",
272
304
  " taskName = [string]$task.TaskName",
305
+ " taskPath = [string]$task.TaskPath",
306
+ " legacy = $target.legacy",
273
307
  " state = [string]$task.State",
274
308
  " userId = [string]$principal.UserId",
275
309
  " userSid = (Resolve-ZswarmSid ([string]$principal.UserId))",
@@ -284,96 +318,92 @@ export function buildServeTaskScript(action, params = {}) {
284
318
  const command = params.command ?? "";
285
319
  const commandB64 = Buffer.from(command, "utf8").toString("base64");
286
320
  return [
287
- PS_PREAMBLE,
321
+ buildPreamble(target),
288
322
  `$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${commandB64}'))`,
289
- "try {",
290
- " $existing = Get-ZswarmTask",
323
+ "$target = Get-ZswarmTarget",
324
+ "$existing = $target.task",
325
+ "if ($existing) {",
291
326
  " $identity = Assert-ZswarmCurrentOwner $existing",
292
- "} catch {",
293
- " if (-not (Test-ZswarmMissing $_)) { throw }",
327
+ "} else {",
294
328
  " $identity = Get-ZswarmIdentity",
295
329
  "}",
296
330
  "$userId = $identity.name",
297
331
  "$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
298
332
  "$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited",
299
333
  "$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId",
300
- `Register-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Action $action -Principal $principal -Trigger $trigger -Force | Out-Null`,
301
- "Write-ZswarmJson @{ registered = $true; userId = $userId; userSid = $identity.sid; logonType = 'Interactive'; runLevel = 'Limited'; taskName = '" +
302
- SERVE_TASK_NAME +
303
- "' }",
334
+ ...TASK_PATH_SPLAT,
335
+ "Register-ScheduledTask -TaskName $target.name @taskPath -Action $action -Principal $principal -Trigger $trigger -Force | Out-Null",
336
+ "Write-ZswarmJson @{ registered = $true; userId = $userId; userSid = $identity.sid; logonType = 'Interactive'; runLevel = 'Limited'; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
304
337
  ].join("\n");
305
338
  }
306
339
  if (action === "start") {
340
+ // Start is only ever asked for a task that was just registered or just
341
+ // found, so a missing one is a real failure and keeps the old contract:
342
+ // the strict lookup below throws the cmdlet's own ObjectNotFound.
307
343
  return [
308
- PS_PREAMBLE,
309
- "try {",
310
- " $task = Get-ZswarmTask",
311
- " [void](Assert-ZswarmCurrentOwner $task)",
312
- "} catch {",
313
- " throw",
344
+ buildPreamble(target),
345
+ "$target = Get-ZswarmTarget",
346
+ "if (-not $target.task) {",
347
+ " if ([string]::IsNullOrEmpty($target.path) -or $target.path -eq '\\') { Get-ScheduledTask -TaskName $target.name -ErrorAction Stop | Out-Null }",
348
+ " else { Get-ScheduledTask -TaskName $target.name -TaskPath $target.path -ErrorAction Stop | Out-Null }",
314
349
  "}",
315
- `Start-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
316
- "Write-ZswarmJson @{ started = $true; taskName = '" + SERVE_TASK_NAME + "' }",
350
+ "$task = $target.task",
351
+ ...TASK_PATH_SPLAT,
352
+ "[void](Assert-ZswarmCurrentOwner $task)",
353
+ "Start-ScheduledTask -TaskName $target.name @taskPath",
354
+ "Write-ZswarmJson @{ started = $true; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
317
355
  ].join("\n");
318
356
  }
319
357
  if (action === "stop") {
320
358
  return [
321
- PS_PREAMBLE,
359
+ buildPreamble(target),
360
+ "$target = Get-ZswarmTarget",
361
+ "$task = $target.task",
362
+ ...TASK_PATH_SPLAT,
363
+ "if ($task) { [void](Assert-ZswarmCurrentOwner $task) }",
322
364
  "try {",
323
- " $task = Get-ZswarmTask",
324
- " [void](Assert-ZswarmCurrentOwner $task)",
325
- ` Stop-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
365
+ " Stop-ScheduledTask -TaskName $target.name @taskPath",
326
366
  "} catch {",
327
367
  " if (-not (Test-ZswarmMissing $_)) { throw }",
328
368
  "}",
329
- "Write-ZswarmJson @{ stopped = $true; taskName = '" + SERVE_TASK_NAME + "' }",
369
+ "Write-ZswarmJson @{ stopped = $true; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
330
370
  ].join("\n");
331
371
  }
332
372
  return [
333
- PS_PREAMBLE,
373
+ buildPreamble(target),
334
374
  "$identity = Get-ZswarmIdentity",
335
375
  "$stopped = $false",
336
376
  "$stopFailed = $false",
337
377
  "$stopError = $null",
338
- "try {",
339
- " $task = Get-ZswarmTask",
340
- "} catch {",
341
- " if (Test-ZswarmMissing $_) {",
342
- " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $false; taskName = '" +
343
- SERVE_TASK_NAME +
344
- "' }",
345
- " exit 0",
346
- " }",
347
- " throw",
378
+ "$target = Get-ZswarmTarget",
379
+ "$task = $target.task",
380
+ ...TASK_PATH_SPLAT,
381
+ "if (-not $task) {",
382
+ " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $false; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
383
+ " exit 0",
348
384
  "}",
349
385
  "[void](Assert-ZswarmCurrentOwner $task)",
350
386
  "try {",
351
- ` Stop-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
387
+ " Stop-ScheduledTask -TaskName $target.name @taskPath",
352
388
  " $stopped = $true",
353
389
  "} catch {",
354
390
  " if (Test-ZswarmMissing $_) {",
355
- " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $false; taskName = '" +
356
- SERVE_TASK_NAME +
357
- "' }",
391
+ " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $false; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
358
392
  " exit 0",
359
393
  " }",
360
394
  " $stopFailed = $true",
361
395
  " $stopError = [string]$_.Exception.Message",
362
396
  "}",
363
397
  "try {",
364
- ` Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false`,
398
+ " Unregister-ScheduledTask -TaskName $target.name @taskPath -Confirm:$false",
365
399
  "} catch {",
366
400
  " if (Test-ZswarmMissing $_) {",
367
- " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $stopped; stopFailed = $stopFailed; stopError = $stopError; taskName = '" +
368
- SERVE_TASK_NAME +
369
- "' }",
401
+ " Write-ZswarmJson @{ cleared = $true; missing = $true; stopped = $stopped; stopFailed = $stopFailed; stopError = $stopError; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
370
402
  " exit 0",
371
403
  " }",
372
404
  " throw",
373
405
  "}",
374
- "Write-ZswarmJson @{ cleared = $true; stopped = $stopped; stopFailed = $stopFailed; stopError = $stopError; taskName = '" +
375
- SERVE_TASK_NAME +
376
- "' }",
406
+ "Write-ZswarmJson @{ cleared = $true; stopped = $stopped; stopFailed = $stopFailed; stopError = $stopError; taskName = $target.name; taskPath = $target.path; legacy = $target.legacy }",
377
407
  ].join("\n");
378
408
  }
379
409
  function runPowerShellDefault(script, options) {
@@ -513,6 +543,11 @@ function launchIdFromCommand(command) {
513
543
  function asString(value) {
514
544
  return typeof value === "string" ? value : "";
515
545
  }
546
+ /** Task Scheduler reports the root folder as `\`; our target path is `''` there. */
547
+ function serveTaskFolder(value) {
548
+ const text = asString(value).trim();
549
+ return text === "\\" ? "" : text;
550
+ }
516
551
  function liveSessionsFrom(checks) {
517
552
  const row = checks.find((item) => item.id === "zellij_sessions");
518
553
  const live = row?.detail?.live;
@@ -570,7 +605,10 @@ function notReadyError(message, details, secrets, code = SERVE_NOT_READY_CODE) {
570
605
  return new ZellijError(code, scrubDiagnosticText(message, secrets), scrubSecrets(details, secrets));
571
606
  }
572
607
  async function runTask(action, input) {
573
- const script = buildServeTaskScript(action, { command: input.command });
608
+ const script = buildServeTaskScript(action, {
609
+ command: input.command,
610
+ target: input.target,
611
+ });
574
612
  const result = await input.run(script, {
575
613
  timeoutMs: input.timeoutMs,
576
614
  signal: input.signal,
@@ -642,6 +680,10 @@ export async function installServeLogon(input = {}) {
642
680
  const { host, label } = parseListenAddress(input.listen);
643
681
  const token = serveInstallToken(input);
644
682
  const secrets = [token];
683
+ // One resolved task identity for the whole install: inspect, register, start and
684
+ // stop must all address the same task, including an adopted legacy one.
685
+ const explicitSession = input.session?.trim() || null;
686
+ const target = serveTaskTarget(explicitSession);
645
687
  throwIfAborted(input.signal);
646
688
  if (remaining() <= 0) {
647
689
  throw notReadyError("serve --install timed out before task mutation", {
@@ -651,7 +693,9 @@ export async function installServeLogon(input = {}) {
651
693
  phase: "inspect",
652
694
  cause: "timeout",
653
695
  listen: label,
654
- task: SERVE_TASK_NAME,
696
+ task: target.name,
697
+ taskPath: target.folder ?? "",
698
+ legacy: false,
655
699
  }, secrets, "timeout");
656
700
  }
657
701
  // Non-loopback: verify Tailscale + OS ownership before any task mutation.
@@ -675,7 +719,9 @@ export async function installServeLogon(input = {}) {
675
719
  phase: "verify",
676
720
  cause: "timeout",
677
721
  listen: label,
678
- task: SERVE_TASK_NAME,
722
+ task: target.name,
723
+ taskPath: target.folder ?? "",
724
+ legacy: false,
679
725
  }, secrets, "timeout");
680
726
  }
681
727
  const launch = resolveServeCliLaunch({
@@ -688,14 +734,19 @@ export async function installServeLogon(input = {}) {
688
734
  const probe = input.probeServe ?? probeServe;
689
735
  const call = input.callServe ??
690
736
  ((target, args, options) => callServe(target, args, options));
691
- const explicitSession = input.session?.trim() || null;
692
737
  const requestedLaunchId = input.launchId?.trim() || "";
738
+ // Refined after inspect when a real (possibly adopted legacy) task is found.
739
+ let taskName = target.name;
740
+ let taskPath = target.folder ?? "";
741
+ let legacyAdopted = false;
693
742
  const partial = (extra) => {
694
743
  const details = scrubSecrets({
695
744
  installed: false,
696
745
  ready: false,
697
746
  running: false,
698
- task: SERVE_TASK_NAME,
747
+ task: taskName,
748
+ taskPath,
749
+ legacy: legacyAdopted,
699
750
  listen: label,
700
751
  ...extra,
701
752
  }, secrets);
@@ -734,8 +785,13 @@ export async function installServeLogon(input = {}) {
734
785
  timeoutMs: inspectBudget,
735
786
  signal: input.signal,
736
787
  secrets,
788
+ target,
737
789
  });
738
790
  const exists = inspected.exists === true;
791
+ // The inspected identity wins: it is the real task (or the adopted legacy one).
792
+ taskName = asString(inspected.taskName) || target.name;
793
+ legacyAdopted = inspected.legacy === true;
794
+ taskPath = legacyAdopted ? "" : serveTaskFolder(inspected.taskPath) || (target.folder ?? "");
739
795
  const existingCommand = commandFromArguments(asString(inspected.arguments));
740
796
  secrets.push(...tokenFromCommand(existingCommand));
741
797
  const ownership = (() => {
@@ -758,7 +814,7 @@ export async function installServeLogon(input = {}) {
758
814
  }
759
815
  const verified = isWindowsSid(userSid) && sameWindowsSid(currentSid, userSid);
760
816
  if (!verified || privilegedSid(userSid) || privilegedAccount(userId)) {
761
- throw notReadyError(`serve --install will not overwrite ${SERVE_TASK_NAME} owned by ${userId || "an unverified account"}`, {
817
+ throw notReadyError(`serve --install will not overwrite ${taskName} owned by ${userId || "an unverified account"}`, {
762
818
  ...partial({
763
819
  phase: "inspect",
764
820
  cause: SERVE_TASK_OWNED_CODE,
@@ -774,7 +830,13 @@ export async function installServeLogon(input = {}) {
774
830
  const execute = asString(inspected.execute);
775
831
  const alreadyRunning = exists && taskRunning(asString(inspected.state));
776
832
  const existingLaunchId = launchIdFromCommand(existingCommand);
777
- const desiredBase = serveLogonCommand(launch.execPath, launch.scriptPath, label, token, serveTaskLaunchEnv(env));
833
+ // WHY: a legacy task is recognised only by the session its command records. If
834
+ // we re-register it under its own root-folder name, keep the marker, or the
835
+ // next `--clear --session` can no longer recognise (and remove) it.
836
+ const taskEnv = legacyAdopted && explicitSession
837
+ ? { ...env, ZSWARM_SESSION: explicitSession }
838
+ : env;
839
+ const desiredBase = serveLogonCommand(launch.execPath, launch.scriptPath, label, token, serveTaskLaunchEnv(taskEnv));
778
840
  const configMatches = exists &&
779
841
  execute.toLowerCase() === "cmd.exe" &&
780
842
  serveCommandFingerprint(existingCommand) === desiredBase &&
@@ -786,7 +848,7 @@ export async function installServeLogon(input = {}) {
786
848
  const launchId = reuseExistingLaunch
787
849
  ? existingLaunchId
788
850
  : (requestedLaunchId || randomUUID());
789
- const childEnv = serveTaskLaunchEnv(env, launchId);
851
+ const childEnv = serveTaskLaunchEnv(taskEnv, launchId);
790
852
  const command = serveLogonCommand(launch.execPath, launch.scriptPath, label, token, childEnv);
791
853
  const redactedCommand = redactServeSecret(command, token);
792
854
  const needsUpdate = !reuseExistingLaunch;
@@ -806,6 +868,7 @@ export async function installServeLogon(input = {}) {
806
868
  signal: input.signal,
807
869
  secrets,
808
870
  command: commandArg,
871
+ target,
809
872
  });
810
873
  }
811
874
  catch (err) {
@@ -1033,7 +1096,9 @@ export async function installServeLogon(input = {}) {
1033
1096
  installed: true,
1034
1097
  ready: true,
1035
1098
  running: true,
1036
- task: SERVE_TASK_NAME,
1099
+ task: taskName,
1100
+ taskPath,
1101
+ legacy: legacyAdopted,
1037
1102
  listen: label,
1038
1103
  command: redactedCommand,
1039
1104
  principal,
@@ -1055,7 +1120,7 @@ export async function installServeLogon(input = {}) {
1055
1120
  lastCause === "zellij_wrong_bin" ||
1056
1121
  lastCause === "zellij_incompatible";
1057
1122
  if (conclusive) {
1058
- throw notReadyError(`serve --install registered ${SERVE_TASK_NAME} but it is not ready (${lastCause})`, known(), secrets);
1123
+ throw notReadyError(`serve --install registered ${taskName} but it is not ready (${lastCause})`, known(), secrets);
1059
1124
  }
1060
1125
  if (remaining() <= 0)
1061
1126
  break;
@@ -1067,7 +1132,7 @@ export async function installServeLogon(input = {}) {
1067
1132
  const code = lastCause === "cancelled" ? "cancelled" : remaining() <= 0 || lastCause === "timeout" ? "timeout" : SERVE_NOT_READY_CODE;
1068
1133
  throw notReadyError(code === "timeout"
1069
1134
  ? "serve --install timed out waiting for authenticated readiness"
1070
- : `serve --install registered ${SERVE_TASK_NAME} but it is not ready (${lastCause})`, known(), secrets, code === SERVE_NOT_READY_CODE ? SERVE_NOT_READY_CODE : code);
1135
+ : `serve --install registered ${taskName} but it is not ready (${lastCause})`, known(), secrets, code === SERVE_NOT_READY_CODE ? SERVE_NOT_READY_CODE : code);
1071
1136
  }
1072
1137
  export async function uninstallServeLogon(input = {}) {
1073
1138
  if ((input.platform ?? process.platform) !== "win32") {
@@ -1080,12 +1145,21 @@ export async function uninstallServeLogon(input = {}) {
1080
1145
  const token = input.token?.trim() || input.env?.ZSWARM_SERVE_TOKEN?.trim() || "none";
1081
1146
  const secrets = token === "none" ? [] : [token];
1082
1147
  const run = input.runPowerShell ?? runPowerShellDefault;
1148
+ // `--clear` honours `--session`: a named session removes its per-session task,
1149
+ // or the legacy root task only when that task records the same session.
1150
+ const explicitSession = input.session?.trim() || null;
1151
+ const target = serveTaskTarget(explicitSession);
1152
+ let taskName = target.name;
1153
+ let taskPath = target.folder ?? "";
1154
+ let legacyAdopted = false;
1083
1155
  const assertClear = (phase, extra = {}) => {
1084
1156
  if (input.signal?.aborted) {
1085
1157
  throw notReadyError("serve --clear cancelled", {
1086
1158
  installed: extra.installed ?? false,
1087
1159
  ready: false,
1088
- task: SERVE_TASK_NAME,
1160
+ task: taskName,
1161
+ taskPath,
1162
+ legacy: legacyAdopted,
1089
1163
  phase,
1090
1164
  cause: "cancelled",
1091
1165
  ...extra,
@@ -1096,7 +1170,9 @@ export async function uninstallServeLogon(input = {}) {
1096
1170
  throw notReadyError("serve --clear timed out", {
1097
1171
  installed: extra.installed ?? false,
1098
1172
  ready: false,
1099
- task: SERVE_TASK_NAME,
1173
+ task: taskName,
1174
+ taskPath,
1175
+ legacy: legacyAdopted,
1100
1176
  phase,
1101
1177
  cause: "timeout",
1102
1178
  ...extra,
@@ -1110,7 +1186,11 @@ export async function uninstallServeLogon(input = {}) {
1110
1186
  timeoutMs: inspectBudget,
1111
1187
  signal: input.signal,
1112
1188
  secrets,
1189
+ target,
1113
1190
  });
1191
+ taskName = asString(inspected.taskName) || target.name;
1192
+ legacyAdopted = inspected.legacy === true;
1193
+ taskPath = legacyAdopted ? "" : serveTaskFolder(inspected.taskPath) || (target.folder ?? "");
1114
1194
  secrets.push(...tokenFromCommand(commandFromArguments(asString(inspected.arguments))));
1115
1195
  assertClear("unregister", { installed: inspected.exists === true });
1116
1196
  if (inspected.exists === true) {
@@ -1120,10 +1200,12 @@ export async function uninstallServeLogon(input = {}) {
1120
1200
  const existingUser = asString(inspected.userId);
1121
1201
  const verified = isWindowsSid(currentSid) && isWindowsSid(userSid) && sameWindowsSid(currentSid, userSid);
1122
1202
  if (!verified || privilegedSid(userSid) || privilegedAccount(existingUser)) {
1123
- throw notReadyError(`serve --clear will not remove ${SERVE_TASK_NAME} owned by ${existingUser || "an unverified account"}`, {
1203
+ throw notReadyError(`serve --clear will not remove ${taskName} owned by ${existingUser || "an unverified account"}`, {
1124
1204
  installed: true,
1125
1205
  ready: false,
1126
- task: SERVE_TASK_NAME,
1206
+ task: taskName,
1207
+ taskPath,
1208
+ legacy: legacyAdopted,
1127
1209
  phase: "inspect",
1128
1210
  cause: SERVE_TASK_OWNED_CODE,
1129
1211
  remedy: REMEDY.owner,
@@ -1141,13 +1223,16 @@ export async function uninstallServeLogon(input = {}) {
1141
1223
  timeoutMs: unregisterBudget,
1142
1224
  signal: input.signal,
1143
1225
  secrets,
1226
+ target,
1144
1227
  });
1145
1228
  }
1146
1229
  catch (err) {
1147
1230
  if (err instanceof ZellijError && (err.code === "cancelled" || err.code === "timeout")) {
1148
1231
  throw notReadyError(err.message, {
1149
1232
  ready: false,
1150
- task: SERVE_TASK_NAME,
1233
+ task: taskName,
1234
+ taskPath,
1235
+ legacy: legacyAdopted,
1151
1236
  phase: "unregister",
1152
1237
  cause: err.code,
1153
1238
  mutation: "uncertain",
@@ -1165,7 +1250,9 @@ export async function uninstallServeLogon(input = {}) {
1165
1250
  };
1166
1251
  assertClear("unregister", confirmed);
1167
1252
  return {
1168
- task: SERVE_TASK_NAME,
1253
+ task: taskName,
1254
+ taskPath,
1255
+ legacy: legacyAdopted,
1169
1256
  ...confirmed,
1170
1257
  };
1171
1258
  }
@@ -2,7 +2,35 @@ import { type Server, type Socket } from "node:net";
2
2
  import { type NetworkInterfacesFn, type ServeBindDeps, type TailscaleStatusRunner } from "./serve-bind.js";
3
3
  import type { OpsResult } from "./types.js";
4
4
  export declare const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
5
+ /**
6
+ * The Windows logon task name used before serve was per session. Installs
7
+ * without a session still use it, at the Task Scheduler root, exactly as they
8
+ * always have; an install with `--session` gets its own task under the folder
9
+ * below, so a second serve cannot overwrite the first.
10
+ */
5
11
  export declare const SERVE_TASK_NAME = "zswarm-serve";
12
+ /** Folder for per-session serve tasks, next to the crew tasks. */
13
+ export declare const SERVE_TASK_FOLDER = "\\zswarm\\";
14
+ export type ServeTaskTarget = {
15
+ /** `null` is the Task Scheduler root, where a sessionless install lives. */
16
+ folder: string | null;
17
+ name: string;
18
+ /** The session this task stands for, or null for the legacy singleton. */
19
+ session: string | null;
20
+ };
21
+ /**
22
+ * The task one serve install/clear owns: `\zswarm\zswarm-serve-<session>` when a
23
+ * session is named, otherwise the legacy root-folder `zswarm-serve` (so an
24
+ * install that never names a session behaves exactly as it did before).
25
+ */
26
+ export declare function serveTaskTarget(session?: string | null): ServeTaskTarget;
27
+ /**
28
+ * The session a stored task command says it serves, from the
29
+ * `set "ZSWARM_SESSION=<session>"` an install bakes into the task environment.
30
+ * This is how a legacy root-folder install is recognised as belonging to one
31
+ * session: without it, a `--session` install/clear leaves that task alone.
32
+ */
33
+ export declare function sessionFromCommand(command: string): string | null;
6
34
  /** One JSONL request; a client that never sends newline cannot grow forever. */
7
35
  export declare const SERVE_MAX_REQUEST_BYTES: number;
8
36
  /**
package/dist/ops/serve.js CHANGED
@@ -5,7 +5,42 @@ import { hostname } from "node:os";
5
5
  import { ZellijError } from "../errors.js";
6
6
  import { authorizeServeListen, SERVE_BIND_REMEDY, SERVE_BIND_VERIFY_TIMEOUT_MS, } from "./serve-bind.js";
7
7
  export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
8
+ /**
9
+ * The Windows logon task name used before serve was per session. Installs
10
+ * without a session still use it, at the Task Scheduler root, exactly as they
11
+ * always have; an install with `--session` gets its own task under the folder
12
+ * below, so a second serve cannot overwrite the first.
13
+ */
8
14
  export const SERVE_TASK_NAME = "zswarm-serve";
15
+ /** Folder for per-session serve tasks, next to the crew tasks. */
16
+ export const SERVE_TASK_FOLDER = "\\zswarm\\";
17
+ /** Task Scheduler rejects these in a name; sessions get no further validation. */
18
+ const SERVE_TASK_SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
19
+ /**
20
+ * The task one serve install/clear owns: `\zswarm\zswarm-serve-<session>` when a
21
+ * session is named, otherwise the legacy root-folder `zswarm-serve` (so an
22
+ * install that never names a session behaves exactly as it did before).
23
+ */
24
+ export function serveTaskTarget(session) {
25
+ const wanted = session?.trim();
26
+ if (!wanted)
27
+ return { folder: null, name: SERVE_TASK_NAME, session: null };
28
+ if (!SERVE_TASK_SESSION.test(wanted)) {
29
+ throw new ZellijError("bad_arg", `invalid session name ${JSON.stringify(session)}`);
30
+ }
31
+ return { folder: SERVE_TASK_FOLDER, name: `${SERVE_TASK_NAME}-${wanted}`, session: wanted };
32
+ }
33
+ /**
34
+ * The session a stored task command says it serves, from the
35
+ * `set "ZSWARM_SESSION=<session>"` an install bakes into the task environment.
36
+ * This is how a legacy root-folder install is recognised as belonging to one
37
+ * session: without it, a `--session` install/clear leaves that task alone.
38
+ */
39
+ export function sessionFromCommand(command) {
40
+ const match = /set "ZSWARM_SESSION=([^"]*)"/.exec(command);
41
+ const session = match?.[1]?.trim();
42
+ return session && SERVE_TASK_SESSION.test(session) ? session : null;
43
+ }
9
44
  /** One JSONL request; a client that never sends newline cannot grow forever. */
10
45
  export const SERVE_MAX_REQUEST_BYTES = 1024 * 1024;
11
46
  /**
package/dist/policy.js CHANGED
@@ -5,6 +5,7 @@ const WRITE_OPS = new Set([
5
5
  "keys",
6
6
  "interrupt",
7
7
  "spawn",
8
+ "restart",
8
9
  "close",
9
10
  "unworktree",
10
11
  "checkpoint",
package/dist/schema.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * One description of the zswarm surface. The MCP tool schema, the CLI flags,
3
3
  * and the CLI help text are all generated from it, so they cannot drift apart.
4
4
  */
5
- export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve", "doctor"];
5
+ export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "restart", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve", "doctor"];
6
6
  export type OpName = (typeof OP_NAMES)[number];
7
7
  /** Ops that address an existing pane through `to`. */
8
8
  export declare const TARGET_OPS: readonly OpName[];