@zswarm/core 0.2.3 → 0.2.5

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.
@@ -0,0 +1,556 @@
1
+ import { execFile } from "node:child_process";
2
+ import { ZellijError } from "../errors.js";
3
+ import { encodePowerShellCommand } from "../zellij/ipc.js";
4
+ /**
5
+ * Windows half of `zswarm crew`.
6
+ *
7
+ * A background Zellij session started with `zellij attach --create-background`
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
10
+ * `\zswarm\` plus a minimized attached-client console window:
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
17
+ *
18
+ * This module is pure PowerShell text (unit-tested by text) plus thin async
19
+ * wrappers. It deliberately mirrors `buildServeTaskScript` in
20
+ * `ops/serve-install.ts`: same preamble helpers, same base64 UTF-8 round-trip
21
+ * for the cmd.exe action body, same `-EncodedCommand` invocation. It must not
22
+ * import `./crew.js`; that module imports this one.
23
+ */
24
+ /** Scheduled-task folder every crew task lives in. */
25
+ 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
+ /** Same shape the `zswarm crew` session validator uses; duplicated to avoid a cycle. */
29
+ 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. */
31
+ const DEFAULT_CREW_TIMEOUT_MS = 20_000;
32
+ function checkSession(session) {
33
+ const text = session?.trim() ?? "";
34
+ if (!CREW_SESSION.test(text)) {
35
+ throw new ZellijError("bad_arg", `invalid session name ${JSON.stringify(session)}`);
36
+ }
37
+ return text;
38
+ }
39
+ /** `zswarm-crew-<session>`: task names, and the client window's title marker. */
40
+ export function crewTaskNames(session) {
41
+ const start = `zswarm-crew-${checkSession(session)}`;
42
+ return { folder: CREW_TASK_FOLDER, start, watch: `${start}-watch` };
43
+ }
44
+ /** The token that proves a console window is this crew's client: its title and
45
+ * the string in its launcher's command line. */
46
+ export function crewClientMarker(session) {
47
+ return `zswarm-crew-${checkSession(session)}`;
48
+ }
49
+ /**
50
+ * A task registration is a readable, long-lived artifact, so only the two
51
+ * variables a crew task cannot discover on its own are serialized — never the
52
+ * caller's whole environment, which can hold tokens. PATH is deliberately not
53
+ * one of them: the task runs as the signed-in user and inherits their PATH, and
54
+ * a host PATH (kilometres of it on Windows) would blow the ~32k Windows
55
+ * command-line limit once the action is base64-encoded inside this script.
56
+ */
57
+ const CREW_TASK_ENV_KEYS = ["ZSWARM_BIN", "ZSWARM_STATE_DIR"];
58
+ function crewEnvAssignments(env) {
59
+ const assignments = [];
60
+ for (const key of CREW_TASK_ENV_KEYS) {
61
+ const value = env[key]?.trim();
62
+ if (!value)
63
+ continue;
64
+ if (/[\0\r\n"%]/.test(value)) {
65
+ throw new ZellijError("bad_arg", `${key} must not contain quotes, newlines, or %`);
66
+ }
67
+ assignments.push(`set "${key}=${value}"`);
68
+ }
69
+ return assignments;
70
+ }
71
+ /** The cmd.exe action body for a task: `set "K=V"&& ...` then the CLI argv. */
72
+ export function crewTaskCommand(input) {
73
+ if (input.mode !== "start" && input.mode !== "check") {
74
+ throw new ZellijError("bad_arg", `unknown crew task mode ${String(input.mode)}`);
75
+ }
76
+ const session = checkSession(input.session);
77
+ for (const [label, value] of [
78
+ ["execPath", input.launch.execPath],
79
+ ["scriptPath", input.launch.scriptPath],
80
+ ]) {
81
+ if (!value || /[\0\r\n"]/.test(value)) {
82
+ throw new ZellijError("bad_arg", `crew task ${label} must not contain quotes or newlines`);
83
+ }
84
+ }
85
+ const argv = `"${input.launch.execPath}" "${input.launch.scriptPath}" crew ${input.mode} ${session}`;
86
+ const assignments = crewEnvAssignments(input.env);
87
+ const line = assignments.length === 0 ? argv : `${assignments.join("&& ")}&& ${argv}`;
88
+ // The task action is `cmd.exe /c <line>`, and cmd strips the first and last
89
+ // quote of a `/c "..."` tail: on a line that starts with the program's own
90
+ // quote that costs the program its opening quote ("The filename, directory
91
+ // name, or volume label syntax is incorrect"). Wrapping the whole line in one
92
+ // more pair makes the stripping harmless — the line still ends with the
93
+ // unquoted session name, so nothing after it is lost.
94
+ return `"${line}"`;
95
+ }
96
+ const PS_PREAMBLE = [
97
+ "$ErrorActionPreference = 'Stop'",
98
+ "$ProgressPreference = 'SilentlyContinue'",
99
+ "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
100
+ "function Get-ZswarmIdentity {",
101
+ " $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()",
102
+ " if (-not $id -or -not $id.User -or [string]::IsNullOrWhiteSpace($id.User.Value)) {",
103
+ " throw 'cannot resolve the current Windows SID for the Interactive crew task'",
104
+ " }",
105
+ " $name = [string]$id.Name",
106
+ " if ([string]::IsNullOrWhiteSpace($name)) {",
107
+ " throw 'cannot resolve the current Windows account name for the Interactive crew task'",
108
+ " }",
109
+ " return @{ name = $name; sid = [string]$id.User.Value }",
110
+ "}",
111
+ "function Resolve-ZswarmSid($account) {",
112
+ " if ($null -eq $account) { return $null }",
113
+ " $text = [string]$account",
114
+ " if ([string]::IsNullOrWhiteSpace($text)) { return $null }",
115
+ " if ($text -match '^S-\\d-') { return $text }",
116
+ " try {",
117
+ " $nt = New-Object System.Security.Principal.NTAccount($text)",
118
+ " $sid = $nt.Translate([System.Security.Principal.SecurityIdentifier])",
119
+ " if (-not $sid -or [string]::IsNullOrWhiteSpace($sid.Value)) { return $null }",
120
+ " return [string]$sid.Value",
121
+ " } catch {",
122
+ " return $null",
123
+ " }",
124
+ "}",
125
+ "function Assert-ZswarmCurrentOwner($task) {",
126
+ " $identity = Get-ZswarmIdentity",
127
+ " $existingSid = Resolve-ZswarmSid ([string]$task.Principal.UserId)",
128
+ " if ([string]::IsNullOrWhiteSpace($existingSid) -or $existingSid -ne $identity.sid) {",
129
+ " throw 'a zswarm crew task is owned by a different or unverified Windows account'",
130
+ " }",
131
+ " return $identity",
132
+ "}",
133
+ "function Write-ZswarmJson($obj) {",
134
+ " $json = $obj | ConvertTo-Json -Compress -Depth 6",
135
+ " [Console]::Out.Write($json)",
136
+ "}",
137
+ `function Get-ZswarmTask($name) { Get-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}' -ErrorAction Stop }`,
138
+ "function Test-ZswarmMissing($err) {",
139
+ " return $err.CategoryInfo.Category -eq 'ObjectNotFound' -or ([string]$err.FullyQualifiedErrorId -match 'ObjectNotFound')",
140
+ "}",
141
+ ].join("\n");
142
+ /** The client scripts do not schedule anything, so they only need JSON output. */
143
+ const PS_CLIENT_PREAMBLE = [
144
+ "$ErrorActionPreference = 'Stop'",
145
+ "$ProgressPreference = 'SilentlyContinue'",
146
+ "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
147
+ "function Write-ZswarmJson($obj) {",
148
+ " $json = $obj | ConvertTo-Json -Compress -Depth 6",
149
+ " [Console]::Out.Write($json)",
150
+ "}",
151
+ ].join("\n");
152
+ /**
153
+ * PIDs of our client windows. Image name alone is never identity: the marker
154
+ * must be in the command line, because every other `powershell.exe` (including
155
+ * this helper's own `-EncodedCommand` process) has an unrelated command line.
156
+ */
157
+ function psClientPidLine(marker) {
158
+ // Match the whole `title <marker> &&` token crewClientCommand writes, not the
159
+ // bare marker: crew "crew" must never match crew "crew-2"'s window.
160
+ return `$clientPids = @(Get-CimInstance Win32_Process | Where-Object { ($_.Name -eq 'cmd.exe' -or $_.Name -eq 'powershell.exe') -and $_.CommandLine -and $_.CommandLine.Contains('title ${marker} &&') } | ForEach-Object { [int]$_.ProcessId })`;
161
+ }
162
+ /** Pure PowerShell text, unit-tested by text. */
163
+ export function buildCrewTaskScript(action, params) {
164
+ const names = crewTaskNames(params.session);
165
+ if (action === "register") {
166
+ // Base64 UTF-8 round-trip, exactly like serve's register: neither the
167
+ // command nor its quoting ever appears in the script text.
168
+ 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
+ return [
172
+ PS_PREAMBLE,
173
+ `$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${startB64}'))`,
174
+ `$startName = '${names.start}'`,
175
+ `$watchName = '${names.watch}'`,
176
+ "$identity = Get-ZswarmIdentity",
177
+ "foreach ($name in @($startName, $watchName)) {",
178
+ " try {",
179
+ " $existing = Get-ZswarmTask $name",
180
+ " [void](Assert-ZswarmCurrentOwner $existing)",
181
+ " } catch {",
182
+ " if (-not (Test-ZswarmMissing $_)) { throw }",
183
+ " }",
184
+ "}",
185
+ "$userId = $identity.name",
186
+ // Highest, unlike serve's Limited: the crew must own the desktop session
187
+ // and its Zellij console window.
188
+ "$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest",
189
+ "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable",
190
+ "$startAction = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
191
+ "$startTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId",
192
+ `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`,
199
+ "Write-ZswarmJson @{",
200
+ " registered = $true",
201
+ " userId = $userId",
202
+ " userSid = $identity.sid",
203
+ " logonType = 'Interactive'",
204
+ " runLevel = 'Highest'",
205
+ ` folder = '${CREW_TASK_FOLDER}'`,
206
+ " startTask = $startName",
207
+ " watchTask = $watchName",
208
+ "}",
209
+ ].join("\n");
210
+ }
211
+ if (action === "inspect") {
212
+ const marker = crewClientMarker(params.session);
213
+ return [
214
+ PS_PREAMBLE,
215
+ `$startName = '${names.start}'`,
216
+ `$watchName = '${names.watch}'`,
217
+ `$marker = '${marker}'`,
218
+ "$tasks = @{}",
219
+ "foreach ($pair in @(@{ key = 'start'; name = $startName }, @{ key = 'watch'; name = $watchName })) {",
220
+ " try {",
221
+ " $task = Get-ZswarmTask $pair.name",
222
+ " } catch {",
223
+ " if (Test-ZswarmMissing $_) {",
224
+ " $tasks[$pair.key] = @{ exists = $false; state = 'missing'; lastResult = $null; lastRunTime = $null; userId = $null; userSid = $null; logonType = $null; runLevel = $null; execute = $null; arguments = $null }",
225
+ " continue",
226
+ " }",
227
+ " throw",
228
+ " }",
229
+ " $action = @($task.Actions)[0]",
230
+ ` $info = Get-ScheduledTaskInfo -TaskName $pair.name -TaskPath '${CREW_TASK_FOLDER}'`,
231
+ " $lastRun = $null",
232
+ " if ($info.LastRunTime -and $info.LastRunTime.Year -gt 1900) { $lastRun = $info.LastRunTime.ToString('o') }",
233
+ " $tasks[$pair.key] = @{",
234
+ " exists = $true",
235
+ " state = [string]$task.State",
236
+ " lastResult = $info.LastTaskResult",
237
+ " lastRunTime = $lastRun",
238
+ " userId = [string]$task.Principal.UserId",
239
+ " userSid = (Resolve-ZswarmSid ([string]$task.Principal.UserId))",
240
+ " logonType = [string]$task.Principal.LogonType",
241
+ " runLevel = [string]$task.Principal.RunLevel",
242
+ " execute = [string]$action.Execute",
243
+ " arguments = [string]$action.Arguments",
244
+ " }",
245
+ "}",
246
+ psClientPidLine(marker),
247
+ "Write-ZswarmJson @{",
248
+ " start = $tasks['start']",
249
+ " watch = $tasks['watch']",
250
+ " clientPresent = ($clientPids.Count -gt 0)",
251
+ " clientPids = @($clientPids)",
252
+ " clientCount = $clientPids.Count",
253
+ "}",
254
+ ].join("\n");
255
+ }
256
+ if (action === "start") {
257
+ return [
258
+ PS_PREAMBLE,
259
+ `$startName = '${names.start}'`,
260
+ `$watchName = '${names.watch}'`,
261
+ "foreach ($name in @($startName, $watchName)) {",
262
+ " try {",
263
+ " $task = Get-ZswarmTask $name",
264
+ " [void](Assert-ZswarmCurrentOwner $task)",
265
+ ` Start-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}'`,
266
+ " } catch {",
267
+ " if (-not (Test-ZswarmMissing $_)) { throw }",
268
+ " }",
269
+ "}",
270
+ "Write-ZswarmJson @{ started = $true; startTask = $startName; watchTask = $watchName }",
271
+ ].join("\n");
272
+ }
273
+ return [
274
+ PS_PREAMBLE,
275
+ `$startName = '${names.start}'`,
276
+ `$watchName = '${names.watch}'`,
277
+ "foreach ($name in @($startName, $watchName)) {",
278
+ " try {",
279
+ " $task = Get-ZswarmTask $name",
280
+ " [void](Assert-ZswarmCurrentOwner $task)",
281
+ " try {",
282
+ ` Stop-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}'`,
283
+ " } catch {",
284
+ " if (-not (Test-ZswarmMissing $_)) { throw }",
285
+ " }",
286
+ ` Unregister-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}' -Confirm:$false`,
287
+ " } catch {",
288
+ " if (-not (Test-ZswarmMissing $_)) { throw }",
289
+ " }",
290
+ "}",
291
+ "Write-ZswarmJson @{ cleared = $true; startTask = $startName; watchTask = $watchName }",
292
+ ].join("\n");
293
+ }
294
+ /**
295
+ * The command line the client window runs. The marker is both the window title
296
+ * and the token `ensure`/`close` look for in a process command line; the crew
297
+ * CLI does the actual `zellij attach --create`, so the window can read the crew
298
+ * dir (and its env file) itself instead of taking secrets on a command line.
299
+ */
300
+ export function crewClientCommand(input) {
301
+ const session = checkSession(input.session);
302
+ const quote = (value) => `"${value.replace(/"/g, '""')}"`;
303
+ return `title ${crewClientMarker(session)} && ${quote(input.launch.execPath)} ${quote(input.launch.scriptPath)} crew client ${session}`;
304
+ }
305
+ /** The minimized attached-client window that makes a Windows Zellij session render. */
306
+ export function buildCrewClientScript(action, params) {
307
+ const session = checkSession(params.session);
308
+ const marker = crewClientMarker(session);
309
+ if (action === "ensure") {
310
+ // `cmd.exe /c <line>`: Start-Process joins -ArgumentList with spaces and
311
+ // does not re-quote the elements, so cmd.exe gets the whole line as its /c
312
+ // command with no quoting layers of our own. `title` names the window and
313
+ // puts the marker in the launcher's command line, which is what later finds
314
+ // and closes this window. The launcher stays alive while its child runs, so
315
+ // its process is also the tree `close` kills.
316
+ const line = params.clientCommand ?? crewClientCommand({
317
+ session,
318
+ launch: { execPath: "node", scriptPath: "zswarm" },
319
+ });
320
+ return [
321
+ PS_CLIENT_PREAMBLE,
322
+ `$marker = '${marker}'`,
323
+ `$line = '${line.replace(/'/g, "''")}'`,
324
+ psClientPidLine(marker),
325
+ "if ($clientPids.Count -gt 0) {",
326
+ " Write-ZswarmJson @{ opened = $false; present = $true; pid = $clientPids[0]; title = $marker; count = $clientPids.Count }",
327
+ " exit 0",
328
+ "}",
329
+ "$proc = Start-Process -FilePath 'cmd.exe' -WindowStyle Minimized -PassThru -ArgumentList @('/c', $line)",
330
+ "Write-ZswarmJson @{ opened = $true; present = $true; pid = [int]$proc.Id; title = $marker; count = 1 }",
331
+ ].join("\n");
332
+ }
333
+ return [
334
+ PS_CLIENT_PREAMBLE,
335
+ `$marker = '${marker}'`,
336
+ psClientPidLine(marker),
337
+ "$closed = @()",
338
+ "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",
342
+ " $closed += $clientPid",
343
+ "}",
344
+ "Write-ZswarmJson @{ closed = @($closed); count = $closed.Count }",
345
+ ].join("\n");
346
+ }
347
+ /** Last line that parses as a JSON object, else the first; null when none does. */
348
+ export function parseCrewJson(stdout) {
349
+ let found = null;
350
+ for (const line of stdout.split(/\r?\n/)) {
351
+ const text = line.trim();
352
+ if (!text.startsWith("{") || !text.endsWith("}"))
353
+ continue;
354
+ let value;
355
+ try {
356
+ value = JSON.parse(text);
357
+ }
358
+ catch {
359
+ continue;
360
+ }
361
+ if (value && typeof value === "object" && !Array.isArray(value)) {
362
+ found = value;
363
+ }
364
+ }
365
+ if (found)
366
+ return found;
367
+ // A single pretty-printed object spans lines and never parses per line.
368
+ const text = stdout.trim();
369
+ if (!text.startsWith("{") || !text.endsWith("}"))
370
+ return null;
371
+ try {
372
+ const value = JSON.parse(text);
373
+ if (value && typeof value === "object" && !Array.isArray(value)) {
374
+ return value;
375
+ }
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ return null;
381
+ }
382
+ /**
383
+ * `powershell -EncodedCommand` costs two bytes of UTF-16 per script character,
384
+ * then base64 inflates that by 4/3; Windows refuses a command line over ~32k
385
+ * characters with a bare `ENAMETOOLONG`, so a script that cannot fit is
386
+ * reported as itself instead of as a spawn failure.
387
+ */
388
+ const MAX_ENCODED_SCRIPT = 30_000;
389
+ /** Powershell runner used in production; injected in tests. Never throws. */
390
+ export const defaultPowerShellExec = (script, options) => {
391
+ const timeout = Math.floor(options.timeoutMs);
392
+ if (timeout <= 0) {
393
+ return Promise.resolve({ code: 1, stdout: "", stderr: "no remaining budget" });
394
+ }
395
+ const encoded = encodePowerShellCommand(script);
396
+ if (encoded.length > MAX_ENCODED_SCRIPT) {
397
+ return Promise.resolve({
398
+ code: 1,
399
+ stdout: "",
400
+ stderr: `the generated PowerShell is ${encoded.length} characters, too long for a Windows command line`,
401
+ });
402
+ }
403
+ return new Promise((resolve) => {
404
+ execFile("powershell.exe", [
405
+ "-NoProfile",
406
+ "-ExecutionPolicy",
407
+ "Bypass",
408
+ "-EncodedCommand",
409
+ encoded,
410
+ ], {
411
+ windowsHide: true,
412
+ timeout,
413
+ signal: options.signal,
414
+ maxBuffer: 1024 * 1024,
415
+ }, (error, stdout, stderr) => {
416
+ if (error) {
417
+ const err = error;
418
+ const text = String(stderr ?? "").trim();
419
+ resolve({ code: 1, stdout: String(stdout ?? ""), stderr: text || err.message });
420
+ return;
421
+ }
422
+ resolve({ code: 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
423
+ });
424
+ });
425
+ };
426
+ function crewTimeout(timeoutMs) {
427
+ return Math.max(1, Math.floor(timeoutMs ?? DEFAULT_CREW_TIMEOUT_MS));
428
+ }
429
+ async function crewRun(run, script, timeoutMs, signal) {
430
+ const result = await run(script, { timeoutMs, signal });
431
+ if (result.code !== 0) {
432
+ const detail = (result.stderr || result.stdout).trim().slice(0, 300);
433
+ throw new ZellijError("failed", detail || "zswarm crew helper failed");
434
+ }
435
+ const parsed = parseCrewJson(result.stdout);
436
+ if (!parsed)
437
+ throw new ZellijError("failed", "zswarm crew helper returned no JSON");
438
+ return parsed;
439
+ }
440
+ function asTaskState(name, task, raw) {
441
+ const info = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
442
+ const exists = info.exists === true;
443
+ return {
444
+ name,
445
+ task,
446
+ path: `${CREW_TASK_FOLDER}${task}`,
447
+ state: exists ? String(info.state ?? "Unknown") : "missing",
448
+ active: exists,
449
+ lastResult: typeof info.lastResult === "number" ? info.lastResult : null,
450
+ lastRun: typeof info.lastRunTime === "string" && info.lastRunTime ? info.lastRunTime : null,
451
+ };
452
+ }
453
+ function clientPids(value) {
454
+ // A one-element `@(...)` can serialize as a scalar, not an array.
455
+ if (typeof value === "number" && Number.isFinite(value))
456
+ return [value];
457
+ if (!Array.isArray(value))
458
+ return [];
459
+ return value.filter((item) => typeof item === "number" && Number.isFinite(item));
460
+ }
461
+ export async function inspectCrewTasks(input) {
462
+ const session = checkSession(input.session);
463
+ const script = buildCrewTaskScript("inspect", { session });
464
+ const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
465
+ const names = crewTaskNames(session);
466
+ const pids = clientPids(data.clientPids);
467
+ const count = typeof data.clientCount === "number" && Number.isFinite(data.clientCount)
468
+ ? data.clientCount
469
+ : pids.length;
470
+ return {
471
+ tasks: [
472
+ asTaskState("start", names.start, data.start),
473
+ asTaskState("watch", names.watch, data.watch),
474
+ ],
475
+ client: {
476
+ present: data.clientPresent === true,
477
+ pid: pids.length > 0 ? pids[0] : null,
478
+ count,
479
+ },
480
+ };
481
+ }
482
+ export async function installCrewTasks(input) {
483
+ const session = checkSession(input.session);
484
+ const names = crewTaskNames(session);
485
+ const intervalMinutes = Math.max(1, Math.floor(input.intervalMinutes ?? CREW_WATCH_MINUTES));
486
+ 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.
497
+ await crewRun(input.runPowerShell, buildCrewTaskScript("start", { session }), timeoutMs, input.signal);
498
+ const inspected = await inspectCrewTasks({
499
+ session,
500
+ runPowerShell: input.runPowerShell,
501
+ timeoutMs,
502
+ signal: input.signal,
503
+ });
504
+ return {
505
+ lines: [
506
+ `${names.folder}${names.start} registered and started`,
507
+ `${names.folder}${names.watch} registered and started`,
508
+ ],
509
+ notes: [
510
+ "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.`,
512
+ ],
513
+ tasks: inspected.tasks,
514
+ client: inspected.client,
515
+ };
516
+ }
517
+ export async function removeCrewTasks(input) {
518
+ const session = checkSession(input.session);
519
+ const names = crewTaskNames(session);
520
+ const timeoutMs = crewTimeout(input.timeoutMs);
521
+ const script = buildCrewTaskScript("unregister", { session });
522
+ await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
523
+ const inspected = await inspectCrewTasks({
524
+ session,
525
+ runPowerShell: input.runPowerShell,
526
+ timeoutMs,
527
+ signal: input.signal,
528
+ });
529
+ return {
530
+ lines: [`${names.folder}${names.start} removed`, `${names.folder}${names.watch} removed`],
531
+ tasks: inspected.tasks,
532
+ };
533
+ }
534
+ export async function ensureCrewClient(input) {
535
+ const session = checkSession(input.session);
536
+ const title = crewClientMarker(session);
537
+ const script = buildCrewClientScript("ensure", { session, clientCommand: input.clientCommand });
538
+ const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
539
+ const pid = typeof data.pid === "number" && Number.isFinite(data.pid) ? data.pid : null;
540
+ const count = typeof data.count === "number" && Number.isFinite(data.count) ? data.count : pid === null ? 0 : 1;
541
+ return {
542
+ opened: data.opened === true,
543
+ pid,
544
+ title: typeof data.title === "string" && data.title ? data.title : title,
545
+ present: data.present === true,
546
+ count,
547
+ };
548
+ }
549
+ export async function closeCrewClient(input) {
550
+ const session = checkSession(input.session);
551
+ const script = buildCrewClientScript("close", { session });
552
+ const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
553
+ const closed = clientPids(data.closed);
554
+ const count = typeof data.count === "number" && Number.isFinite(data.count) ? data.count : closed.length;
555
+ return { closed, count };
556
+ }
@@ -1,3 +1,4 @@
1
+ import { type PowerShellExec } from "./crew-windows.js";
1
2
  import { type Runner, type ServiceAccount, type ServiceHost, type ServiceSpec } from "./services.js";
2
3
  export declare const CREW_WATCH_SEC = 120;
3
4
  export type CrewDeps = {
@@ -23,6 +24,24 @@ export type CrewDeps = {
23
24
  log?: (line: string) => void;
24
25
  /** Restarts a systemd user unit (the watchdog on Linux); injected in tests. */
25
26
  restartUnit?: (unit: string) => number;
27
+ /** The account the crew runs for; injected in tests (a win32 account cannot run real tasks in CI). */
28
+ account?: ServiceAccount;
29
+ /** Runs the crew's scheduled-task PowerShell on Windows; injected in tests. */
30
+ runPowerShell?: PowerShellExec;
31
+ /** The CLI launcher the client window runs; defaults to this process's. */
32
+ launch?: {
33
+ execPath: string;
34
+ scriptPath: string;
35
+ };
36
+ /** The attached client window on Windows; injected in tests. */
37
+ client?: {
38
+ ensure: (session: string) => Promise<{
39
+ opened: boolean;
40
+ pid: number | null;
41
+ count: number;
42
+ }>;
43
+ close: (session: string) => Promise<number[]>;
44
+ };
26
45
  };
27
46
  /** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
28
47
  export declare const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
@@ -34,6 +53,13 @@ export declare function firstLayoutPane(kdl: string): string | undefined;
34
53
  export declare function parseEnvFile(text: string): Record<string, string>;
35
54
  /** Start the session from its layout and prove the first pane exists; up to three tries. */
36
55
  export declare function crewStart(session: string, deps?: CrewDeps): Promise<number>;
56
+ /**
57
+ * `crew client`: what the minimized console window runs. It reads the crew dir
58
+ * (so an env file's variables reach Zellij without being written into a task or
59
+ * a command line) and attaches with `--create`, which is what makes a Windows
60
+ * session render — a background-only session leaves pane grids empty.
61
+ */
62
+ export declare function crewClient(session: string, deps?: CrewDeps): number;
37
63
  /** Watchdog: restart the session if its server died or its first pane is gone. */
38
64
  export declare function crewCheck(session: string, deps?: CrewDeps): Promise<number>;
39
65
  export type CrewUpInput = {
@@ -54,6 +80,8 @@ export type CrewUpInput = {
54
80
  /** Copies crew files into the crew dir; injected in tests. */
55
81
  copy?: (account: ServiceAccount, from: string, to: string, secret: boolean) => void;
56
82
  writeMeta?: (account: ServiceAccount, path: string, text: string) => void;
83
+ /** Runs the crew's scheduled-task PowerShell on Windows; injected in tests. */
84
+ runPowerShell?: PowerShellExec;
57
85
  };
58
86
  export declare function crewServiceSpecs(input: {
59
87
  account: ServiceAccount;
@@ -64,7 +92,7 @@ export declare function crewServiceSpecs(input: {
64
92
  };
65
93
  env: NodeJS.ProcessEnv;
66
94
  }): ServiceSpec[];
67
- export declare function crewUp(input: CrewUpInput): Record<string, unknown>;
95
+ export declare function crewUp(input: CrewUpInput): Promise<Record<string, unknown>>;
68
96
  export declare function crewStatus(input: {
69
97
  session: string;
70
98
  runAs?: string;
@@ -72,6 +100,7 @@ export declare function crewStatus(input: {
72
100
  account?: ServiceAccount;
73
101
  run?: Runner;
74
102
  deps?: CrewDeps;
103
+ runPowerShell?: PowerShellExec;
75
104
  }): Promise<Record<string, unknown>>;
76
105
  export declare function crewDown(input: {
77
106
  session: string;
@@ -80,4 +109,5 @@ export declare function crewDown(input: {
80
109
  account?: ServiceAccount;
81
110
  run?: Runner;
82
111
  fs?: ServiceHost["fs"];
83
- }): Record<string, unknown>;
112
+ runPowerShell?: PowerShellExec;
113
+ }): Promise<Record<string, unknown>>;