@zswarm/core 0.2.4 → 0.2.6
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 +9 -2
- package/dist/host/crew-windows.d.ts +155 -0
- package/dist/host/crew-windows.js +556 -0
- package/dist/host/crew.d.ts +32 -2
- package/dist/host/crew.js +212 -23
- package/dist/host/services.d.ts +7 -4
- package/dist/host/services.js +18 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/schema.d.ts +27 -0
- package/dist/schema.js +112 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/host/crew.d.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
+
runPowerShell?: PowerShellExec;
|
|
113
|
+
}): Promise<Record<string, unknown>>;
|