@zswarm/core 0.2.6 → 0.2.8
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/harness.d.ts +40 -6
- package/dist/harness.js +97 -11
- package/dist/host/cli.js +21 -5
- package/dist/host/relay.d.ts +37 -0
- package/dist/host/relay.js +110 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ops/delivery.d.ts +30 -6
- package/dist/ops/delivery.js +71 -21
- package/dist/ops/dispatch.d.ts +9 -0
- package/dist/ops/dispatch.js +25 -3
- package/dist/ops/restart.d.ts +59 -0
- package/dist/ops/restart.js +449 -0
- package/dist/ops/serve-install.d.ts +8 -1
- package/dist/ops/serve-install.js +160 -73
- package/dist/ops/serve.d.ts +28 -0
- package/dist/ops/serve.js +35 -0
- package/dist/policy.js +1 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +39 -7
- package/package.json +1 -1
package/dist/harness.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export type SubmitStrategy = "auto" | "double-enter";
|
|
2
|
+
/**
|
|
3
|
+
* How to ask a harness to quit its own UI. A slash command is typed and
|
|
4
|
+
* submitted; keys are sent raw. `null` means the restart op falls back to
|
|
5
|
+
* Ctrl+C instead of guessing.
|
|
6
|
+
*/
|
|
7
|
+
export type HarnessExitRecipe = {
|
|
8
|
+
/** A command line to type into the harness composer, e.g. "/exit". */
|
|
9
|
+
command?: string;
|
|
10
|
+
/** Raw keys to send, e.g. ["Ctrl c"]. */
|
|
11
|
+
keys?: readonly string[];
|
|
12
|
+
};
|
|
2
13
|
export type HarnessProfile = {
|
|
3
|
-
/** Stable id: "codex" | "cursor" | "opencode" | "gemini" | "pi" | "unknown" */
|
|
14
|
+
/** Stable id: "codex" | "cursor" | "opencode" | "gemini" | "pi" | "claude" | "unknown" */
|
|
4
15
|
name: string;
|
|
5
16
|
/** What `send` should do when the caller did not pass submit= explicitly. */
|
|
6
17
|
submit: SubmitStrategy;
|
|
@@ -19,23 +30,46 @@ export type HarnessProfile = {
|
|
|
19
30
|
* these against the trailing lines to avoid treating a busy pane as idle.
|
|
20
31
|
*/
|
|
21
32
|
busy: readonly RegExp[];
|
|
33
|
+
/** Program to type in a shell pane to relaunch this harness; null when unknown. */
|
|
34
|
+
launch: string | null;
|
|
35
|
+
/** How to ask the harness to quit; null falls back to Ctrl+C. */
|
|
36
|
+
exit: HarnessExitRecipe | null;
|
|
37
|
+
/** Trailing lines showing the harness finished starting and is ready for input. */
|
|
38
|
+
ready: readonly RegExp[];
|
|
22
39
|
};
|
|
40
|
+
type HarnessName = Exclude<HarnessProfile["name"], "unknown">;
|
|
23
41
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
42
|
+
* The stable, ordered list of known harness names. `--harness` validates
|
|
43
|
+
* against this and the schema help names it, so both stay in step with PROFILES.
|
|
44
|
+
*/
|
|
45
|
+
export declare const HARNESS_NAMES: readonly HarnessName[];
|
|
46
|
+
/**
|
|
47
|
+
* Identify the harness, case-insensitively. An explicit `name` is the override
|
|
48
|
+
* for wrapper-launched panes whose command/title hides the real harness: a
|
|
49
|
+
* non-empty name must be one of `HARNESS_NAMES` (else `bad_arg`). Without an
|
|
50
|
+
* override the pane's command wins (the executable that spawned it), then the
|
|
51
|
+
* title for panes launched from a generic shell, then `unknown`.
|
|
27
52
|
*/
|
|
28
53
|
export declare function resolveHarness(pane: {
|
|
29
54
|
command?: string | null;
|
|
30
55
|
title?: string | null;
|
|
31
|
-
}): HarnessProfile;
|
|
56
|
+
}, name?: string | null): HarnessProfile;
|
|
32
57
|
/**
|
|
33
58
|
* Does this screen's trailing lines show the harness's queued-message hint?
|
|
34
59
|
* Only the last few lines count: a quoted marker in scrollback is not a queue.
|
|
35
60
|
*/
|
|
36
61
|
export declare function queuedPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
37
62
|
/**
|
|
38
|
-
*
|
|
63
|
+
* The busy marker regex a screen shows on its trailing lines, or null. Only
|
|
39
64
|
* the last few lines count: a quoted marker in scrollback is not a live turn.
|
|
40
65
|
*/
|
|
66
|
+
export declare function busyMarker(screen: string, profile: HarnessProfile): RegExp | null;
|
|
67
|
+
/** Does this screen's trailing lines show the harness actively working? */
|
|
41
68
|
export declare function busyPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Does this screen's trailing lines show the harness ready for input? An empty
|
|
71
|
+
* ready set is unknown, never a match: a profile that never declared a marker
|
|
72
|
+
* must not make the restart op wait forever.
|
|
73
|
+
*/
|
|
74
|
+
export declare function readyPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
75
|
+
export {};
|
package/dist/harness.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ZellijError } from "./errors.js";
|
|
1
2
|
/** Command and title needles observed on real panes in one live session. */
|
|
2
3
|
const PROFILES = [
|
|
3
4
|
{ name: "codex", command: ["codex"], title: ["agent-codex"] },
|
|
@@ -5,7 +6,13 @@ const PROFILES = [
|
|
|
5
6
|
{ name: "opencode", command: ["opencode"], title: ["agent-opencode"] },
|
|
6
7
|
{ name: "gemini", command: ["agy", "gemini"], title: ["agent-gemini"] },
|
|
7
8
|
{ name: "pi", command: ["pi"], title: ["agent-pi"] },
|
|
9
|
+
{ name: "claude", command: ["claude"], title: ["agent-claude"] },
|
|
8
10
|
];
|
|
11
|
+
/**
|
|
12
|
+
* The stable, ordered list of known harness names. `--harness` validates
|
|
13
|
+
* against this and the schema help names it, so both stay in step with PROFILES.
|
|
14
|
+
*/
|
|
15
|
+
export const HARNESS_NAMES = PROFILES.map((p) => p.name);
|
|
9
16
|
/**
|
|
10
17
|
* Only codex needs a second Enter to submit: its composer keeps the pasted
|
|
11
18
|
* message (`› [zswarm from=...]`) until an extra Enter lands — verified live
|
|
@@ -17,6 +24,7 @@ const SUBMIT = {
|
|
|
17
24
|
opencode: "auto",
|
|
18
25
|
gemini: "auto",
|
|
19
26
|
pi: "auto",
|
|
27
|
+
claude: "auto",
|
|
20
28
|
};
|
|
21
29
|
/**
|
|
22
30
|
* Prompt shapes any CLI can show, so every profile carries these as a base.
|
|
@@ -50,6 +58,7 @@ const WAITING = {
|
|
|
50
58
|
/Accept this file edit\?/i,
|
|
51
59
|
],
|
|
52
60
|
pi: GENERIC_WAITING,
|
|
61
|
+
claude: [...GENERIC_WAITING, /Do you want to (?:proceed|continue)\?/i],
|
|
53
62
|
};
|
|
54
63
|
/**
|
|
55
64
|
* Queued-message hints seen live. Claude Code prints
|
|
@@ -68,6 +77,7 @@ const QUEUED = {
|
|
|
68
77
|
opencode: GENERIC_QUEUED,
|
|
69
78
|
gemini: GENERIC_QUEUED,
|
|
70
79
|
pi: GENERIC_QUEUED,
|
|
80
|
+
claude: GENERIC_QUEUED,
|
|
71
81
|
};
|
|
72
82
|
/**
|
|
73
83
|
* Busy (working) markers seen live. OpenCode draws a progress bar of filled
|
|
@@ -80,6 +90,47 @@ const BUSY = {
|
|
|
80
90
|
opencode: [/[■⬝]{4,}/],
|
|
81
91
|
gemini: [],
|
|
82
92
|
pi: [],
|
|
93
|
+
claude: [/esc to interrupt/i],
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Program to type in a shell pane to start this harness. The first entry of the
|
|
97
|
+
* harness's command needle is the executable a shell would accept.
|
|
98
|
+
*/
|
|
99
|
+
const LAUNCH = {
|
|
100
|
+
codex: "codex",
|
|
101
|
+
cursor: "cursor-agent",
|
|
102
|
+
opencode: "opencode",
|
|
103
|
+
gemini: "agy",
|
|
104
|
+
pi: "pi",
|
|
105
|
+
claude: "claude",
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* How to ask a harness to quit its own UI. `/exit` is documented for OpenCode
|
|
109
|
+
* and Claude Code; the rest have no equally reliable in-band quit, so they carry
|
|
110
|
+
* `null` and the restart op falls back to Ctrl+C (twice) after a bounded wait.
|
|
111
|
+
*/
|
|
112
|
+
const EXIT = {
|
|
113
|
+
codex: null,
|
|
114
|
+
cursor: null,
|
|
115
|
+
opencode: { command: "/exit" },
|
|
116
|
+
gemini: null,
|
|
117
|
+
pi: null,
|
|
118
|
+
claude: { command: "/exit" },
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* A trailing line that means the harness finished starting and accepts input.
|
|
122
|
+
* OpenCode's idle screen pins its composer placeholder ("Ask anything…") and
|
|
123
|
+
* footer ("ctrl+p commands"); Claude Code pins "? for shortcuts". The profiles
|
|
124
|
+
* whose composer is not known well enough carry `[]`, and the restart op then
|
|
125
|
+
* falls back to waiting for the screen to settle instead of guessing.
|
|
126
|
+
*/
|
|
127
|
+
const READY = {
|
|
128
|
+
codex: [],
|
|
129
|
+
cursor: [],
|
|
130
|
+
opencode: [/ask anything/i, /ctrl\+p commands/i],
|
|
131
|
+
gemini: [],
|
|
132
|
+
pi: [],
|
|
133
|
+
claude: [/\? for shortcuts/i, /bypass permissions on/i],
|
|
83
134
|
};
|
|
84
135
|
const UNKNOWN = {
|
|
85
136
|
name: "unknown",
|
|
@@ -87,6 +138,9 @@ const UNKNOWN = {
|
|
|
87
138
|
waiting: GENERIC_WAITING,
|
|
88
139
|
queued: GENERIC_QUEUED,
|
|
89
140
|
busy: [/esc to interrupt/i],
|
|
141
|
+
launch: null,
|
|
142
|
+
exit: null,
|
|
143
|
+
ready: [],
|
|
90
144
|
};
|
|
91
145
|
function escapeRegExp(text) {
|
|
92
146
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -122,23 +176,40 @@ function matchTitle(title) {
|
|
|
122
176
|
}
|
|
123
177
|
return null;
|
|
124
178
|
}
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
* The command is the executable that spawned the pane, so it wins; the title
|
|
128
|
-
* is the fallback for panes launched from a generic shell. Never throws.
|
|
129
|
-
*/
|
|
130
|
-
export function resolveHarness(pane) {
|
|
131
|
-
const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
|
|
132
|
-
if (name === "unknown")
|
|
133
|
-
return UNKNOWN;
|
|
179
|
+
/** Assemble one known profile from the per-name tables, the one assembly path. */
|
|
180
|
+
function profileFor(name) {
|
|
134
181
|
return {
|
|
135
182
|
name,
|
|
136
183
|
submit: SUBMIT[name],
|
|
137
184
|
waiting: WAITING[name],
|
|
138
185
|
queued: QUEUED[name],
|
|
139
186
|
busy: BUSY[name],
|
|
187
|
+
launch: LAUNCH[name],
|
|
188
|
+
exit: EXIT[name],
|
|
189
|
+
ready: READY[name],
|
|
140
190
|
};
|
|
141
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Identify the harness, case-insensitively. An explicit `name` is the override
|
|
194
|
+
* for wrapper-launched panes whose command/title hides the real harness: a
|
|
195
|
+
* non-empty name must be one of `HARNESS_NAMES` (else `bad_arg`). Without an
|
|
196
|
+
* override the pane's command wins (the executable that spawned it), then the
|
|
197
|
+
* title for panes launched from a generic shell, then `unknown`.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveHarness(pane, name) {
|
|
200
|
+
const override = name?.trim().toLowerCase();
|
|
201
|
+
if (override) {
|
|
202
|
+
const known = HARNESS_NAMES.find((candidate) => candidate === override);
|
|
203
|
+
if (!known) {
|
|
204
|
+
throw new ZellijError("bad_arg", `unknown harness "${name.trim()}"; known harnesses: ${HARNESS_NAMES.join(", ")}`);
|
|
205
|
+
}
|
|
206
|
+
return profileFor(known);
|
|
207
|
+
}
|
|
208
|
+
const inferred = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
|
|
209
|
+
if (inferred === "unknown")
|
|
210
|
+
return UNKNOWN;
|
|
211
|
+
return profileFor(inferred);
|
|
212
|
+
}
|
|
142
213
|
/**
|
|
143
214
|
* Does this screen's trailing lines show the harness's queued-message hint?
|
|
144
215
|
* Only the last few lines count: a quoted marker in scrollback is not a queue.
|
|
@@ -148,12 +219,27 @@ export function queuedPrompt(screen, profile) {
|
|
|
148
219
|
return profile.queued.some((re) => lines.some((line) => re.test(line)));
|
|
149
220
|
}
|
|
150
221
|
/**
|
|
151
|
-
*
|
|
222
|
+
* The busy marker regex a screen shows on its trailing lines, or null. Only
|
|
152
223
|
* the last few lines count: a quoted marker in scrollback is not a live turn.
|
|
153
224
|
*/
|
|
225
|
+
export function busyMarker(screen, profile) {
|
|
226
|
+
const lines = trailingLines(screen);
|
|
227
|
+
return profile.busy.find((re) => lines.some((line) => re.test(line))) ?? null;
|
|
228
|
+
}
|
|
229
|
+
/** Does this screen's trailing lines show the harness actively working? */
|
|
154
230
|
export function busyPrompt(screen, profile) {
|
|
231
|
+
return busyMarker(screen, profile) !== null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Does this screen's trailing lines show the harness ready for input? An empty
|
|
235
|
+
* ready set is unknown, never a match: a profile that never declared a marker
|
|
236
|
+
* must not make the restart op wait forever.
|
|
237
|
+
*/
|
|
238
|
+
export function readyPrompt(screen, profile) {
|
|
239
|
+
if (profile.ready.length === 0)
|
|
240
|
+
return false;
|
|
155
241
|
const lines = trailingLines(screen);
|
|
156
|
-
return profile.
|
|
242
|
+
return profile.ready.some((re) => lines.some((line) => re.test(line)));
|
|
157
243
|
}
|
|
158
244
|
/** The six last non-empty, right-trimmed lines of a screen, CR stripped. */
|
|
159
245
|
function trailingLines(screen) {
|
package/dist/host/cli.js
CHANGED
|
@@ -84,12 +84,23 @@ async function crew(argv) {
|
|
|
84
84
|
return 2;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/** The DELIVERY cell: the recorded verdict, a reply failure, or `-`. */
|
|
88
|
+
function deliveryLabel(delivery) {
|
|
89
|
+
if (!delivery)
|
|
90
|
+
return "-";
|
|
91
|
+
if (delivery.submitted)
|
|
92
|
+
return delivery.submitted;
|
|
93
|
+
if (delivery.note)
|
|
94
|
+
return `failed:${delivery.note}`;
|
|
95
|
+
return "-";
|
|
96
|
+
}
|
|
87
97
|
/** A plain-text table for `relay --list`, one row per relay. */
|
|
88
98
|
function relayTable(rows) {
|
|
89
|
-
const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB", "LEASE"];
|
|
99
|
+
const header = ["ID", "STATE", "DELIVERY", "TO", "SESSION", "AGE", "LAST CHECK", "JOB", "LEASE"];
|
|
90
100
|
const body = rows.map((r) => [
|
|
91
101
|
r.id,
|
|
92
102
|
r.state,
|
|
103
|
+
deliveryLabel(r.delivery),
|
|
93
104
|
r.to,
|
|
94
105
|
r.session ?? "-",
|
|
95
106
|
`${r.age}s`,
|
|
@@ -114,7 +125,7 @@ function leaseTable(leases) {
|
|
|
114
125
|
return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
|
|
115
126
|
}
|
|
116
127
|
async function relay(argv) {
|
|
117
|
-
const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--leases", "--cancel-all", "--json", "--queue", "--force"]);
|
|
128
|
+
const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--leases", "--cancel-all", "--json", "--queue", "--force", "--if-idle"]);
|
|
118
129
|
const run = str(flags["--run"]);
|
|
119
130
|
if (run)
|
|
120
131
|
return runRelayDir(run);
|
|
@@ -172,6 +183,7 @@ async function relay(argv) {
|
|
|
172
183
|
to: str(flags["--to"]) ?? "",
|
|
173
184
|
bodyFile: str(flags["--body-file"]),
|
|
174
185
|
from: str(flags["--from"]),
|
|
186
|
+
harness: str(flags["--harness"]),
|
|
175
187
|
done: str(flags["--done"]) ?? "",
|
|
176
188
|
replyTo: str(flags["--reply-to"]) ?? "",
|
|
177
189
|
replySession: str(flags["--reply-session"]),
|
|
@@ -182,6 +194,7 @@ async function relay(argv) {
|
|
|
182
194
|
foreground: flags["--foreground"] === true,
|
|
183
195
|
queue: flags["--queue"] === true,
|
|
184
196
|
force: flags["--force"] === true,
|
|
197
|
+
ifIdle: flags["--if-idle"] === true,
|
|
185
198
|
}));
|
|
186
199
|
}
|
|
187
200
|
async function host(argv) {
|
|
@@ -244,14 +257,17 @@ const COMMANDS = {
|
|
|
244
257
|
relay: {
|
|
245
258
|
usage: [
|
|
246
259
|
"zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
|
|
247
|
-
" [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
|
|
248
|
-
" [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground]",
|
|
260
|
+
" [--reply-session S] [--from NAME] [--harness NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
|
|
261
|
+
" [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground] [--if-idle]",
|
|
249
262
|
" send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
|
|
250
263
|
" waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane;",
|
|
264
|
+
" --harness names the target harness and is passed on every send so delivery matches it;",
|
|
251
265
|
" --max-age (default --timeout-min + 60) is a hard end that always yields a final status",
|
|
252
266
|
" it leases its target pane until it ends; a second relay to a leased pane fails pane_leased",
|
|
253
267
|
" unless --queue waits for the lease to free or --force shares it",
|
|
254
|
-
"
|
|
268
|
+
" --if-idle applies send's busy check before the lease: a harness that shows it is working fails pane_busy",
|
|
269
|
+
" without sending; --queue then waits for the pane to go idle as well as for the lease, and --force skips the check",
|
|
270
|
+
"zswarm relay --list [--json] id, state, delivery, target, session, age, last check, job and lease per relay dir",
|
|
255
271
|
"zswarm relay --leases [--json] live pane leases and the relay that holds each",
|
|
256
272
|
"zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
|
|
257
273
|
"zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
|
package/dist/host/relay.d.ts
CHANGED
|
@@ -25,6 +25,19 @@ export type RelayJob = {
|
|
|
25
25
|
unit?: string;
|
|
26
26
|
pid?: number;
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* The last delivery verdict, persisted so `relay --list` can show whether the
|
|
30
|
+
* body (and later the reply) actually landed. A verdict is not a failure: the
|
|
31
|
+
* relay keeps waiting for the done line after recording one.
|
|
32
|
+
*/
|
|
33
|
+
export type RelayDelivery = {
|
|
34
|
+
/** The op's `submitted` value: "true", "false", "unverified", "queued", "not-delivered". */
|
|
35
|
+
submitted?: string;
|
|
36
|
+
/** Epoch milliseconds the verdict was recorded. */
|
|
37
|
+
at?: number;
|
|
38
|
+
/** A reply's hard failure code, or a short note about a soft-but-not-ok send. */
|
|
39
|
+
note?: string;
|
|
40
|
+
};
|
|
28
41
|
export type RelayConfig = {
|
|
29
42
|
id: string;
|
|
30
43
|
serve: string;
|
|
@@ -35,6 +48,8 @@ export type RelayConfig = {
|
|
|
35
48
|
replyTo: string;
|
|
36
49
|
replySession?: string;
|
|
37
50
|
from: string;
|
|
51
|
+
/** The target harness (`--harness`); sent with the body and the reply. */
|
|
52
|
+
harness?: string;
|
|
38
53
|
timeoutMin: number;
|
|
39
54
|
/** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
|
|
40
55
|
maxAgeMin?: number;
|
|
@@ -49,6 +64,8 @@ export type RelayConfig = {
|
|
|
49
64
|
job?: RelayJob;
|
|
50
65
|
/** Final status once the loop ends; absent while the relay runs. */
|
|
51
66
|
status?: RelayStatus;
|
|
67
|
+
/** The last delivery verdict (the initial send, then the reply). */
|
|
68
|
+
delivery?: RelayDelivery;
|
|
52
69
|
/** Set by `--cancel`; a late loop must not overwrite it. */
|
|
53
70
|
cancelled?: boolean;
|
|
54
71
|
cancelledAt?: number;
|
|
@@ -81,6 +98,20 @@ export declare function doneLines(screen: string, done: string): string[];
|
|
|
81
98
|
export declare function freshDoneLine(screen: string, done: string, seen?: readonly string[]): string | null;
|
|
82
99
|
/** Fill {id} {status} {note} {match} {output} {pane} in a message template. */
|
|
83
100
|
export declare function fillMessage(template: string, fields: Record<string, string>): string;
|
|
101
|
+
/**
|
|
102
|
+
* True when a send result is only a delivery *verdict*, not a hard failure.
|
|
103
|
+
*
|
|
104
|
+
* The op layer reports `ok:false, error.code="not_delivered"` when the pane was
|
|
105
|
+
* unchanged apart from the composer. That is the op saying "I could not
|
|
106
|
+
* confirm", not "the send failed": a slow-redrawing pane can still have taken
|
|
107
|
+
* the body, and a false not-delivered must never kill the relay. `submitted`
|
|
108
|
+
* values other than `true` (false / "unverified" / "queued") are the same kind
|
|
109
|
+
* of verdict. Only a hard error is worth aborting on: the pane is gone
|
|
110
|
+
* (peer_not_found / pane_exited / not_found) or serve/the tunnel is down
|
|
111
|
+
* (serve_* / connection failures), or the call itself threw. The relay records
|
|
112
|
+
* the verdict and keeps waiting — the done line is the real proof of delivery.
|
|
113
|
+
*/
|
|
114
|
+
export declare function softSendVerdict(res: OpsResult): boolean;
|
|
84
115
|
/** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
|
|
85
116
|
export declare function deliverMessage(cfg: RelayConfig, body: string, dir: string, deps?: RelayDeps): Promise<boolean>;
|
|
86
117
|
/** The waiting loop; returns the final status and the message it delivered or left behind. */
|
|
@@ -95,6 +126,8 @@ export type RelayStartInput = {
|
|
|
95
126
|
to: string;
|
|
96
127
|
bodyFile?: string;
|
|
97
128
|
from?: string;
|
|
129
|
+
/** Target harness name; sent with the body and the reply (omitted when absent). */
|
|
130
|
+
harness?: string;
|
|
98
131
|
done: string;
|
|
99
132
|
replyTo: string;
|
|
100
133
|
replySession?: string;
|
|
@@ -108,6 +141,8 @@ export type RelayStartInput = {
|
|
|
108
141
|
queue?: boolean;
|
|
109
142
|
/** Send to a leased pane anyway; the lease is shared and noted. */
|
|
110
143
|
force?: boolean;
|
|
144
|
+
/** With --if-idle, check the target harness before the lease: a visibly working pane fails pane_busy. */
|
|
145
|
+
ifIdle?: boolean;
|
|
111
146
|
env?: NodeJS.ProcessEnv;
|
|
112
147
|
deps?: RelayDeps;
|
|
113
148
|
/** State store for the pane lease; injected in tests. */
|
|
@@ -169,6 +204,8 @@ export type RelayRow = {
|
|
|
169
204
|
age: number;
|
|
170
205
|
state: string;
|
|
171
206
|
lastCheck?: number;
|
|
207
|
+
/** The last delivery verdict recorded in config.json, for the DELIVERY column. */
|
|
208
|
+
delivery?: RelayDelivery;
|
|
172
209
|
/** The launchd label / systemd unit / pid, when the job is still loaded. */
|
|
173
210
|
job?: string;
|
|
174
211
|
/** The live pane lease held for this relay's target, if any. */
|
package/dist/host/relay.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { ZellijError } from "../errors.js";
|
|
5
|
-
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
5
|
+
import { dispatchZswarm, paneBusyMarker } from "../ops/dispatch.js";
|
|
6
6
|
import { resolveServeCliLaunch } from "../ops/serve-install.js";
|
|
7
7
|
import { createStateStore, defaultStateDir } from "../state.js";
|
|
8
8
|
import { findLease, leaseKey, releaseLeaseKey, takeLease } from "./lease.js";
|
|
@@ -92,14 +92,35 @@ const NOTES = {
|
|
|
92
92
|
SERVE_DOWN: "serve was unreachable for three checks in a row; run zswarm doctor against it.",
|
|
93
93
|
UNCONFIRMED: "the pane printed the done line but the check never passed; dump the pane once for the error.",
|
|
94
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* True when a send result is only a delivery *verdict*, not a hard failure.
|
|
97
|
+
*
|
|
98
|
+
* The op layer reports `ok:false, error.code="not_delivered"` when the pane was
|
|
99
|
+
* unchanged apart from the composer. That is the op saying "I could not
|
|
100
|
+
* confirm", not "the send failed": a slow-redrawing pane can still have taken
|
|
101
|
+
* the body, and a false not-delivered must never kill the relay. `submitted`
|
|
102
|
+
* values other than `true` (false / "unverified" / "queued") are the same kind
|
|
103
|
+
* of verdict. Only a hard error is worth aborting on: the pane is gone
|
|
104
|
+
* (peer_not_found / pane_exited / not_found) or serve/the tunnel is down
|
|
105
|
+
* (serve_* / connection failures), or the call itself threw. The relay records
|
|
106
|
+
* the verdict and keeps waiting — the done line is the real proof of delivery.
|
|
107
|
+
*/
|
|
108
|
+
export function softSendVerdict(res) {
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
return res.error.code === "not_delivered";
|
|
111
|
+
const submitted = res.data?.submitted;
|
|
112
|
+
return submitted !== true;
|
|
113
|
+
}
|
|
95
114
|
/** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
|
|
96
115
|
export async function deliverMessage(cfg, body, dir, deps = {}) {
|
|
97
116
|
const env = deps.env ?? process.env;
|
|
98
117
|
const call = deps.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
99
118
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
119
|
+
const now = deps.now ?? Date.now;
|
|
100
120
|
const log = deps.log ?? (() => { });
|
|
101
121
|
writeFileSync(join(dir, "message.txt"), body);
|
|
102
122
|
const delays = delaysFrom(env, deps.delays);
|
|
123
|
+
let failureCode = "failed";
|
|
103
124
|
for (const [i, delay] of delays.entries()) {
|
|
104
125
|
await sleep(delay * 1000);
|
|
105
126
|
// Generous Zellij budgets: this runs exactly when the machine is busiest.
|
|
@@ -108,16 +129,23 @@ export async function deliverMessage(cfg, body, dir, deps = {}) {
|
|
|
108
129
|
};
|
|
109
130
|
if (cfg.replySession)
|
|
110
131
|
args.session = cfg.replySession;
|
|
132
|
+
if (cfg.harness)
|
|
133
|
+
args.harness = cfg.harness;
|
|
111
134
|
const res = await call(args, localEnv(env)).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
|
|
112
135
|
if (res.ok) {
|
|
113
|
-
|
|
136
|
+
const submitted = String(res.data?.submitted ?? "unknown");
|
|
137
|
+
recordDelivery(dir, submitted, now());
|
|
138
|
+
log(`message attempt ${i + 1}: delivered, submitted=${submitted}`);
|
|
114
139
|
return true;
|
|
115
140
|
}
|
|
141
|
+
failureCode = res.error.code;
|
|
116
142
|
log(`message attempt ${i + 1} failed: ${res.error.code}: ${res.error.message.slice(0, 200)}`);
|
|
117
143
|
}
|
|
118
144
|
const fallback = join(relaysDir(env), "undelivered");
|
|
119
145
|
mkdirSync(fallback, { recursive: true, mode: 0o700 });
|
|
120
146
|
writeFileSync(join(fallback, `${cfg.id}.txt`), body);
|
|
147
|
+
// The reply never landed: record the last failure code so --list shows failed:<code>.
|
|
148
|
+
recordDelivery(dir, undefined, now(), failureCode);
|
|
121
149
|
log(`message not delivered after ${delays.length} attempts; left in ${join(fallback, `${cfg.id}.txt`)}`);
|
|
122
150
|
return false;
|
|
123
151
|
}
|
|
@@ -304,6 +332,11 @@ function updateConfig(dir, patch) {
|
|
|
304
332
|
const cfg = { ...JSON.parse(readFileSync(path, "utf8")), ...patch };
|
|
305
333
|
writePrivate(path, `${JSON.stringify(cfg, null, 1)}\n`);
|
|
306
334
|
}
|
|
335
|
+
/** Record the last delivery verdict in config.json; `note` carries a hard failure code. */
|
|
336
|
+
function recordDelivery(dir, submitted, at, note) {
|
|
337
|
+
const delivery = { ...(submitted ? { submitted } : {}), at, ...(note ? { note } : {}) };
|
|
338
|
+
updateConfig(dir, { delivery });
|
|
339
|
+
}
|
|
307
340
|
/** Persist the final status unless `--cancel` already marked the relay. */
|
|
308
341
|
function writeFinalStatus(dir, status) {
|
|
309
342
|
try {
|
|
@@ -391,6 +424,7 @@ export async function startRelay(input) {
|
|
|
391
424
|
// The detached job gets a minimal env, so pin the caller's session now.
|
|
392
425
|
replySession: input.replySession?.trim() || env.ZSWARM_SESSION?.trim() || env.ZELLIJ_SESSION_NAME?.trim() || undefined,
|
|
393
426
|
from: input.from?.trim() || "zswarm-relay",
|
|
427
|
+
harness: input.harness?.trim() || undefined,
|
|
394
428
|
timeoutMin: input.timeoutMin ?? 240,
|
|
395
429
|
maxAgeMin: input.maxAgeMin ?? (input.timeoutMin ?? 240) + DEFAULT_MAX_AGE_EXTRA_MIN,
|
|
396
430
|
};
|
|
@@ -404,15 +438,70 @@ export async function startRelay(input) {
|
|
|
404
438
|
let how;
|
|
405
439
|
const leaseNow = input.deps?.now ?? Date.now;
|
|
406
440
|
const leaseSleep = input.deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
441
|
+
const call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
442
|
+
const log = input.deps?.log ?? ((line) => appendFileSync(join(dir, "relay.log"), `${new Date().toISOString()} ${line}\n`));
|
|
443
|
+
const remote = serveEnv(cfg, env);
|
|
444
|
+
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
407
445
|
const take = () => takeLease(store, { relayId: id, from: cfg.from, at: leaseNow(), dir, target: leaseTarget, force: input.force });
|
|
408
446
|
const leaseKeyValue = leaseKey(leaseTarget);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
447
|
+
/** The pane --to names, with the command/title the harness check needs. */
|
|
448
|
+
const resolveRemotePane = async () => {
|
|
449
|
+
const args = { op: "list" };
|
|
450
|
+
if (cfg.session)
|
|
451
|
+
args.session = cfg.session;
|
|
452
|
+
const res = await call(args, remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
|
|
453
|
+
const panes = res.ok
|
|
454
|
+
? res.data.panes ?? []
|
|
455
|
+
: [];
|
|
456
|
+
const pane = panes.find((p) => p.title === cfg.to || p.id === cfg.to);
|
|
457
|
+
return pane ? { command: pane.command, title: pane.title } : { title: cfg.to };
|
|
458
|
+
};
|
|
459
|
+
// --if-idle: the same busy check send --if-idle uses, one dump before the
|
|
460
|
+
// lease is taken. --force skips it, as it skips the lease.
|
|
461
|
+
const checkIdle = input.ifIdle === true && input.force !== true;
|
|
462
|
+
const idlePane = checkIdle ? await resolveRemotePane() : undefined;
|
|
463
|
+
/** The harness working marker one dump of the target shows, or null. */
|
|
464
|
+
const busyMarkerNow = async () => {
|
|
465
|
+
const res = await call(target({ op: "dump", to: cfg.to, max: 400 }), remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
|
|
466
|
+
if (!res.ok)
|
|
467
|
+
return null; // cannot see the screen: the lease is the only guard
|
|
468
|
+
return paneBusyMarker(String(res.data?.text ?? ""), idlePane ?? { title: cfg.to }, cfg.harness);
|
|
469
|
+
};
|
|
470
|
+
const deadline = leaseNow() + cfg.timeoutMin * 60_000;
|
|
471
|
+
let busy = null;
|
|
472
|
+
let taken;
|
|
473
|
+
if (!input.queue) {
|
|
474
|
+
if (checkIdle) {
|
|
475
|
+
busy = await busyMarkerNow();
|
|
476
|
+
if (busy) {
|
|
477
|
+
rmSync(dir, { recursive: true, force: true });
|
|
478
|
+
throw new ZellijError("pane_busy", `pane ${cfg.to} is busy with a task (marker: ${busy}); retry when it is idle`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
taken = take();
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
// --queue polls the lease and, under --if-idle, the busy marker. The lease
|
|
485
|
+
// is only taken once the pane is not visibly working.
|
|
486
|
+
let attempt;
|
|
487
|
+
for (;;) {
|
|
488
|
+
if (checkIdle)
|
|
489
|
+
busy = await busyMarkerNow();
|
|
490
|
+
attempt = busy ? undefined : take();
|
|
491
|
+
if (attempt?.ok)
|
|
492
|
+
break;
|
|
493
|
+
if (leaseNow() >= deadline)
|
|
494
|
+
break;
|
|
413
495
|
await leaseSleep(Math.min(LEASE_QUEUE_POLL_MS, Math.max(1, deadline - leaseNow())));
|
|
414
|
-
taken = take();
|
|
415
496
|
}
|
|
497
|
+
if (attempt?.ok)
|
|
498
|
+
taken = attempt;
|
|
499
|
+
else if (busy) {
|
|
500
|
+
rmSync(dir, { recursive: true, force: true });
|
|
501
|
+
throw new ZellijError("pane_busy", `pane ${cfg.to} is busy with a task (marker: ${busy}) and did not free before --timeout-min`);
|
|
502
|
+
}
|
|
503
|
+
else
|
|
504
|
+
taken = attempt ?? take();
|
|
416
505
|
}
|
|
417
506
|
if (!taken.ok) {
|
|
418
507
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -423,16 +512,23 @@ export async function startRelay(input) {
|
|
|
423
512
|
if (taken.shared) {
|
|
424
513
|
warning = `pane ${cfg.to} is already leased by relay ${taken.lease.relayId}; --force shares its lease`;
|
|
425
514
|
}
|
|
426
|
-
const call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
427
|
-
const remote = serveEnv(cfg, env);
|
|
428
|
-
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
429
515
|
try {
|
|
430
516
|
if (body !== undefined) {
|
|
431
|
-
const
|
|
432
|
-
if (
|
|
517
|
+
const sendArgs = { op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 };
|
|
518
|
+
if (cfg.harness)
|
|
519
|
+
sendArgs.harness = cfg.harness;
|
|
520
|
+
const res = await call(target(sendArgs), remote);
|
|
521
|
+
// A non-ok send is only fatal when it is a hard error. `not_delivered` is a
|
|
522
|
+
// verdict ("could not confirm"), so the relay records it and keeps waiting.
|
|
523
|
+
if (!res.ok && !softSendVerdict(res)) {
|
|
433
524
|
throw new ZellijError(res.error.code, `send failed: ${res.error.message}`, res.error.details);
|
|
434
|
-
|
|
525
|
+
}
|
|
526
|
+
const rawSubmitted = res.ok ? res.data?.submitted : undefined;
|
|
527
|
+
sent = rawSubmitted === true ? "submitted" : "unverified";
|
|
528
|
+
const submittedText = rawSubmitted === undefined ? (res.ok ? "unverified" : "not-delivered") : String(rawSubmitted);
|
|
529
|
+
recordDelivery(dir, submittedText, leaseNow(), res.ok ? undefined : res.error.code);
|
|
435
530
|
if (sent !== "submitted") {
|
|
531
|
+
log(`send verdict submitted=${submittedText}; waiting for the done line`);
|
|
436
532
|
// One dump; one Enter if the body still sits in the composer.
|
|
437
533
|
const dump = await call(target({ op: "dump", to: cfg.to, max: 2000 }), remote);
|
|
438
534
|
const screen = dump.ok ? String(dump.data.text ?? "") : "";
|
|
@@ -669,6 +765,7 @@ export function listRelays(deps = {}) {
|
|
|
669
765
|
age: Math.max(0, Math.floor((now() - run.started) / 1000)),
|
|
670
766
|
state: relayState(run.dir, cfg),
|
|
671
767
|
lastCheck: lastCheckResult(run.dir),
|
|
768
|
+
...(cfg.delivery ? { delivery: cfg.delivery } : {}),
|
|
672
769
|
...(loaded && cfg.job ? { job: jobLabelText(cfg.job) } : {}),
|
|
673
770
|
...(held ? { lease: { relayId: held.relayId, from: held.from, at: held.at } } : {}),
|
|
674
771
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { createZellijClient, type ZellijClient, type ZellijClientOptions, type Z
|
|
|
12
12
|
export { createStateStore, defaultStateDir, type LeaseRecord, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
13
13
|
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, type LeaseTakeInput, type LeaseTakeResult, type LeaseTarget, } from "./host/lease.js";
|
|
14
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
15
|
+
export { EXIT_WAIT_MS, HANDOFF_INLINE_MAX, HANDOFF_MARKER, HANDOFF_SELF_WAIT_MS, READY_SETTLE_MS, READY_STABLE_GAP_MS, READY_WAIT_MS, restartLaunchCommand, restartPane, restartPaneKind, type RestartPaneKind, type RestartStep, } from "./ops/restart.js";
|
|
16
|
+
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, type HarnessExitRecipe, type HarnessProfile, type SubmitStrategy, } from "./harness.js";
|
|
15
17
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
16
18
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type IpcDiscoveryState, type SshExecFn, type SshTarget, } from "./exec.js";
|
|
17
19
|
export { resolveSshTarget } from "./zellij/binary.js";
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ export { createZellijClient, } from "./zellij/client.js";
|
|
|
12
12
|
export { createStateStore, defaultStateDir, } from "./state.js";
|
|
13
13
|
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, } from "./host/lease.js";
|
|
14
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
15
|
+
export { EXIT_WAIT_MS, HANDOFF_INLINE_MAX, HANDOFF_MARKER, HANDOFF_SELF_WAIT_MS, READY_SETTLE_MS, READY_STABLE_GAP_MS, READY_WAIT_MS, restartLaunchCommand, restartPane, restartPaneKind, } from "./ops/restart.js";
|
|
16
|
+
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, } from "./harness.js";
|
|
15
17
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
16
18
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
17
19
|
export { resolveSshTarget } from "./zellij/binary.js";
|