@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 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
- * Identify the harness from a pane's command and title, case-insensitively.
25
- * The command is the executable that spawned the pane, so it wins; the title
26
- * is the fallback for panes launched from a generic shell. Never throws.
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
- * Does this screen's trailing lines show the harness actively working? Only
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
- * Identify the harness from a pane's command and title, case-insensitively.
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
- * Does this screen's trailing lines show the harness actively working? Only
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.busy.some((re) => lines.some((line) => re.test(line)));
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
- "zswarm relay --list [--json] id, state, target, session, age, last check, job and lease per relay dir",
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)",
@@ -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. */
@@ -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
- log(`message attempt ${i + 1}: delivered, submitted=${String(res.data?.submitted)}`);
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
- let taken = take();
410
- if (!taken.ok && input.queue) {
411
- const deadline = leaseNow() + cfg.timeoutMin * 60_000;
412
- while (!taken.ok && leaseNow() < deadline) {
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 res = await call(target({ op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 }), remote);
432
- if (!res.ok)
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
- sent = res.data?.submitted === true ? "submitted" : "unverified";
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";