@zswarm/core 0.1.8 → 0.1.9
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/exec.d.ts +2 -1
- package/dist/exec.js +16 -3
- package/dist/ops/broadcast.d.ts +6 -0
- package/dist/ops/broadcast.js +15 -11
- package/dist/ops/status.d.ts +4 -2
- package/dist/ops/status.js +15 -12
- package/dist/ops/wait.js +29 -7
- package/dist/state.d.ts +7 -0
- package/dist/state.js +52 -9
- package/package.json +1 -1
package/dist/exec.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type ExecOptions = {
|
|
|
21
21
|
* Stop as soon as the accumulated stdout satisfies this, instead of waiting
|
|
22
22
|
* for the process to exit. `zellij pipe` answers in milliseconds but stays
|
|
23
23
|
* resident when it has no terminal, so waiting for exit means waiting for the
|
|
24
|
-
* timeout.
|
|
24
|
+
* timeout. Replies are JSON lines, so this is only consulted when a chunk
|
|
25
|
+
* could have completed one (see `mayCompleteLine`).
|
|
25
26
|
*/
|
|
26
27
|
until?: (stdout: string) => boolean;
|
|
27
28
|
/** Kill the child when aborted (MCP cancellation). */
|
package/dist/exec.js
CHANGED
|
@@ -322,6 +322,14 @@ export function createSshExec(target, env, options = {}) {
|
|
|
322
322
|
};
|
|
323
323
|
return exec;
|
|
324
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* A JSON line is complete once its newline arrives, or once the stream ends on
|
|
327
|
+
* `}` (a last line written without one). Asking `until` about every chunk
|
|
328
|
+
* re-parses a multi-megabyte single-line scrollback reply once per 64KB read.
|
|
329
|
+
*/
|
|
330
|
+
function mayCompleteLine(chunk) {
|
|
331
|
+
return chunk.includes("\n") || chunk.trimEnd().endsWith("}");
|
|
332
|
+
}
|
|
325
333
|
/** Read stdout until the answer is in hand, then stop caring about the child. */
|
|
326
334
|
function runUntil(binPath, args, env, options) {
|
|
327
335
|
return new Promise((resolve) => {
|
|
@@ -361,13 +369,18 @@ function runUntil(binPath, args, env, options) {
|
|
|
361
369
|
child.on("error", (err) => {
|
|
362
370
|
finish(localSpawnResult(err, binPath));
|
|
363
371
|
});
|
|
372
|
+
// Decode across reads: a multi-byte character split between two chunks
|
|
373
|
+
// must not come out as U+FFFD in the screen text.
|
|
374
|
+
child.stdout.setEncoding("utf8");
|
|
375
|
+
child.stderr.setEncoding("utf8");
|
|
364
376
|
child.stdout.on("data", (chunk) => {
|
|
365
|
-
stdout += chunk
|
|
366
|
-
if (options.until(stdout))
|
|
377
|
+
stdout += chunk;
|
|
378
|
+
if (mayCompleteLine(chunk) && options.until(stdout)) {
|
|
367
379
|
finish({ code: 0, stdout, stderr });
|
|
380
|
+
}
|
|
368
381
|
});
|
|
369
382
|
child.stderr.on("data", (chunk) => {
|
|
370
|
-
stderr += chunk
|
|
383
|
+
stderr += chunk;
|
|
371
384
|
});
|
|
372
385
|
child.on("close", (code) => finish({ code: code ?? 0, stdout, stderr }));
|
|
373
386
|
});
|
package/dist/ops/broadcast.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import type { StateStore } from "../state.js";
|
|
|
3
3
|
import type { ZellijClient } from "../zellij/client.js";
|
|
4
4
|
import type { ZellijPane } from "../zellij/panes.js";
|
|
5
5
|
import type { Clock, OpsResult } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Deliveries in flight at once. Each one is a dump, paste, Enter, settle sleep
|
|
8
|
+
* and re-dump against its own `--pane-id`, so panes do not wait on each other;
|
|
9
|
+
* the cap keeps a large crew from launching a burst of zellij (or ssh) clients.
|
|
10
|
+
*/
|
|
11
|
+
export declare const BROADCAST_CONCURRENCY = 3;
|
|
6
12
|
export type Skipped = {
|
|
7
13
|
id: string;
|
|
8
14
|
reason: string;
|
package/dist/ops/broadcast.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { assertPaneAllowed } from "../policy.js";
|
|
3
3
|
import { deliverTo, selfPaneTitle, withSenderLabel } from "./delivery.js";
|
|
4
|
+
import { mapPool } from "./status.js";
|
|
4
5
|
import { isTrue, optionalString } from "./util.js";
|
|
6
|
+
/**
|
|
7
|
+
* Deliveries in flight at once. Each one is a dump, paste, Enter, settle sleep
|
|
8
|
+
* and re-dump against its own `--pane-id`, so panes do not wait on each other;
|
|
9
|
+
* the cap keeps a large crew from launching a burst of zellij (or ssh) clients.
|
|
10
|
+
*/
|
|
11
|
+
export const BROADCAST_CONCURRENCY = 3;
|
|
5
12
|
/**
|
|
6
13
|
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
7
14
|
* every terminal pane — then drop the ones it must not write to.
|
|
@@ -92,17 +99,14 @@ export async function broadcast(client, state, args, clock, policy, env = proces
|
|
|
92
99
|
if (targets.length === 0) {
|
|
93
100
|
throw new ZellijError("no_targets", `nothing to broadcast to${skipped.length > 0 ? ` (skipped ${skipped.map((s) => `${s.id}:${s.reason}`).join(", ")})` : ""}`);
|
|
94
101
|
}
|
|
95
|
-
const results =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
clock,
|
|
104
|
-
}));
|
|
105
|
-
}
|
|
102
|
+
const results = await mapPool(targets, BROADCAST_CONCURRENCY, (pane) => deliverTo(client, state, labeled, {
|
|
103
|
+
session,
|
|
104
|
+
pane,
|
|
105
|
+
body,
|
|
106
|
+
op: "broadcast",
|
|
107
|
+
at: clock.now(),
|
|
108
|
+
clock,
|
|
109
|
+
}));
|
|
106
110
|
const delivered = results.filter((r) => r.ok);
|
|
107
111
|
return {
|
|
108
112
|
ok: true,
|
package/dist/ops/status.d.ts
CHANGED
|
@@ -40,12 +40,13 @@ export declare function statusTabs(peers: Record<string, unknown>[]): {
|
|
|
40
40
|
panes: number;
|
|
41
41
|
states: Record<string, number>;
|
|
42
42
|
}[];
|
|
43
|
-
|
|
43
|
+
type ScreenPair = {
|
|
44
44
|
exited: boolean;
|
|
45
45
|
before: string;
|
|
46
46
|
after: string;
|
|
47
47
|
profile?: HarnessProfile | null;
|
|
48
|
-
}
|
|
48
|
+
};
|
|
49
|
+
export declare function classify(input: ScreenPair): PeerState;
|
|
49
50
|
/** Run `fn` over items with at most `limit` in flight. */
|
|
50
51
|
export declare function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
51
52
|
/**
|
|
@@ -59,3 +60,4 @@ export declare function peerStatus(client: ZellijClient, args: Record<string, un
|
|
|
59
60
|
deadlineAt?: number;
|
|
60
61
|
signal?: AbortSignal;
|
|
61
62
|
}): Promise<OpsResult>;
|
|
63
|
+
export {};
|
package/dist/ops/status.js
CHANGED
|
@@ -53,9 +53,6 @@ export function waitingPrompt(screen, profile) {
|
|
|
53
53
|
const last = lines[lines.length - 1] ?? "";
|
|
54
54
|
return QUESTION.test(last) ? { reason: "prompt", evidence: last.slice(0, 320), source: "screen" } : null;
|
|
55
55
|
}
|
|
56
|
-
function promptHolds(screen, profile) {
|
|
57
|
-
return waitingPrompt(screen, profile) !== null;
|
|
58
|
-
}
|
|
59
56
|
/** Compact summary of the selected terminal peers, including inactive tabs. */
|
|
60
57
|
export function statusTabs(peers) {
|
|
61
58
|
const tabs = new Map();
|
|
@@ -69,12 +66,17 @@ export function statusTabs(peers) {
|
|
|
69
66
|
}
|
|
70
67
|
return [...tabs.values()];
|
|
71
68
|
}
|
|
72
|
-
|
|
69
|
+
/** The verdict and, for `waiting`, the prompt behind it — one screen scan. */
|
|
70
|
+
function classifyWithEvidence(input) {
|
|
73
71
|
if (input.exited)
|
|
74
|
-
return "exited";
|
|
72
|
+
return { state: "exited", waiting: null };
|
|
75
73
|
if (input.before !== input.after)
|
|
76
|
-
return "busy";
|
|
77
|
-
|
|
74
|
+
return { state: "busy", waiting: null };
|
|
75
|
+
const waiting = waitingPrompt(input.after, input.profile);
|
|
76
|
+
return { state: waiting ? "waiting" : "idle", waiting };
|
|
77
|
+
}
|
|
78
|
+
export function classify(input) {
|
|
79
|
+
return classifyWithEvidence(input).state;
|
|
78
80
|
}
|
|
79
81
|
/** Run `fn` over items with at most `limit` in flight. */
|
|
80
82
|
export async function mapPool(items, limit, fn) {
|
|
@@ -96,6 +98,7 @@ function isCancelledError(err) {
|
|
|
96
98
|
return ((err instanceof ZellijError && err.code === "cancelled") ||
|
|
97
99
|
(err instanceof Error && /cancelled/i.test(err.message)));
|
|
98
100
|
}
|
|
101
|
+
/** A `waiting` peer carries its evidence in `extra`, found when it was classified. */
|
|
99
102
|
function peerEntry(pane, state, screen, verbose, extra) {
|
|
100
103
|
const entry = {
|
|
101
104
|
id: pane.id,
|
|
@@ -106,8 +109,6 @@ function peerEntry(pane, state, screen, verbose, extra) {
|
|
|
106
109
|
lastLine: lastLine(screen).slice(0, 160),
|
|
107
110
|
...extra,
|
|
108
111
|
};
|
|
109
|
-
if (state === "waiting")
|
|
110
|
-
entry.waiting = waitingPrompt(screen, resolveHarness(pane));
|
|
111
112
|
if (verbose) {
|
|
112
113
|
entry.command = pane.command ?? null;
|
|
113
114
|
entry.cwd = pane.cwd ?? null;
|
|
@@ -185,11 +186,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
|
|
|
185
186
|
return peerEntry(pane, "exited", "", verbose);
|
|
186
187
|
}
|
|
187
188
|
const row = changed.get(pane.id);
|
|
189
|
+
const waiting = row ? waitingPrompt(row.screen, resolveHarness(pane)) : null;
|
|
188
190
|
const state = !row ? "unknown"
|
|
189
|
-
:
|
|
191
|
+
: waiting ? "waiting"
|
|
190
192
|
: row.first ? "unknown" : row.changed ? "busy" : "idle";
|
|
191
193
|
return peerEntry(pane, state, row?.screen ?? "", verbose, {
|
|
192
194
|
...(row?.first ? { first: true } : {}),
|
|
195
|
+
...(waiting ? { waiting } : {}),
|
|
193
196
|
});
|
|
194
197
|
})
|
|
195
198
|
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
@@ -315,13 +318,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
|
|
|
315
318
|
peers.push(peerEntry(pane, "unknown", after ?? first ?? "", verbose));
|
|
316
319
|
continue;
|
|
317
320
|
}
|
|
318
|
-
const state =
|
|
321
|
+
const { state, waiting } = classifyWithEvidence({
|
|
319
322
|
exited: false,
|
|
320
323
|
before: first,
|
|
321
324
|
after,
|
|
322
325
|
profile: resolveHarness(pane),
|
|
323
326
|
});
|
|
324
|
-
peers.push(peerEntry(pane, state, after, verbose));
|
|
327
|
+
peers.push(peerEntry(pane, state, after, verbose, waiting ? { waiting } : undefined));
|
|
325
328
|
}
|
|
326
329
|
peers.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
327
330
|
const free = peers.filter((p) => p.state === "idle").map((p) => p.id);
|
package/dist/ops/wait.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { DEFAULT_TIMEOUT_MS } from "../zellij/binary.js";
|
|
2
3
|
import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
3
4
|
const WAIT_DEFAULTS = {
|
|
4
5
|
idleMs: 2_000,
|
|
@@ -10,6 +11,11 @@ const WAIT_LIMITS = {
|
|
|
10
11
|
pollMs: { min: 50, max: 30_000 },
|
|
11
12
|
timeoutMs: { min: 1_000, max: 900_000 },
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* A poll at the deadline still gets this long, so `timeout` reports the screen
|
|
16
|
+
* at the deadline; a hung dump there overruns the wait by at most this much.
|
|
17
|
+
*/
|
|
18
|
+
const FINAL_DUMP_MS = 1_000;
|
|
13
19
|
export function buildMatcher(args) {
|
|
14
20
|
const match = optionalString(args.match);
|
|
15
21
|
if (!match)
|
|
@@ -98,15 +104,29 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
|
|
|
98
104
|
let lastChangeAt = started;
|
|
99
105
|
let polls = 0;
|
|
100
106
|
let changes = 0;
|
|
107
|
+
let last = null;
|
|
101
108
|
for (;;) {
|
|
102
109
|
throwIfAborted(signal);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
const left = timeoutMs - (clock.now() - started);
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
const dumped = await client.dumpPane({
|
|
114
|
+
session,
|
|
115
|
+
paneId: pane.id,
|
|
116
|
+
full,
|
|
117
|
+
timeoutMs: Math.max(FINAL_DUMP_MS, Math.min(left, DEFAULT_TIMEOUT_MS)),
|
|
118
|
+
});
|
|
119
|
+
text = dumped.text;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
throwIfAborted(signal);
|
|
123
|
+
// Out of time with a screen in hand: the wait timed out, it did not fail.
|
|
124
|
+
if (last && clock.now() - started >= timeoutMs) {
|
|
125
|
+
return waitResult("timeout", { ...last, at: clock.now() });
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
108
129
|
polls++;
|
|
109
|
-
const text = dumped.text;
|
|
110
130
|
const screen = normalizeScreen(text);
|
|
111
131
|
const at = clock.now();
|
|
112
132
|
const ctx = {
|
|
@@ -137,6 +157,8 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
|
|
|
137
157
|
}
|
|
138
158
|
if (at - started >= timeoutMs)
|
|
139
159
|
return waitResult("timeout", ctx);
|
|
140
|
-
|
|
160
|
+
last = ctx;
|
|
161
|
+
// The last poll lands on the deadline, not up to a whole pollMs past it.
|
|
162
|
+
await clock.sleep(Math.min(pollMs, timeoutMs - (at - started)));
|
|
141
163
|
}
|
|
142
164
|
}
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export type BusMarkerRecord = {
|
|
|
29
29
|
configKey: string;
|
|
30
30
|
installedAt: number;
|
|
31
31
|
};
|
|
32
|
+
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
33
|
+
export declare const LOG_TAIL_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* Past this, the append that crossed it trims the file back to its readable
|
|
36
|
+
* tail. Keeps the log bounded without needing a rotation daemon.
|
|
37
|
+
*/
|
|
38
|
+
export declare const LOG_MAX_BYTES: number;
|
|
32
39
|
export declare function defaultStateDir(env?: NodeJS.ProcessEnv): string;
|
|
33
40
|
export type StateStoreOptions = {
|
|
34
41
|
dir?: string;
|
package/dist/state.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const LOG_FILE = "log.jsonl";
|
|
@@ -7,8 +7,13 @@ const SIGNALS_LOCK = "signals.lock";
|
|
|
7
7
|
const CURSORS_FILE = "cursors.json";
|
|
8
8
|
const CURSORS_LOCK = "cursors.lock";
|
|
9
9
|
const BUS_FILE = "bus.json";
|
|
10
|
-
/**
|
|
11
|
-
const LOG_TAIL_BYTES = 512 * 1024;
|
|
10
|
+
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
11
|
+
export const LOG_TAIL_BYTES = 512 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Past this, the append that crossed it trims the file back to its readable
|
|
14
|
+
* tail. Keeps the log bounded without needing a rotation daemon.
|
|
15
|
+
*/
|
|
16
|
+
export const LOG_MAX_BYTES = 4 * 1024 * 1024;
|
|
12
17
|
/** Bound for waiting on a *live* lock holder before failing. */
|
|
13
18
|
const LOCK_WAIT_MS = 5_000;
|
|
14
19
|
/**
|
|
@@ -16,6 +21,45 @@ const LOCK_WAIT_MS = 5_000;
|
|
|
16
21
|
* exclusive create (wait), not as abandoned debris (refuse).
|
|
17
22
|
*/
|
|
18
23
|
const LOCK_PENDING_MS = LOCK_WAIT_MS;
|
|
24
|
+
/**
|
|
25
|
+
* The last `bytes` of a file, starting at its first whole line. Reads only
|
|
26
|
+
* that window, so a long-lived log does not cost a full read per `log` call.
|
|
27
|
+
*/
|
|
28
|
+
function readFileTail(path, bytes) {
|
|
29
|
+
const fd = openSync(path, "r");
|
|
30
|
+
try {
|
|
31
|
+
const size = fstatSync(fd).size;
|
|
32
|
+
// One byte of lead-in says whether the window already starts a line.
|
|
33
|
+
const start = Math.max(0, size - bytes - 1);
|
|
34
|
+
const buf = Buffer.alloc(size - start);
|
|
35
|
+
let read = 0;
|
|
36
|
+
while (read < buf.length) {
|
|
37
|
+
const n = readSync(fd, buf, read, buf.length - read, start + read);
|
|
38
|
+
if (n === 0)
|
|
39
|
+
break;
|
|
40
|
+
read += n;
|
|
41
|
+
}
|
|
42
|
+
const text = buf.toString("utf8", 0, read);
|
|
43
|
+
return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Keep only a file's readable tail. Everything before it is already invisible
|
|
51
|
+
* to `readLog`; an append racing the rename can be lost, and the log is an aid.
|
|
52
|
+
*/
|
|
53
|
+
function trimToTail(path, bytes) {
|
|
54
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(tmp, readFileTail(path, bytes), "utf8");
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
rmSync(tmp, { force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
19
63
|
function sleepSync(ms) {
|
|
20
64
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
21
65
|
}
|
|
@@ -53,7 +97,10 @@ export function createStateStore(options = {}) {
|
|
|
53
97
|
return;
|
|
54
98
|
try {
|
|
55
99
|
ensureDir();
|
|
56
|
-
|
|
100
|
+
const path = join(dir, LOG_FILE);
|
|
101
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
102
|
+
if (statSync(path).size > LOG_MAX_BYTES)
|
|
103
|
+
trimToTail(path, LOG_TAIL_BYTES);
|
|
57
104
|
}
|
|
58
105
|
catch {
|
|
59
106
|
// The log is an aid, never a reason to fail an op.
|
|
@@ -62,15 +109,11 @@ export function createStateStore(options = {}) {
|
|
|
62
109
|
function readLog() {
|
|
63
110
|
let raw;
|
|
64
111
|
try {
|
|
65
|
-
raw =
|
|
112
|
+
raw = readFileTail(join(dir, LOG_FILE), LOG_TAIL_BYTES);
|
|
66
113
|
}
|
|
67
114
|
catch {
|
|
68
115
|
return [];
|
|
69
116
|
}
|
|
70
|
-
if (raw.length > LOG_TAIL_BYTES) {
|
|
71
|
-
raw = raw.slice(raw.length - LOG_TAIL_BYTES);
|
|
72
|
-
raw = raw.slice(raw.indexOf("\n") + 1);
|
|
73
|
-
}
|
|
74
117
|
const out = [];
|
|
75
118
|
for (const line of raw.split("\n")) {
|
|
76
119
|
if (!line.trim())
|