@zswarm/core 0.2.7 → 0.2.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/harness.d.ts CHANGED
@@ -37,15 +37,23 @@ export type HarnessProfile = {
37
37
  /** Trailing lines showing the harness finished starting and is ready for input. */
38
38
  ready: readonly RegExp[];
39
39
  };
40
+ type HarnessName = Exclude<HarnessProfile["name"], "unknown">;
40
41
  /**
41
- * Identify the harness from a pane's command and title, case-insensitively.
42
- * The command is the executable that spawned the pane, so it wins; the title
43
- * 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`.
44
52
  */
45
53
  export declare function resolveHarness(pane: {
46
54
  command?: string | null;
47
55
  title?: string | null;
48
- }): HarnessProfile;
56
+ }, name?: string | null): HarnessProfile;
49
57
  /**
50
58
  * Does this screen's trailing lines show the harness's queued-message hint?
51
59
  * Only the last few lines count: a quoted marker in scrollback is not a queue.
@@ -64,3 +72,4 @@ export declare function busyPrompt(screen: string, profile: HarnessProfile): boo
64
72
  * must not make the restart op wait forever.
65
73
  */
66
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"] },
@@ -7,6 +8,11 @@ const PROFILES = [
7
8
  { name: "pi", command: ["pi"], title: ["agent-pi"] },
8
9
  { name: "claude", command: ["claude"], title: ["agent-claude"] },
9
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);
10
16
  /**
11
17
  * Only codex needs a second Enter to submit: its composer keeps the pasted
12
18
  * message (`› [zswarm from=...]`) until an extra Enter lands — verified live
@@ -170,15 +176,8 @@ function matchTitle(title) {
170
176
  }
171
177
  return null;
172
178
  }
173
- /**
174
- * Identify the harness from a pane's command and title, case-insensitively.
175
- * The command is the executable that spawned the pane, so it wins; the title
176
- * is the fallback for panes launched from a generic shell. Never throws.
177
- */
178
- export function resolveHarness(pane) {
179
- const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
180
- if (name === "unknown")
181
- return UNKNOWN;
179
+ /** Assemble one known profile from the per-name tables, the one assembly path. */
180
+ function profileFor(name) {
182
181
  return {
183
182
  name,
184
183
  submit: SUBMIT[name],
@@ -190,6 +189,27 @@ export function resolveHarness(pane) {
190
189
  ready: READY[name],
191
190
  };
192
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
+ }
193
213
  /**
194
214
  * Does this screen's trailing lines show the harness's queued-message hint?
195
215
  * Only the last few lines count: a quoted marker in scrollback is not a queue.
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`,
@@ -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"]),
@@ -245,16 +257,17 @@ const COMMANDS = {
245
257
  relay: {
246
258
  usage: [
247
259
  "zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
248
- " [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
260
+ " [--reply-session S] [--from NAME] [--harness NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
249
261
  " [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground] [--if-idle]",
250
262
  " send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
251
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;",
252
265
  " --max-age (default --timeout-min + 60) is a hard end that always yields a final status",
253
266
  " it leases its target pane until it ends; a second relay to a leased pane fails pane_leased",
254
267
  " unless --queue waits for the lease to free or --force shares it",
255
268
  " --if-idle applies send's busy check before the lease: a harness that shows it is working fails pane_busy",
256
269
  " without sending; --queue then waits for the pane to go idle as well as for the lease, and --force skips the check",
257
- "zswarm relay --list [--json] id, state, target, session, age, last check, job and lease per relay dir",
270
+ "zswarm relay --list [--json] id, state, delivery, target, session, age, last check, job and lease per relay dir",
258
271
  "zswarm relay --leases [--json] live pane leases and the relay that holds each",
259
272
  "zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
260
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;
@@ -171,6 +204,8 @@ export type RelayRow = {
171
204
  age: number;
172
205
  state: string;
173
206
  lastCheck?: number;
207
+ /** The last delivery verdict recorded in config.json, for the DELIVERY column. */
208
+ delivery?: RelayDelivery;
174
209
  /** The launchd label / systemd unit / pid, when the job is still loaded. */
175
210
  job?: string;
176
211
  /** The live pane lease held for this relay's target, if any. */
@@ -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
  };
@@ -405,6 +439,7 @@ export async function startRelay(input) {
405
439
  const leaseNow = input.deps?.now ?? Date.now;
406
440
  const leaseSleep = input.deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
407
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`));
408
443
  const remote = serveEnv(cfg, env);
409
444
  const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
410
445
  const take = () => takeLease(store, { relayId: id, from: cfg.from, at: leaseNow(), dir, target: leaseTarget, force: input.force });
@@ -430,7 +465,7 @@ export async function startRelay(input) {
430
465
  const res = await call(target({ op: "dump", to: cfg.to, max: 400 }), remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
431
466
  if (!res.ok)
432
467
  return null; // cannot see the screen: the lease is the only guard
433
- return paneBusyMarker(String(res.data?.text ?? ""), idlePane ?? { title: cfg.to });
468
+ return paneBusyMarker(String(res.data?.text ?? ""), idlePane ?? { title: cfg.to }, cfg.harness);
434
469
  };
435
470
  const deadline = leaseNow() + cfg.timeoutMin * 60_000;
436
471
  let busy = null;
@@ -479,11 +514,21 @@ export async function startRelay(input) {
479
514
  }
480
515
  try {
481
516
  if (body !== undefined) {
482
- const res = await call(target({ op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 }), remote);
483
- 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)) {
484
524
  throw new ZellijError(res.error.code, `send failed: ${res.error.message}`, res.error.details);
485
- 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);
486
530
  if (sent !== "submitted") {
531
+ log(`send verdict submitted=${submittedText}; waiting for the done line`);
487
532
  // One dump; one Enter if the body still sits in the composer.
488
533
  const dump = await call(target({ op: "dump", to: cfg.to, max: 2000 }), remote);
489
534
  const screen = dump.ok ? String(dump.data.text ?? "") : "";
@@ -720,6 +765,7 @@ export function listRelays(deps = {}) {
720
765
  age: Math.max(0, Math.floor((now() - run.started) / 1000)),
721
766
  state: relayState(run.dir, cfg),
722
767
  lastCheck: lastCheckResult(run.dir),
768
+ ...(cfg.delivery ? { delivery: cfg.delivery } : {}),
723
769
  ...(loaded && cfg.job ? { job: jobLabelText(cfg.job) } : {}),
724
770
  ...(held ? { lease: { relayId: held.relayId, from: held.from, at: held.at } } : {}),
725
771
  });
@@ -1,3 +1,4 @@
1
+ import type { HarnessProfile } from "../harness.js";
1
2
  import type { StateStore } from "../state.js";
2
3
  import type { ZellijClient } from "../zellij/client.js";
3
4
  import type { ZellijPane } from "../zellij/panes.js";
@@ -20,19 +21,26 @@ export declare const SEND_BUDGETS: {
20
21
  readonly settleMs: 300;
21
22
  /** Pane-lookup retry window for an absent pane. */
22
23
  readonly observeMs: 1000;
24
+ /**
25
+ * Base window that polls the pane for positive evidence before any negative
26
+ * verdict; scaled up for slow panes and capped by `confirmMaxMs`.
27
+ */
28
+ readonly confirmMs: 1000;
23
29
  /** The scaled window never exceeds this multiple of the default. */
24
30
  readonly maxScale: 5;
25
31
  /** A first Zellij call at or under this many ms is not "slow". */
26
32
  readonly slowMs: 1000;
27
33
  readonly settleMaxMs: 5000;
28
34
  readonly observeMaxMs: 30000;
35
+ readonly confirmMaxMs: 15000;
29
36
  };
30
37
  /**
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.
38
+ * Scale the fixed settle/observe/confirm windows by how slow the send's first
39
+ * Zellij call was. A call under `slowMs` keeps the defaults; five seconds or
40
+ * more pins the 5x cap. Explicit `--settle-ms` / `--observe-ms` are returned as
41
+ * given, and a scaled value is capped by the call's own timeout so it cannot
42
+ * outlast the observation budget. `confirmMs` is always scaled (no explicit
43
+ * flag) and capped by both `confirmMaxMs` and the call timeout.
36
44
  */
37
45
  export declare function scaleBudgets(input: {
38
46
  firstCallMs: number;
@@ -42,6 +50,7 @@ export declare function scaleBudgets(input: {
42
50
  }): {
43
51
  settleMs: number;
44
52
  observeMs: number;
53
+ confirmMs: number;
45
54
  scale: number;
46
55
  };
47
56
  export type SenderSource = "arg" | "env" | "title" | "default";
@@ -78,7 +87,8 @@ export declare function attachKnownSender(args: Record<string, unknown>, env: No
78
87
  export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
79
88
  /**
80
89
  * Caller `submit=` always wins. With none given, the pane's harness picks
81
- * auto vs double-enter (codex needs two Enters; the rest submit on one).
90
+ * auto vs double-enter (codex needs two Enters; the rest submit on one). An
91
+ * explicit `--harness` overrides the pane-inferred profile.
82
92
  */
83
93
  export declare function resolveSubmitMode(args: Record<string, unknown>, pane: {
84
94
  command?: string | null;
@@ -107,6 +117,20 @@ export declare function composerHolds(screen: string, body: string): boolean;
107
117
  export declare function classifySubmit(before: string, after: string, body: string, opts?: {
108
118
  queued?: (screen: string) => boolean;
109
119
  }): Submitted;
120
+ /**
121
+ * Press Enter once and report whether the body submitted, without pasting.
122
+ * The restart op uses this to re-ask a still-held composer; a failed dump is
123
+ * unverified, never a negative verdict.
124
+ */
125
+ export declare function submitAfterEnter(client: ZellijClient, input: {
126
+ session: string;
127
+ paneId: string;
128
+ body: string;
129
+ before: string;
130
+ clock: Clock;
131
+ settleMs: number;
132
+ profile: HarnessProfile;
133
+ }): Promise<Submitted>;
110
134
  /** Paste a body into one pane and record the attempt. */
111
135
  export declare function deliverTo(client: ZellijClient, state: StateStore, args: Record<string, unknown>, input: {
112
136
  session: string;
@@ -1,24 +1,33 @@
1
- import { queuedPrompt, resolveHarness } from "../harness.js";
2
- import { isTrue, numberArg } from "./util.js";
1
+ import { busyMarker, queuedPrompt, resolveHarness } from "../harness.js";
2
+ import { isTrue, numberArg, optionalString } from "./util.js";
3
3
  /** Fixed send windows before adaptive scaling; explicit flags always win. */
4
4
  export const SEND_BUDGETS = {
5
5
  /** Pause before checking the paste landed. */
6
6
  settleMs: 300,
7
7
  /** Pane-lookup retry window for an absent pane. */
8
8
  observeMs: 1_000,
9
+ /**
10
+ * Base window that polls the pane for positive evidence before any negative
11
+ * verdict; scaled up for slow panes and capped by `confirmMaxMs`.
12
+ */
13
+ confirmMs: 1_000,
9
14
  /** The scaled window never exceeds this multiple of the default. */
10
15
  maxScale: 5,
11
16
  /** A first Zellij call at or under this many ms is not "slow". */
12
17
  slowMs: 1_000,
13
18
  settleMaxMs: 5_000,
14
19
  observeMaxMs: 30_000,
20
+ confirmMaxMs: 15_000,
15
21
  };
22
+ /** Upper bound on confirm polls, so a pinned deadline cannot spin forever. */
23
+ const MAX_CONFIRM_POLLS = 40;
16
24
  /**
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.
25
+ * Scale the fixed settle/observe/confirm windows by how slow the send's first
26
+ * Zellij call was. A call under `slowMs` keeps the defaults; five seconds or
27
+ * more pins the 5x cap. Explicit `--settle-ms` / `--observe-ms` are returned as
28
+ * given, and a scaled value is capped by the call's own timeout so it cannot
29
+ * outlast the observation budget. `confirmMs` is always scaled (no explicit
30
+ * flag) and capped by both `confirmMaxMs` and the call timeout.
22
31
  */
23
32
  export function scaleBudgets(input) {
24
33
  const raw = input.firstCallMs > SEND_BUDGETS.slowMs ? input.firstCallMs / SEND_BUDGETS.slowMs : 1;
@@ -27,6 +36,7 @@ export function scaleBudgets(input) {
27
36
  return {
28
37
  settleMs: input.settleMs !== undefined ? Math.min(input.settleMs, SEND_BUDGETS.settleMaxMs) : scaled(SEND_BUDGETS.settleMs, SEND_BUDGETS.settleMaxMs),
29
38
  observeMs: input.observeMs !== undefined ? Math.min(input.observeMs, SEND_BUDGETS.observeMaxMs) : scaled(SEND_BUDGETS.observeMs, SEND_BUDGETS.observeMaxMs),
39
+ confirmMs: scaled(SEND_BUDGETS.confirmMs, SEND_BUDGETS.confirmMaxMs),
30
40
  scale,
31
41
  };
32
42
  }
@@ -91,7 +101,8 @@ export function bodyText(client, args, body) {
91
101
  }
92
102
  /**
93
103
  * Caller `submit=` always wins. With none given, the pane's harness picks
94
- * auto vs double-enter (codex needs two Enters; the rest submit on one).
104
+ * auto vs double-enter (codex needs two Enters; the rest submit on one). An
105
+ * explicit `--harness` overrides the pane-inferred profile.
95
106
  */
96
107
  export function resolveSubmitMode(args, pane) {
97
108
  const raw = args.submit;
@@ -99,7 +110,7 @@ export function resolveSubmitMode(args, pane) {
99
110
  return raw;
100
111
  if (raw !== undefined && raw !== null && raw !== "")
101
112
  return "auto";
102
- return resolveHarness(pane).submit;
113
+ return resolveHarness(pane, optionalString(args.harness)).submit;
103
114
  }
104
115
  function nonEmptyLines(screen) {
105
116
  return screen
@@ -205,7 +216,7 @@ async function dumpOrNull(client, session, paneId) {
205
216
  }
206
217
  }
207
218
  async function verifySubmit(client, input) {
208
- const { session, paneId, body, mode, settleMs, clock, before, queued } = input;
219
+ const { session, paneId, body, mode, settleMs, confirmMs, clock, before, profile, queued } = input;
209
220
  const classify = (base, screen) => classifySubmit(base, screen, body, { queued });
210
221
  if (mode === "none")
211
222
  return "unverified";
@@ -232,24 +243,58 @@ async function verifySubmit(client, input) {
232
243
  verdict = classify(before, rescued);
233
244
  if (verdict === true || verdict === "queued")
234
245
  return verdict;
235
- if (verdict === false)
236
- return false;
237
246
  }
238
- // Inconclusive or not (yet) on screen; TUIs often redraw after the first settle.
239
- await clock.sleep(Math.min(Math.max(settleMs * 2, 800), 5000));
240
- const later = await dumpOrNull(client, session, paneId);
241
- if (later === null)
242
- return verdict;
243
- if (composerHolds(after, body) && !composerHolds(later, body))
244
- return true;
245
- return classify(before, later);
247
+ // Nothing conclusive yet: a freshly restarted agent can redraw slowly, so poll
248
+ // for the whole scaled confirm window before any negative verdict. Positive
249
+ // evidence is new output, a queue hint, the harness's busy marker, or the body
250
+ // leaving a composer it was sitting in.
251
+ const pollMs = Math.min(Math.max(settleMs * 2, 500), 1_000);
252
+ const deadline = clock.now() + confirmMs;
253
+ for (let polls = 0; polls < MAX_CONFIRM_POLLS && clock.now() < deadline; polls++) {
254
+ await clock.sleep(Math.min(pollMs, Math.max(0, deadline - clock.now())));
255
+ const later = await dumpOrNull(client, session, paneId);
256
+ if (later === null)
257
+ continue; // unreadable pane: keep polling
258
+ const seen = classify(before, later);
259
+ if (seen === true || seen === "queued")
260
+ return seen;
261
+ if (busyMarker(later, profile))
262
+ return true;
263
+ if (composerHolds(after, body) && !composerHolds(later, body))
264
+ return true;
265
+ verdict = seen;
266
+ }
267
+ // An unknown harness has no reliable busy marker, so an unchanged screen is
268
+ // not proof the body never arrived; a false not-delivered would make the
269
+ // caller resend the whole message. A known harness's not-delivered is now
270
+ // backed by the whole window.
271
+ if (verdict === "not-delivered" && profile.name === "unknown")
272
+ return "unverified";
273
+ return verdict;
274
+ }
275
+ /**
276
+ * Press Enter once and report whether the body submitted, without pasting.
277
+ * The restart op uses this to re-ask a still-held composer; a failed dump is
278
+ * unverified, never a negative verdict.
279
+ */
280
+ export async function submitAfterEnter(client, input) {
281
+ const { session, paneId, body, before, clock, settleMs, profile } = input;
282
+ await client.sendKeys({ session, paneId, keys: ["Enter"] });
283
+ await clock.sleep(settleMs);
284
+ const screen = await dumpOrNull(client, session, paneId);
285
+ if (screen === null)
286
+ return "unverified";
287
+ return classifySubmit(before, screen, body, {
288
+ queued: (s) => queuedPrompt(s, profile),
289
+ });
246
290
  }
247
291
  /** Paste a body into one pane and record the attempt. */
248
292
  export async function deliverTo(client, state, args, input) {
249
293
  const { session, pane, body, op, at, clock, firstCallMs = 0, timeoutMs = 30_000 } = input;
250
294
  const from = senderLabel(args);
295
+ const override = optionalString(args.harness);
251
296
  const mode = resolveSubmitMode(args, pane);
252
- const profile = resolveHarness(pane);
297
+ const profile = resolveHarness(pane, override);
253
298
  const queued = (screen) => queuedPrompt(screen, profile);
254
299
  const explicitSettle = args.settleMs !== undefined && args.settleMs !== null && args.settleMs !== "";
255
300
  const budgets = scaleBudgets({
@@ -258,6 +303,7 @@ export async function deliverTo(client, state, args, input) {
258
303
  settleMs: explicitSettle ? numberArg(args, "settleMs", SEND_BUDGETS.settleMs, { min: 50, max: SEND_BUDGETS.settleMaxMs }) : undefined,
259
304
  });
260
305
  const settleMs = budgets.settleMs;
306
+ const confirmMs = budgets.confirmMs;
261
307
  const confirmOnce = isTrue(args.confirm);
262
308
  try {
263
309
  let before = null;
@@ -276,8 +322,10 @@ export async function deliverTo(client, state, args, input) {
276
322
  body,
277
323
  mode,
278
324
  settleMs,
325
+ confirmMs,
279
326
  clock,
280
327
  before,
328
+ profile,
281
329
  queued,
282
330
  });
283
331
  // Opt-in confirm. A second paste is only safe on positive evidence the
@@ -293,8 +341,10 @@ export async function deliverTo(client, state, args, input) {
293
341
  body,
294
342
  mode,
295
343
  settleMs,
344
+ confirmMs,
296
345
  clock,
297
346
  before: retryBefore,
347
+ profile,
298
348
  queued,
299
349
  });
300
350
  }
@@ -8,7 +8,7 @@ import type { DispatchDeps, OpsResult } from "./types.js";
8
8
  export declare function paneBusyMarker(screen: string, pane: {
9
9
  command?: string | null;
10
10
  title?: string | null;
11
- }): string | null;
11
+ }, harness?: string | null): string | null;
12
12
  /**
13
13
  * Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
14
14
  * and serve (and remote IPC). `--ssh` sets the destination for this call and
@@ -36,8 +36,8 @@ import { installServeService, uninstallServeService } from "../host/serve-servic
36
36
  * busy check behind `send --if-idle` and `relay --if-idle`; the marker names
37
37
  * itself (its regex source) in the `pane_busy` error.
38
38
  */
39
- export function paneBusyMarker(screen, pane) {
40
- return busyMarker(screen, resolveHarness(pane))?.source ?? null;
39
+ export function paneBusyMarker(screen, pane, harness) {
40
+ return busyMarker(screen, resolveHarness(pane, harness))?.source ?? null;
41
41
  }
42
42
  /**
43
43
  * Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
@@ -412,7 +412,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
412
412
  }
413
413
  const screen = await client.dumpPane({ session, paneId: pane.id }).then((res) => res.text, () => null);
414
414
  if (screen !== null) {
415
- const marker = paneBusyMarker(screen, pane);
415
+ const marker = paneBusyMarker(screen, pane, optionalString(args.harness));
416
416
  if (marker) {
417
417
  throw new ZellijError("pane_busy", `pane ${to} is busy with a task (marker: ${marker}); retry when it is idle`);
418
418
  }
@@ -27,6 +27,8 @@ export declare const READY_SETTLE_MS = 500;
27
27
  export declare const READY_STABLE_GAP_MS = 1000;
28
28
  /** Screen poll interval while a step waits. */
29
29
  export declare const RESTART_POLL_MS = 250;
30
+ /** Settle after the rescue Enter before re-reading the composer, matching the relay. */
31
+ export declare const SUBMIT_ENTER_SETTLE_MS = 800;
30
32
  export type RestartPaneKind = "command" | "shell";
31
33
  export type RestartStep = {
32
34
  step: string;
@@ -5,7 +5,7 @@ import { ZellijError } from "../errors.js";
5
5
  import { readyPrompt, resolveHarness } from "../harness.js";
6
6
  import { findLease } from "../host/lease.js";
7
7
  import { tokenizeCommand } from "../keys.js";
8
- import { deliverTo } from "./delivery.js";
8
+ import { deliverTo, submitAfterEnter } from "./delivery.js";
9
9
  import { assertNotPlugin, assertNotSelf } from "./guards.js";
10
10
  import { isTrue, numberArg, optionalString, throwIfAborted } from "./util.js";
11
11
  /**
@@ -32,6 +32,8 @@ export const READY_SETTLE_MS = 500;
32
32
  export const READY_STABLE_GAP_MS = 1_000;
33
33
  /** Screen poll interval while a step waits. */
34
34
  export const RESTART_POLL_MS = 250;
35
+ /** Settle after the rescue Enter before re-reading the composer, matching the relay. */
36
+ export const SUBMIT_ENTER_SETTLE_MS = 800;
35
37
  /**
36
38
  * Zellij keeps an exited command pane on screen with a re-run prompt. The exact
37
39
  * wording has changed between releases, so match the stable phrases.
@@ -249,7 +251,8 @@ export async function restartPane(client, state, args, target, clock, opts = {})
249
251
  if (held && !isTrue(args.force)) {
250
252
  throw new ZellijError("pane_leased", `pane ${to} is leased by relay ${held.relayId} (from ${held.from}, since ${new Date(held.at).toISOString()}); pass force=true to restart it anyway`, { relayId: held.relayId, from: held.from, at: held.at, dir: held.dir });
251
253
  }
252
- const profile = resolveHarness(observed);
254
+ // --harness names the profile for wrapper-launched panes (exit recipe, ready and busy markers).
255
+ const profile = resolveHarness(observed, optionalString(args.harness));
253
256
  const kind = restartPaneKind(observed);
254
257
  const handoffSelf = isTrue(args.handoffSelf);
255
258
  const supplied = typeof args.handoff === "string" && args.handoff.length > 0 ? args.handoff : null;
@@ -394,8 +397,39 @@ export async function restartPane(client, state, args, target, clock, opts = {})
394
397
  failStep("deliver", `handoff not delivered (${delivered.error?.message ?? "unknown"})`, steps);
395
398
  }
396
399
  const source = handoff.pointer ? `pointer to ${handoffPath}` : `${handoff.body.length} chars`;
397
- const submitted = delivered.submitted === undefined ? "unknown" : String(delivered.submitted);
398
- record("deliver", true, `${source} ${resent ? "resent" : "sent"}; submitted=${submitted}`);
400
+ let submitted = delivered.submitted;
401
+ // `false` means the paste is still sitting in the harness's composer: the
402
+ // body was never sent. Reporting that as success loses the handoff when the
403
+ // pane is restarted again, so press Enter once and re-verify before trusting
404
+ // it. A screen read that fails here only turns the verdict into `unverified`,
405
+ // which still fails the step below rather than claiming success.
406
+ if (submitted === false) {
407
+ let before = "";
408
+ try {
409
+ before = (await client.dumpPane({ session, paneId: pane.id })).text;
410
+ }
411
+ catch {
412
+ // Unreadable pane: submitAfterEnter reports unverified, never a pass.
413
+ }
414
+ submitted = await submitAfterEnter(client, {
415
+ session,
416
+ paneId: pane.id,
417
+ body: handoff.body,
418
+ before,
419
+ clock,
420
+ settleMs: Math.min(SUBMIT_ENTER_SETTLE_MS, Math.max(1, remaining())),
421
+ profile,
422
+ });
423
+ if (submitted !== true && submitted !== "queued") {
424
+ record("deliver", false, `composer still holds the handoff; submitted=${submitted} after one Enter`);
425
+ failStep("deliver", `handoff not submitted (composer still holds it; submitted=${submitted} after one Enter)`, steps);
426
+ }
427
+ record("deliver", true, `${source} ${resent ? "resent" : "sent"}; submitted=${submitted} after one Enter`);
428
+ }
429
+ else {
430
+ const verdict = submitted === undefined ? "unknown" : String(submitted);
431
+ record("deliver", true, `${source} ${resent ? "resent" : "sent"}; submitted=${verdict}`);
432
+ }
399
433
  }
400
434
  return {
401
435
  ok: true,
package/dist/schema.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ZellijError } from "./errors.js";
2
+ import { HARNESS_NAMES } from "./harness.js";
2
3
  /**
3
4
  * One description of the zswarm surface. The MCP tool schema, the CLI flags,
4
5
  * and the CLI help text are all generated from it, so they cannot drift apart.
@@ -474,6 +475,13 @@ export const PARAMS = [
474
475
  ops: ["send", "broadcast"],
475
476
  description: 'send/broadcast: after the send, dump once more and report what it sees; paste again only on "not-delivered" (positive evidence nothing landed), press Enter when the body is still in the composer, and never paste the body twice',
476
477
  },
478
+ {
479
+ name: "harness",
480
+ type: "string",
481
+ flags: ["--harness"],
482
+ ops: ["send", "restart"],
483
+ description: `send/restart: override the harness inferred from the pane's command/title for wrapper-launched panes (known: ${HARNESS_NAMES.join(", ")})`,
484
+ },
477
485
  {
478
486
  name: "ifIdle",
479
487
  type: "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {