@zswarm/core 0.2.2 → 0.2.4
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/assets/host-install.sh +36 -2
- package/assets/host-report.sh +15 -1
- package/assets/slot.py +35 -1
- package/dist/harness.d.ts +20 -0
- package/dist/harness.js +64 -1
- package/dist/host/cli.js +78 -4
- package/dist/host/crew.d.ts +2 -0
- package/dist/host/crew.js +13 -0
- package/dist/host/host.js +13 -0
- package/dist/host/lease.d.ts +68 -0
- package/dist/host/lease.js +138 -0
- package/dist/host/relay.d.ts +80 -1
- package/dist/host/relay.js +291 -9
- package/dist/host/services.d.ts +9 -2
- package/dist/host/services.js +105 -9
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/ops/delivery.d.ts +51 -2
- package/dist/ops/delivery.js +162 -31
- package/dist/ops/dispatch.js +151 -14
- package/dist/ops/util.d.ts +9 -0
- package/dist/ops/util.js +24 -0
- package/dist/schema.js +50 -3
- package/dist/state.d.ts +20 -0
- package/dist/state.js +20 -0
- package/dist/zellij/args.d.ts +1 -1
- package/dist/zellij/args.js +3 -1
- package/dist/zellij/bus.js +1 -0
- package/dist/zellij/client.d.ts +2 -0
- package/dist/zellij/client.js +17 -1
- package/dist/zellij/panes.d.ts +1 -0
- package/dist/zellij/panes.js +3 -0
- package/dist/zellij/session.d.ts +11 -0
- package/dist/zellij/session.js +35 -3
- package/package.json +1 -1
package/dist/host/services.js
CHANGED
|
@@ -11,6 +11,14 @@ export const defaultRunner = (cmd, args) => {
|
|
|
11
11
|
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
|
12
12
|
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
|
|
13
13
|
};
|
|
14
|
+
/** A synchronous sleep for install paths (Node allows Atomics.wait on its main thread). */
|
|
15
|
+
function defaultSleep(ms) {
|
|
16
|
+
if (ms > 0)
|
|
17
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
18
|
+
}
|
|
19
|
+
function hostSleep(host, ms) {
|
|
20
|
+
(host.sleep ?? defaultSleep)(ms);
|
|
21
|
+
}
|
|
14
22
|
/** The account services are installed for: the current user, or `runAs` when root. */
|
|
15
23
|
export function serviceAccount(runAs, run = defaultRunner) {
|
|
16
24
|
const platform = process.platform;
|
|
@@ -181,11 +189,30 @@ function launchdPlistPath(account, label) {
|
|
|
181
189
|
function xml(text) {
|
|
182
190
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
183
191
|
}
|
|
184
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The account's per-user temp dir. launchd gives daemons no TMPDIR, so Zellij
|
|
194
|
+
* would fall back to it anyway; naming it explicitly lets every caller (an SSH
|
|
195
|
+
* shell, a script that sets TMPDIR) agree on one Zellij socket dir. Resolved as
|
|
196
|
+
* the account so root writing a foreign LaunchDaemon gets that account's dir.
|
|
197
|
+
*/
|
|
198
|
+
function darwinUserTempDir(account, run) {
|
|
199
|
+
const argv = [...(account.foreign ? accountArgv(account) : []), "getconf", "DARWIN_USER_TEMP_DIR"];
|
|
200
|
+
const [cmd, ...args] = argv;
|
|
201
|
+
if (!cmd)
|
|
202
|
+
return undefined;
|
|
203
|
+
const res = run(cmd, args);
|
|
204
|
+
const dir = res.status === 0 ? res.stdout.trim() : "";
|
|
205
|
+
return dir || undefined;
|
|
206
|
+
}
|
|
207
|
+
export function launchdPlist(account, spec, run = defaultRunner) {
|
|
185
208
|
checkName(spec.name);
|
|
186
209
|
const label = launchdLabel(account, spec.name);
|
|
187
210
|
const log = join(account.home, "Library", "Logs", "zswarm", `${spec.name}.log`);
|
|
188
|
-
const env = { HOME: account.home, USER: account.user
|
|
211
|
+
const env = { HOME: account.home, USER: account.user };
|
|
212
|
+
const tmpdir = darwinUserTempDir(account, run);
|
|
213
|
+
if (tmpdir)
|
|
214
|
+
env.TMPDIR = tmpdir;
|
|
215
|
+
Object.assign(env, spec.env ?? {});
|
|
189
216
|
const lines = [
|
|
190
217
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
191
218
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -217,6 +244,36 @@ function launchdDomain(host) {
|
|
|
217
244
|
const gui = `gui/${a.uid}`;
|
|
218
245
|
return host.run("launchctl", ["print", gui]).status === 0 ? gui : `user/${a.uid}`;
|
|
219
246
|
}
|
|
247
|
+
/** launchd removes a booted-out job asynchronously; wait until print stops seeing it. */
|
|
248
|
+
const BOOTOUT_WAIT_MS = 10_000;
|
|
249
|
+
const BOOTOUT_POLL_MS = 250;
|
|
250
|
+
/** bootstrap racing that teardown fails with EIO (5) or 37; retry a couple of times. */
|
|
251
|
+
const BOOTSTRAP_RETRIES = 2;
|
|
252
|
+
const BOOTSTRAP_RETRY_MS = 1_000;
|
|
253
|
+
function waitForBootout(host, domain, label) {
|
|
254
|
+
const deadline = Date.now() + BOOTOUT_WAIT_MS;
|
|
255
|
+
for (;;) {
|
|
256
|
+
if (host.run("launchctl", ["print", `${domain}/${label}`]).status !== 0)
|
|
257
|
+
return;
|
|
258
|
+
const left = deadline - Date.now();
|
|
259
|
+
if (left <= 0)
|
|
260
|
+
return;
|
|
261
|
+
hostSleep(host, Math.min(BOOTOUT_POLL_MS, left));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function bootstrapLaunchd(host, domain, path, label) {
|
|
265
|
+
let last;
|
|
266
|
+
for (let attempt = 0; attempt <= BOOTSTRAP_RETRIES; attempt++) {
|
|
267
|
+
const res = host.run("launchctl", ["bootstrap", domain, path]);
|
|
268
|
+
if (res.status === 0)
|
|
269
|
+
return;
|
|
270
|
+
last = res;
|
|
271
|
+
if (!/Bootstrap failed: (5|37):/.test(res.stderr || res.stdout) || attempt === BOOTSTRAP_RETRIES)
|
|
272
|
+
break;
|
|
273
|
+
hostSleep(host, BOOTSTRAP_RETRY_MS);
|
|
274
|
+
}
|
|
275
|
+
must(last, `launchctl bootstrap ${domain} ${label}`);
|
|
276
|
+
}
|
|
220
277
|
// ------------------------------------------------------------------ common
|
|
221
278
|
/** Write, (re)load and start the services. Returns what it did, one line per unit. */
|
|
222
279
|
export function installServices(host, specs) {
|
|
@@ -257,9 +314,10 @@ export function installServices(host, specs) {
|
|
|
257
314
|
for (const spec of specs) {
|
|
258
315
|
const label = launchdLabel(a, spec.name);
|
|
259
316
|
const path = launchdPlistPath(a, label);
|
|
260
|
-
fs.write(path, launchdPlist(a, spec), 0o644);
|
|
317
|
+
fs.write(path, launchdPlist(a, spec, host.run), 0o644);
|
|
261
318
|
host.run("launchctl", ["bootout", `${domain}/${label}`]);
|
|
262
|
-
|
|
319
|
+
waitForBootout(host, domain, label);
|
|
320
|
+
bootstrapLaunchd(host, domain, path, label);
|
|
263
321
|
lines.push(`${label} loaded in ${domain}`);
|
|
264
322
|
}
|
|
265
323
|
return { lines, notes };
|
|
@@ -298,7 +356,16 @@ export function serviceStates(host, specs) {
|
|
|
298
356
|
return specs.map((spec) => {
|
|
299
357
|
const unit = systemdUnitName(spec.name, spec.kind);
|
|
300
358
|
const state = systemctl(host, ["is-active", unit]).stdout.trim() || "unknown";
|
|
301
|
-
|
|
359
|
+
const props = systemctl(host, ["show", unit, "-p", "MainPID", "-p", "ExecMainStatus"]).stdout;
|
|
360
|
+
const pid = Number(/^MainPID=(\d+)$/m.exec(props)?.[1] ?? NaN);
|
|
361
|
+
const exit = /^ExecMainStatus=(-?\d+)$/m.exec(props)?.[1];
|
|
362
|
+
const lastExit = exit === undefined ? undefined : Number(exit);
|
|
363
|
+
return {
|
|
364
|
+
name: spec.name,
|
|
365
|
+
unit,
|
|
366
|
+
active: state === "active",
|
|
367
|
+
...describeJob(spec, state, Number.isFinite(pid) && pid > 0 ? pid : undefined, lastExit),
|
|
368
|
+
};
|
|
302
369
|
});
|
|
303
370
|
}
|
|
304
371
|
const domain = launchdDomain(host);
|
|
@@ -307,12 +374,41 @@ export function serviceStates(host, specs) {
|
|
|
307
374
|
const res = host.run("launchctl", ["print", `${domain}/${label}`]);
|
|
308
375
|
if (res.status !== 0)
|
|
309
376
|
return { name: spec.name, unit: label, state: "not loaded", active: false };
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
377
|
+
const raw = /^\s*state = (.+)$/m.exec(res.stdout)?.[1]?.trim() ?? "loaded";
|
|
378
|
+
const pid = Number(/^\s*pid = (\d+)$/m.exec(res.stdout)?.[1] ?? NaN);
|
|
379
|
+
const exit = /last exit code = (-?\d+)/.exec(res.stdout)?.[1];
|
|
380
|
+
const lastExit = exit === undefined ? undefined : Number(exit);
|
|
381
|
+
const pidOrUndef = Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
382
|
+
// Oneshot and interval jobs sit "not running" between runs; being loaded is up for them.
|
|
383
|
+
const active = spec.kind === "daemon" ? raw === "running" : true;
|
|
384
|
+
return {
|
|
385
|
+
name: spec.name,
|
|
386
|
+
unit: label,
|
|
387
|
+
active,
|
|
388
|
+
...describeJob(spec, spec.kind === "daemon" ? raw : "loaded", pidOrUndef, lastExit),
|
|
389
|
+
};
|
|
314
390
|
});
|
|
315
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Word a service by what it is: a daemon is running with a pid, an interval
|
|
394
|
+
* job is a timer and a oneshot has run; the raw platform state reads like a
|
|
395
|
+
* failure between timer runs.
|
|
396
|
+
*/
|
|
397
|
+
function describeJob(spec, healthy, pid, lastExit) {
|
|
398
|
+
const parts = [];
|
|
399
|
+
if (spec.kind === "daemon") {
|
|
400
|
+
parts.push(pid === undefined ? healthy : `running (pid ${pid})`);
|
|
401
|
+
}
|
|
402
|
+
else if (spec.kind === "interval") {
|
|
403
|
+
parts.push(`timer every ${Math.max(30, Math.floor(spec.intervalSec ?? 120))} s`, healthy);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
parts.push("oneshot", healthy);
|
|
407
|
+
}
|
|
408
|
+
if (lastExit !== undefined)
|
|
409
|
+
parts.push(`last exit ${lastExit}`);
|
|
410
|
+
return { state: parts.join(", "), pid, lastExit };
|
|
411
|
+
}
|
|
316
412
|
function must(res, what) {
|
|
317
413
|
if (res.status !== 0) {
|
|
318
414
|
throw new ZellijError("failed", `${what} failed: ${(res.stderr || res.stdout).trim().slice(0, 300)}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecRes
|
|
|
4
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
|
|
5
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
|
|
6
6
|
export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
|
|
7
|
-
export { liveSessionNames, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
|
|
7
|
+
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
|
|
8
8
|
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, type WaitRequest, } from "./zellij/args.js";
|
|
9
9
|
export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
|
|
11
11
|
export { createZellijClient, type ZellijClient, type ZellijClientOptions, type ZellijTransport, } from "./zellij/client.js";
|
|
12
|
-
export { createStateStore, defaultStateDir, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
12
|
+
export { createStateStore, defaultStateDir, type LeaseRecord, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
13
|
+
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, type LeaseTakeInput, type LeaseTakeResult, type LeaseTarget, } from "./host/lease.js";
|
|
13
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
14
15
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
15
16
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type IpcDiscoveryState, type SshExecFn, type SshTarget, } from "./exec.js";
|
|
@@ -23,7 +24,7 @@ export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
|
|
|
23
24
|
export { selectTargets } from "./ops/broadcast.js";
|
|
24
25
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
25
26
|
export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, type PeerState, } from "./ops/status.js";
|
|
26
|
-
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
|
+
export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
28
|
export type { RoutingContext } from "./ops/routing.js";
|
|
28
29
|
export type { DispatchDeps, OpsResult, ServeInstallDeps } from "./ops/types.js";
|
|
29
30
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, type DoctorCheck, type DoctorCheckScope, type DoctorCheckState, type DoctorReport, type DoctorRoute, type HostInspectInput, } from "./ops/doctor.js";
|
|
@@ -34,5 +35,5 @@ export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
|
34
35
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
|
|
35
36
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
|
|
36
37
|
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
|
|
37
|
-
export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayConfig, type RelayDeps, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
|
|
38
|
+
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
|
|
38
39
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,13 @@ export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
|
|
|
4
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
|
|
5
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, } from "./zellij/binary.js";
|
|
6
6
|
export { normalizePaneId, parsePaneList, resolvePane, } from "./zellij/panes.js";
|
|
7
|
-
export { liveSessionNames, parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
|
|
7
|
+
export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
|
|
8
8
|
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, } from "./zellij/args.js";
|
|
9
9
|
export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, } from "./ops/bus.js";
|
|
11
11
|
export { createZellijClient, } from "./zellij/client.js";
|
|
12
12
|
export { createStateStore, defaultStateDir, } from "./state.js";
|
|
13
|
+
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, } from "./host/lease.js";
|
|
13
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
14
15
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
15
16
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
@@ -23,7 +24,7 @@ export { parseTabList, resolveTab } from "./zellij/tabs.js";
|
|
|
23
24
|
export { selectTargets } from "./ops/broadcast.js";
|
|
24
25
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
25
26
|
export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, } from "./ops/status.js";
|
|
26
|
-
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
|
+
export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
28
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, } from "./ops/doctor.js";
|
|
28
29
|
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
29
30
|
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
|
|
@@ -32,5 +33,5 @@ export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
|
32
33
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
|
|
33
34
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
|
|
34
35
|
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
|
|
35
|
-
export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
|
|
36
|
+
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
|
|
36
37
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
|
package/dist/ops/delivery.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { StateStore } from "../state.js";
|
|
|
2
2
|
import type { ZellijClient } from "../zellij/client.js";
|
|
3
3
|
import type { ZellijPane } from "../zellij/panes.js";
|
|
4
4
|
import type { Clock } from "./types.js";
|
|
5
|
-
export type Submitted = boolean | "unverified";
|
|
5
|
+
export type Submitted = boolean | "queued" | "unverified" | "not-delivered";
|
|
6
6
|
export type DeliveryResult = {
|
|
7
7
|
to: string;
|
|
8
8
|
ok: boolean;
|
|
@@ -14,6 +14,36 @@ export type DeliveryResult = {
|
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
export type SubmitMode = "auto" | "double-enter" | "none";
|
|
17
|
+
/** Fixed send windows before adaptive scaling; explicit flags always win. */
|
|
18
|
+
export declare const SEND_BUDGETS: {
|
|
19
|
+
/** Pause before checking the paste landed. */
|
|
20
|
+
readonly settleMs: 300;
|
|
21
|
+
/** Pane-lookup retry window for an absent pane. */
|
|
22
|
+
readonly observeMs: 1000;
|
|
23
|
+
/** The scaled window never exceeds this multiple of the default. */
|
|
24
|
+
readonly maxScale: 5;
|
|
25
|
+
/** A first Zellij call at or under this many ms is not "slow". */
|
|
26
|
+
readonly slowMs: 1000;
|
|
27
|
+
readonly settleMaxMs: 5000;
|
|
28
|
+
readonly observeMaxMs: 30000;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Scale the fixed settle/observe windows by how slow the send's first Zellij
|
|
32
|
+
* call was. A call under `slowMs` keeps the defaults; five seconds or more pins
|
|
33
|
+
* the 5x cap. Explicit `--settle-ms` / `--observe-ms` are returned as given,
|
|
34
|
+
* and a scaled value is capped by the call's own timeout so it cannot outlast
|
|
35
|
+
* the observation budget.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scaleBudgets(input: {
|
|
38
|
+
firstCallMs: number;
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
settleMs?: number;
|
|
41
|
+
observeMs?: number;
|
|
42
|
+
}): {
|
|
43
|
+
settleMs: number;
|
|
44
|
+
observeMs: number;
|
|
45
|
+
scale: number;
|
|
46
|
+
};
|
|
17
47
|
export type SenderSource = "arg" | "env" | "title" | "default";
|
|
18
48
|
export type SenderLabel = {
|
|
19
49
|
label: string;
|
|
@@ -54,14 +84,29 @@ export declare function resolveSubmitMode(args: Record<string, unknown>, pane: {
|
|
|
54
84
|
command?: string | null;
|
|
55
85
|
title?: string | null;
|
|
56
86
|
}): SubmitMode;
|
|
87
|
+
/**
|
|
88
|
+
* The body text to look for on screen: its first non-empty line, trimmed to 40
|
|
89
|
+
* chars. A prefix of the whole body (`body.slice(0, 40)`) can span a newline and
|
|
90
|
+
* then never matches any single screen line. A leading `[zswarm from=…]` header
|
|
91
|
+
* is skipped so we look for the caller's own first line.
|
|
92
|
+
*/
|
|
93
|
+
export declare function bodyNeedle(body: string): string;
|
|
57
94
|
/** True when the paste still sits in the composer (bottom of the screen). */
|
|
58
95
|
export declare function composerHolds(screen: string, body: string): boolean;
|
|
59
96
|
/**
|
|
60
97
|
* Compare the pane before vs after the paste.
|
|
61
98
|
* Composer clearing (pasted text left the bottom input region) is submission.
|
|
62
99
|
* `[Pasted text` in scrollback is not evidence.
|
|
100
|
+
*
|
|
101
|
+
* `not-delivered` is deliberately conservative: only when the screen is
|
|
102
|
+
* unchanged once the paste/composer itself is discounted and nothing is queued.
|
|
103
|
+
* A screen that changed while the body is gone is `unverified` — the pane may
|
|
104
|
+
* have taken the message and scrolled it away, and a false `not-delivered`
|
|
105
|
+
* makes the caller resend the whole message.
|
|
63
106
|
*/
|
|
64
|
-
export declare function classifySubmit(before: string, after: string, body: string
|
|
107
|
+
export declare function classifySubmit(before: string, after: string, body: string, opts?: {
|
|
108
|
+
queued?: (screen: string) => boolean;
|
|
109
|
+
}): Submitted;
|
|
65
110
|
/** Paste a body into one pane and record the attempt. */
|
|
66
111
|
export declare function deliverTo(client: ZellijClient, state: StateStore, args: Record<string, unknown>, input: {
|
|
67
112
|
session: string;
|
|
@@ -70,4 +115,8 @@ export declare function deliverTo(client: ZellijClient, state: StateStore, args:
|
|
|
70
115
|
op: string;
|
|
71
116
|
at: number;
|
|
72
117
|
clock: Clock;
|
|
118
|
+
/** How long the send's first Zellij call took, for adaptive budgets. */
|
|
119
|
+
firstCallMs?: number;
|
|
120
|
+
/** The observation timeout the scaled windows are capped by. */
|
|
121
|
+
timeoutMs?: number;
|
|
73
122
|
}): Promise<DeliveryResult>;
|
package/dist/ops/delivery.js
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
|
-
import { resolveHarness } from "../harness.js";
|
|
1
|
+
import { queuedPrompt, resolveHarness } from "../harness.js";
|
|
2
2
|
import { isTrue, numberArg } from "./util.js";
|
|
3
|
+
/** Fixed send windows before adaptive scaling; explicit flags always win. */
|
|
4
|
+
export const SEND_BUDGETS = {
|
|
5
|
+
/** Pause before checking the paste landed. */
|
|
6
|
+
settleMs: 300,
|
|
7
|
+
/** Pane-lookup retry window for an absent pane. */
|
|
8
|
+
observeMs: 1_000,
|
|
9
|
+
/** The scaled window never exceeds this multiple of the default. */
|
|
10
|
+
maxScale: 5,
|
|
11
|
+
/** A first Zellij call at or under this many ms is not "slow". */
|
|
12
|
+
slowMs: 1_000,
|
|
13
|
+
settleMaxMs: 5_000,
|
|
14
|
+
observeMaxMs: 30_000,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Scale the fixed settle/observe windows by how slow the send's first Zellij
|
|
18
|
+
* call was. A call under `slowMs` keeps the defaults; five seconds or more pins
|
|
19
|
+
* the 5x cap. Explicit `--settle-ms` / `--observe-ms` are returned as given,
|
|
20
|
+
* and a scaled value is capped by the call's own timeout so it cannot outlast
|
|
21
|
+
* the observation budget.
|
|
22
|
+
*/
|
|
23
|
+
export function scaleBudgets(input) {
|
|
24
|
+
const raw = input.firstCallMs > SEND_BUDGETS.slowMs ? input.firstCallMs / SEND_BUDGETS.slowMs : 1;
|
|
25
|
+
const scale = Math.min(SEND_BUDGETS.maxScale, Math.max(1, raw));
|
|
26
|
+
const scaled = (base, hardMax) => Math.max(1, Math.floor(Math.min(base * scale, hardMax, input.timeoutMs)));
|
|
27
|
+
return {
|
|
28
|
+
settleMs: input.settleMs !== undefined ? Math.min(input.settleMs, SEND_BUDGETS.settleMaxMs) : scaled(SEND_BUDGETS.settleMs, SEND_BUDGETS.settleMaxMs),
|
|
29
|
+
observeMs: input.observeMs !== undefined ? Math.min(input.observeMs, SEND_BUDGETS.observeMaxMs) : scaled(SEND_BUDGETS.observeMs, SEND_BUDGETS.observeMaxMs),
|
|
30
|
+
scale,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
3
33
|
/** Cursor / Claude leave this in scrollback after a successful submit. */
|
|
4
34
|
const PASTE_MARKER = "[Pasted text";
|
|
5
35
|
const SENDER_LABEL_MAX = 48;
|
|
@@ -78,44 +108,71 @@ function nonEmptyLines(screen) {
|
|
|
78
108
|
.map((line) => line.replace(/\s+$/, ""))
|
|
79
109
|
.filter((line) => line.length > 0);
|
|
80
110
|
}
|
|
111
|
+
/** All whitespace removed, so a body match survives a pane's line wrapping. */
|
|
112
|
+
function withoutSpaces(text) {
|
|
113
|
+
return text.replace(/\s+/g, "");
|
|
114
|
+
}
|
|
115
|
+
/** True when `needle` appears in `text`, ignoring whitespace (wrapped panes). */
|
|
116
|
+
function containsNeedle(text, needle) {
|
|
117
|
+
const wanted = withoutSpaces(needle);
|
|
118
|
+
return wanted.length > 0 && withoutSpaces(text).includes(wanted);
|
|
119
|
+
}
|
|
120
|
+
const ZSWARM_HEADER = /^\[zswarm from=/i;
|
|
121
|
+
/**
|
|
122
|
+
* The body text to look for on screen: its first non-empty line, trimmed to 40
|
|
123
|
+
* chars. A prefix of the whole body (`body.slice(0, 40)`) can span a newline and
|
|
124
|
+
* then never matches any single screen line. A leading `[zswarm from=…]` header
|
|
125
|
+
* is skipped so we look for the caller's own first line.
|
|
126
|
+
*/
|
|
127
|
+
export function bodyNeedle(body) {
|
|
128
|
+
const lines = body
|
|
129
|
+
.replace(/\r/g, "")
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((line) => line.trim())
|
|
132
|
+
.filter((line) => line.length > 0);
|
|
133
|
+
const first = lines.length > 0 && ZSWARM_HEADER.test(lines[0]) ? lines[1] : lines[0];
|
|
134
|
+
return (first ?? "").slice(0, 40);
|
|
135
|
+
}
|
|
81
136
|
function isPasteLine(line, needle) {
|
|
82
137
|
if (line.includes(PASTE_MARKER))
|
|
83
138
|
return true;
|
|
84
|
-
return
|
|
139
|
+
return containsNeedle(line, needle);
|
|
85
140
|
}
|
|
86
141
|
/** True when the paste still sits in the composer (bottom of the screen). */
|
|
87
142
|
export function composerHolds(screen, body) {
|
|
88
|
-
const needle = body
|
|
143
|
+
const needle = bodyNeedle(body);
|
|
89
144
|
const lines = nonEmptyLines(screen);
|
|
90
145
|
const last3 = lines.slice(-3);
|
|
91
146
|
const last = lines[lines.length - 1] ?? "";
|
|
92
147
|
if (last.includes(PASTE_MARKER))
|
|
93
148
|
return true;
|
|
94
|
-
return
|
|
149
|
+
return containsNeedle(last3.join("\n"), needle);
|
|
95
150
|
}
|
|
96
151
|
/**
|
|
97
152
|
* Compare the pane before vs after the paste.
|
|
98
153
|
* Composer clearing (pasted text left the bottom input region) is submission.
|
|
99
154
|
* `[Pasted text` in scrollback is not evidence.
|
|
155
|
+
*
|
|
156
|
+
* `not-delivered` is deliberately conservative: only when the screen is
|
|
157
|
+
* unchanged once the paste/composer itself is discounted and nothing is queued.
|
|
158
|
+
* A screen that changed while the body is gone is `unverified` — the pane may
|
|
159
|
+
* have taken the message and scrolled it away, and a false `not-delivered`
|
|
160
|
+
* makes the caller resend the whole message.
|
|
100
161
|
*/
|
|
101
|
-
export function classifySubmit(before, after, body) {
|
|
102
|
-
const needle = body
|
|
162
|
+
export function classifySubmit(before, after, body, opts = {}) {
|
|
163
|
+
const needle = bodyNeedle(body);
|
|
103
164
|
const beforeLines = nonEmptyLines(before);
|
|
104
165
|
const afterLines = nonEmptyLines(after);
|
|
105
166
|
const beforeNorm = beforeLines.join("\n");
|
|
106
|
-
const afterNorm = afterLines.join("\n");
|
|
107
167
|
const last3 = afterLines.slice(-3);
|
|
108
168
|
const last = afterLines[afterLines.length - 1] ?? "";
|
|
109
169
|
const markerInLastLine = last.includes(PASTE_MARKER);
|
|
110
|
-
const bodyInLastRegion =
|
|
170
|
+
const bodyInLastRegion = containsNeedle(last3.join("\n"), needle);
|
|
111
171
|
const holds = bodyInLastRegion || markerInLastLine;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return false;
|
|
117
|
-
return "unverified";
|
|
118
|
-
}
|
|
172
|
+
// The harness says it accepted the paste but queued it behind a running turn.
|
|
173
|
+
if (opts.queued?.(after))
|
|
174
|
+
return "queued";
|
|
175
|
+
// The paste is still in the composer: not submitted (Enter may rescue it).
|
|
119
176
|
if (holds) {
|
|
120
177
|
const withoutPaste = afterLines.filter((line) => !isPasteLine(line, needle));
|
|
121
178
|
if (withoutPaste.join("\n") === beforeNorm)
|
|
@@ -128,9 +185,15 @@ export function classifySubmit(before, after, body) {
|
|
|
128
185
|
return belowNovel.length > 0 ? true : false;
|
|
129
186
|
}
|
|
130
187
|
// Paste left the input region: still on screen, just not at the bottom.
|
|
131
|
-
if (
|
|
188
|
+
if (containsNeedle(after, needle))
|
|
132
189
|
return true;
|
|
133
|
-
|
|
190
|
+
// Nothing moved apart from the paste/composer itself and nothing is queued:
|
|
191
|
+
// positive evidence the paste never reached the pane, so a resend is safe.
|
|
192
|
+
const beforeApart = beforeLines.filter((line) => !isPasteLine(line, needle));
|
|
193
|
+
const afterApart = afterLines.filter((line) => !isPasteLine(line, needle));
|
|
194
|
+
if (afterApart.join("\n") === beforeApart.join("\n"))
|
|
195
|
+
return "not-delivered";
|
|
196
|
+
// The screen changed while the body is not visible: inconclusive, never a resend.
|
|
134
197
|
return "unverified";
|
|
135
198
|
}
|
|
136
199
|
async function dumpOrNull(client, session, paneId) {
|
|
@@ -142,7 +205,8 @@ async function dumpOrNull(client, session, paneId) {
|
|
|
142
205
|
}
|
|
143
206
|
}
|
|
144
207
|
async function verifySubmit(client, input) {
|
|
145
|
-
const { session, paneId, body, mode, settleMs, clock, before } = input;
|
|
208
|
+
const { session, paneId, body, mode, settleMs, clock, before, queued } = input;
|
|
209
|
+
const classify = (base, screen) => classifySubmit(base, screen, body, { queued });
|
|
146
210
|
if (mode === "none")
|
|
147
211
|
return "unverified";
|
|
148
212
|
if (mode === "double-enter") {
|
|
@@ -156,47 +220,57 @@ async function verifySubmit(client, input) {
|
|
|
156
220
|
const after = await dumpOrNull(client, session, paneId);
|
|
157
221
|
if (after === null)
|
|
158
222
|
return "unverified";
|
|
159
|
-
let verdict =
|
|
160
|
-
if (verdict === true)
|
|
161
|
-
return
|
|
223
|
+
let verdict = classify(before, after);
|
|
224
|
+
if (verdict === true || verdict === "queued")
|
|
225
|
+
return verdict;
|
|
162
226
|
if (verdict === false) {
|
|
163
227
|
await client.sendKeys({ session, paneId, keys: ["Enter"] });
|
|
164
228
|
await clock.sleep(settleMs);
|
|
165
229
|
const rescued = await dumpOrNull(client, session, paneId);
|
|
166
230
|
if (rescued === null)
|
|
167
231
|
return "unverified";
|
|
168
|
-
verdict =
|
|
169
|
-
if (verdict === true)
|
|
170
|
-
return
|
|
232
|
+
verdict = classify(before, rescued);
|
|
233
|
+
if (verdict === true || verdict === "queued")
|
|
234
|
+
return verdict;
|
|
171
235
|
if (verdict === false)
|
|
172
236
|
return false;
|
|
173
237
|
}
|
|
174
|
-
//
|
|
238
|
+
// Inconclusive or not (yet) on screen; TUIs often redraw after the first settle.
|
|
175
239
|
await clock.sleep(Math.min(Math.max(settleMs * 2, 800), 5000));
|
|
176
240
|
const later = await dumpOrNull(client, session, paneId);
|
|
177
241
|
if (later === null)
|
|
178
|
-
return
|
|
242
|
+
return verdict;
|
|
179
243
|
if (composerHolds(after, body) && !composerHolds(later, body))
|
|
180
244
|
return true;
|
|
181
|
-
return
|
|
245
|
+
return classify(before, later);
|
|
182
246
|
}
|
|
183
247
|
/** Paste a body into one pane and record the attempt. */
|
|
184
248
|
export async function deliverTo(client, state, args, input) {
|
|
185
|
-
const { session, pane, body, op, at, clock } = input;
|
|
249
|
+
const { session, pane, body, op, at, clock, firstCallMs = 0, timeoutMs = 30_000 } = input;
|
|
186
250
|
const from = senderLabel(args);
|
|
187
251
|
const mode = resolveSubmitMode(args, pane);
|
|
188
|
-
const
|
|
252
|
+
const profile = resolveHarness(pane);
|
|
253
|
+
const queued = (screen) => queuedPrompt(screen, profile);
|
|
254
|
+
const explicitSettle = args.settleMs !== undefined && args.settleMs !== null && args.settleMs !== "";
|
|
255
|
+
const budgets = scaleBudgets({
|
|
256
|
+
firstCallMs,
|
|
257
|
+
timeoutMs,
|
|
258
|
+
settleMs: explicitSettle ? numberArg(args, "settleMs", SEND_BUDGETS.settleMs, { min: 50, max: SEND_BUDGETS.settleMaxMs }) : undefined,
|
|
259
|
+
});
|
|
260
|
+
const settleMs = budgets.settleMs;
|
|
261
|
+
const confirmOnce = isTrue(args.confirm);
|
|
189
262
|
try {
|
|
190
263
|
let before = null;
|
|
191
264
|
if (mode === "auto") {
|
|
192
265
|
before = await dumpOrNull(client, session, pane.id);
|
|
193
266
|
}
|
|
194
|
-
const
|
|
267
|
+
const paste = () => client.injectPane({
|
|
195
268
|
session,
|
|
196
269
|
paneId: pane.id,
|
|
197
270
|
text: bodyText(client, args, body),
|
|
198
271
|
});
|
|
199
|
-
|
|
272
|
+
let delivered = await paste();
|
|
273
|
+
let submitted = await verifySubmit(client, {
|
|
200
274
|
session,
|
|
201
275
|
paneId: delivered.paneId,
|
|
202
276
|
body,
|
|
@@ -204,7 +278,64 @@ export async function deliverTo(client, state, args, input) {
|
|
|
204
278
|
settleMs,
|
|
205
279
|
clock,
|
|
206
280
|
before,
|
|
281
|
+
queued,
|
|
207
282
|
});
|
|
283
|
+
// Opt-in confirm. A second paste is only safe on positive evidence the
|
|
284
|
+
// first never landed; a still-held composer gets Enter, and unverified just
|
|
285
|
+
// looks again. Never paste the body twice.
|
|
286
|
+
if (confirmOnce && mode === "auto" && submitted !== true && submitted !== "queued") {
|
|
287
|
+
if (submitted === "not-delivered") {
|
|
288
|
+
const retryBefore = await dumpOrNull(client, session, delivered.paneId);
|
|
289
|
+
delivered = await paste();
|
|
290
|
+
submitted = await verifySubmit(client, {
|
|
291
|
+
session,
|
|
292
|
+
paneId: delivered.paneId,
|
|
293
|
+
body,
|
|
294
|
+
mode,
|
|
295
|
+
settleMs,
|
|
296
|
+
clock,
|
|
297
|
+
before: retryBefore,
|
|
298
|
+
queued,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (submitted === false) {
|
|
302
|
+
// The paste is still in the composer: press Enter, never paste again.
|
|
303
|
+
await client.sendKeys({ session, paneId: delivered.paneId, keys: ["Enter"] });
|
|
304
|
+
const seen = await dumpOrNull(client, session, delivered.paneId);
|
|
305
|
+
if (seen !== null && before !== null) {
|
|
306
|
+
submitted = classifySubmit(before, seen, body, { queued });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// unverified: one more dump to report what is actually on screen.
|
|
311
|
+
const seen = await dumpOrNull(client, session, delivered.paneId);
|
|
312
|
+
if (seen !== null && before !== null) {
|
|
313
|
+
submitted = classifySubmit(before, seen, body, { queued });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (submitted === "not-delivered") {
|
|
318
|
+
state.appendLog({
|
|
319
|
+
at,
|
|
320
|
+
op,
|
|
321
|
+
session,
|
|
322
|
+
to: delivered.paneId,
|
|
323
|
+
from,
|
|
324
|
+
bytes: body.length,
|
|
325
|
+
ok: false,
|
|
326
|
+
detail: "submitted=not-delivered",
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
to: delivered.paneId,
|
|
330
|
+
ok: false,
|
|
331
|
+
delivery: "zellij_paste",
|
|
332
|
+
submitted,
|
|
333
|
+
error: {
|
|
334
|
+
code: "not_delivered",
|
|
335
|
+
message: "the pane was unchanged apart from the composer after the send; the body never landed, so a resend is safe",
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
208
339
|
state.appendLog({
|
|
209
340
|
at,
|
|
210
341
|
op,
|