@zswarm/core 0.8.1 → 0.8.3
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 +9 -0
- package/dist/harness.js +44 -2
- package/dist/host/cli.js +1 -0
- package/dist/host/relay.js +27 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ops/delivery.d.ts +1 -1
- package/dist/ops/delivery.js +43 -6
- package/dist/ops/dispatch.d.ts +11 -0
- package/dist/ops/dispatch.js +23 -3
- package/dist/schema.js +24 -13
- package/package.json +1 -1
package/dist/harness.d.ts
CHANGED
|
@@ -66,6 +66,15 @@ export declare function queuedPrompt(screen: string, profile: HarnessProfile): b
|
|
|
66
66
|
export declare function busyMarker(screen: string, profile: HarnessProfile): RegExp | null;
|
|
67
67
|
/** Does this screen's trailing lines show the harness actively working? */
|
|
68
68
|
export declare function busyPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The waiting-prompt regex a screen shows on its trailing lines, or null. Only
|
|
71
|
+
* the last few lines count: a quoted dialog in scrollback is not a live menu.
|
|
72
|
+
* `send` uses this before pasting; status uses its own wider window because a
|
|
73
|
+
* full-screen TUI can hide the prompt under chrome.
|
|
74
|
+
*/
|
|
75
|
+
export declare function waitingMarker(screen: string, profile: HarnessProfile): RegExp | null;
|
|
76
|
+
/** Does this screen's trailing lines show a prompt that wants the user's answer? */
|
|
77
|
+
export declare function waitingPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
69
78
|
/**
|
|
70
79
|
* Does this screen's trailing lines show the harness ready for input? An empty
|
|
71
80
|
* ready set is unknown, never a match: a profile that never declared a marker
|
package/dist/harness.js
CHANGED
|
@@ -51,14 +51,42 @@ const WAITING = {
|
|
|
51
51
|
/^(?:[›❯>]\s*)?(?:Run this command|Allow this command|Do you want to run this command)\?/i,
|
|
52
52
|
/^(?:[›❯>]\s*)?(?:Allow|Approve) (?:this|the) (?:tool|command|operation)(?: call)?\?/i,
|
|
53
53
|
],
|
|
54
|
-
opencode: [
|
|
54
|
+
opencode: [
|
|
55
|
+
...GENERIC_WAITING,
|
|
56
|
+
/Permission required/i,
|
|
57
|
+
/Allow once/i,
|
|
58
|
+
// OpenCode 2.2.5 TUI question dialog. packages/tui/src/mini/footer.form.tsx
|
|
59
|
+
// renders the free-text row as "Type your own answer" (line 517) and the
|
|
60
|
+
// footer as "↑↓ select enter choose tab next esc dismiss" (line 611;
|
|
61
|
+
// the mono variant at line 610 says "up/down select..."). While the answer
|
|
62
|
+
// is being edited the footer becomes "enter save esc dismiss" (line 606)
|
|
63
|
+
// and the confirmation step uses "enter submit esc dismiss" (line 604).
|
|
64
|
+
/^\s*>?\s*\d+\.\s+Type your own answer\s*$/i,
|
|
65
|
+
/^(?:↑↓|up\/down) select\s+enter choose\s+tab next\s+esc dismiss\s*$/i,
|
|
66
|
+
/^enter (?:save|submit)\s+esc dismiss\s*$/i,
|
|
67
|
+
],
|
|
55
68
|
gemini: [
|
|
56
69
|
...GENERIC_WAITING,
|
|
57
70
|
/Do you want to proceed\?/i,
|
|
58
71
|
/Accept this file edit\?/i,
|
|
59
72
|
],
|
|
60
73
|
pi: GENERIC_WAITING,
|
|
61
|
-
claude: [
|
|
74
|
+
claude: [
|
|
75
|
+
...GENERIC_WAITING,
|
|
76
|
+
/Do you want to (?:proceed|continue)\?/i,
|
|
77
|
+
// Claude Code 2.1.316 AskUserQuestion menu. The free-text row is labelled
|
|
78
|
+
// "Other" with placeholder "Type something." (single-select) or
|
|
79
|
+
// "Type something" (multi-select); the footer is
|
|
80
|
+
// "Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel".
|
|
81
|
+
// These strings are in the installed 2.1.316 build (checked against its
|
|
82
|
+
// bundle), and code.claude.com/docs/en/tools-reference names the "Other
|
|
83
|
+
// row" for typed answers. The other ordering is the permission/selection
|
|
84
|
+
// footer: "↑/↓ to navigate · Enter to select · Esc to cancel".
|
|
85
|
+
/^(?:[›❯>]\s*)?\d+\.\s+Other\s*$/i,
|
|
86
|
+
/^Type something\.?$/i,
|
|
87
|
+
/^Enter to select\s+·\s+.*\s+·\s+Esc to cancel$/i,
|
|
88
|
+
/^(?:↑\/↓|up\/down) to navigate\s+·\s+Enter to select\s+·\s+Esc to cancel$/i,
|
|
89
|
+
],
|
|
62
90
|
};
|
|
63
91
|
/**
|
|
64
92
|
* Queued-message hints seen live. Claude Code prints
|
|
@@ -230,6 +258,20 @@ export function busyMarker(screen, profile) {
|
|
|
230
258
|
export function busyPrompt(screen, profile) {
|
|
231
259
|
return busyMarker(screen, profile) !== null;
|
|
232
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* The waiting-prompt regex a screen shows on its trailing lines, or null. Only
|
|
263
|
+
* the last few lines count: a quoted dialog in scrollback is not a live menu.
|
|
264
|
+
* `send` uses this before pasting; status uses its own wider window because a
|
|
265
|
+
* full-screen TUI can hide the prompt under chrome.
|
|
266
|
+
*/
|
|
267
|
+
export function waitingMarker(screen, profile) {
|
|
268
|
+
const lines = trailingLines(screen);
|
|
269
|
+
return profile.waiting.find((re) => lines.some((line) => re.test(line))) ?? null;
|
|
270
|
+
}
|
|
271
|
+
/** Does this screen's trailing lines show a prompt that wants the user's answer? */
|
|
272
|
+
export function waitingPrompt(screen, profile) {
|
|
273
|
+
return waitingMarker(screen, profile) !== null;
|
|
274
|
+
}
|
|
233
275
|
/**
|
|
234
276
|
* Does this screen's trailing lines show the harness ready for input? An empty
|
|
235
277
|
* ready set is unknown, never a match: a profile that never declared a marker
|
package/dist/host/cli.js
CHANGED
|
@@ -272,6 +272,7 @@ const COMMANDS = {
|
|
|
272
272
|
" unless --queue waits for the lease to free or --force shares it",
|
|
273
273
|
" --if-idle applies send's busy check before the lease: a harness that shows it is working fails pane_busy",
|
|
274
274
|
" without sending; --queue then waits for the pane to go idle as well as for the lease, and --force skips the check",
|
|
275
|
+
" a target showing a question/selection dialog is refused pane_waiting (--queue waits for it to close)",
|
|
275
276
|
"zswarm relay --list [--json] id, state, delivery, target, session, age, last check, job and lease per relay dir",
|
|
276
277
|
"zswarm relay --leases [--json] live pane leases and the relay that holds each",
|
|
277
278
|
"zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
|
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, paneBusyMarker } from "../ops/dispatch.js";
|
|
5
|
+
import { dispatchZswarm, paneBusyMarker, paneWaitingMarker } 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";
|
|
@@ -498,6 +498,19 @@ export async function startRelay(input) {
|
|
|
498
498
|
return paneBusyMarker(String(res.data?.text ?? ""), idlePane ?? { title: cfg.to }, cfg.harness);
|
|
499
499
|
};
|
|
500
500
|
const deadline = leaseNow() + cfg.timeoutMin * 60_000;
|
|
501
|
+
/**
|
|
502
|
+
* The waiting dialog one dump of the target shows, or null. The send op
|
|
503
|
+
* checks this itself before pasting; --queue also waits on it here so a
|
|
504
|
+
* question the owner is answering delays the relay instead of failing it.
|
|
505
|
+
*/
|
|
506
|
+
let gatePane = idlePane;
|
|
507
|
+
const waitingProfilePane = async () => (gatePane ??= await resolveRemotePane());
|
|
508
|
+
const waitingNow = async () => {
|
|
509
|
+
const res = await call(target({ op: "dump", to: cfg.to, max: 400 }), remote).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
|
|
510
|
+
if (!res.ok)
|
|
511
|
+
return null; // cannot see the screen: the send op still checks
|
|
512
|
+
return paneWaitingMarker(String(res.data?.text ?? ""), await waitingProfilePane(), cfg.harness);
|
|
513
|
+
};
|
|
501
514
|
let busy = null;
|
|
502
515
|
let taken;
|
|
503
516
|
if (!input.queue) {
|
|
@@ -511,13 +524,16 @@ export async function startRelay(input) {
|
|
|
511
524
|
taken = take();
|
|
512
525
|
}
|
|
513
526
|
else {
|
|
514
|
-
// --queue polls the lease
|
|
515
|
-
// is only taken once the pane is
|
|
527
|
+
// --queue polls the lease, the busy marker under --if-idle, and a waiting
|
|
528
|
+
// question/selection dialog. The lease is only taken once the pane is
|
|
529
|
+
// neither visibly working nor asking the owner a question.
|
|
516
530
|
let attempt;
|
|
531
|
+
let dialog = null;
|
|
517
532
|
for (;;) {
|
|
518
533
|
if (checkIdle)
|
|
519
534
|
busy = await busyMarkerNow();
|
|
520
|
-
|
|
535
|
+
dialog = busy ? null : await waitingNow();
|
|
536
|
+
attempt = busy || dialog ? undefined : take();
|
|
521
537
|
if (attempt?.ok)
|
|
522
538
|
break;
|
|
523
539
|
if (leaseNow() >= deadline)
|
|
@@ -530,6 +546,10 @@ export async function startRelay(input) {
|
|
|
530
546
|
rmSync(dir, { recursive: true, force: true });
|
|
531
547
|
throw new ZellijError("pane_busy", `pane ${cfg.to} is busy with a task (marker: ${busy}) and did not free before --timeout-min`);
|
|
532
548
|
}
|
|
549
|
+
else if (dialog) {
|
|
550
|
+
rmSync(dir, { recursive: true, force: true });
|
|
551
|
+
throw new ZellijError("pane_waiting", `pane ${cfg.to} is showing a question/selection dialog (prompt: ${dialog}) and it did not close before --timeout-min`);
|
|
552
|
+
}
|
|
533
553
|
else
|
|
534
554
|
taken = attempt ?? take();
|
|
535
555
|
}
|
|
@@ -564,7 +584,9 @@ export async function startRelay(input) {
|
|
|
564
584
|
const screen = dump.ok ? String(dump.data.text ?? "") : "";
|
|
565
585
|
const tail = screen.trim().split("\n").slice(-4).join("\n");
|
|
566
586
|
const firstLine = body.trim().split("\n")[0]?.slice(0, 60) ?? "";
|
|
567
|
-
|
|
587
|
+
const dialog = paneWaitingMarker(screen, { title: cfg.to }, cfg.harness);
|
|
588
|
+
if (!dialog &&
|
|
589
|
+
((firstLine && tail.includes(firstLine)) || tail.includes("[zswarm from=") || tail.includes("[Pasted text"))) {
|
|
568
590
|
await call(target({ op: "keys", to: cfg.to, keys: "Enter" }), remote);
|
|
569
591
|
sent = "enter-pressed";
|
|
570
592
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { createStateStore, defaultStateDir, type LeaseRecord, type LogEntry, typ
|
|
|
14
14
|
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, type LeaseTakeInput, type LeaseTakeResult, type LeaseTarget, } from "./host/lease.js";
|
|
15
15
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
16
16
|
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";
|
|
17
|
-
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, type HarnessExitRecipe, type HarnessProfile, type SubmitStrategy, } from "./harness.js";
|
|
17
|
+
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, waitingMarker, waitingPrompt, type HarnessExitRecipe, type HarnessProfile, type SubmitStrategy, } from "./harness.js";
|
|
18
18
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
19
19
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type IpcDiscoveryState, type SshExecFn, type SshTarget, } from "./exec.js";
|
|
20
20
|
export { resolveSshTarget } from "./zellij/binary.js";
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export { createStateStore, defaultStateDir, } from "./state.js";
|
|
|
14
14
|
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, } from "./host/lease.js";
|
|
15
15
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
16
16
|
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";
|
|
17
|
-
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, } from "./harness.js";
|
|
17
|
+
export { busyPrompt, queuedPrompt, readyPrompt, resolveHarness, waitingMarker, waitingPrompt, } from "./harness.js";
|
|
18
18
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
19
19
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
20
20
|
export { resolveSshTarget } from "./zellij/binary.js";
|
package/dist/ops/delivery.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { StateStore } from "../state.js";
|
|
|
3
3
|
import type { ZellijClient } from "../zellij/client.js";
|
|
4
4
|
import type { ZellijPane } from "../zellij/panes.js";
|
|
5
5
|
import type { Clock } from "./types.js";
|
|
6
|
-
export type Submitted = boolean | "queued" | "unverified" | "not-delivered";
|
|
6
|
+
export type Submitted = boolean | "queued" | "unverified" | "not-delivered" | "waiting";
|
|
7
7
|
/**
|
|
8
8
|
* Where a message came from: the sending machine, the sender's Zellij session
|
|
9
9
|
* and pane, and whether this host received the op over `serve`. Every part is
|
package/dist/ops/delivery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
|
-
import { busyMarker, queuedPrompt, resolveHarness } from "../harness.js";
|
|
2
|
+
import { busyMarker, queuedPrompt, resolveHarness, waitingMarker } from "../harness.js";
|
|
3
3
|
import { resolveSelfPaneId, resolveSelfSession } from "../zellij/session.js";
|
|
4
4
|
import { isTrue, numberArg, optionalString } from "./util.js";
|
|
5
5
|
/** Cap for one origin part; keeps a spoofed value from wrapping the header. */
|
|
@@ -320,6 +320,10 @@ async function verifySubmit(client, input) {
|
|
|
320
320
|
if (verdict === true || verdict === "queued")
|
|
321
321
|
return verdict;
|
|
322
322
|
if (verdict === false) {
|
|
323
|
+
// A dialog appeared (or was already there) between the gate and this dump:
|
|
324
|
+
// Enter would choose its highlighted option, so never send the rescue key.
|
|
325
|
+
if (waitingMarker(after, profile))
|
|
326
|
+
return "waiting";
|
|
323
327
|
await client.sendKeys({ session, paneId, keys: ["Enter"] });
|
|
324
328
|
await clock.sleep(settleMs);
|
|
325
329
|
const rescued = await dumpOrNull(client, session, paneId);
|
|
@@ -438,11 +442,18 @@ export async function deliverTo(client, state, args, input) {
|
|
|
438
442
|
});
|
|
439
443
|
}
|
|
440
444
|
else if (submitted === false) {
|
|
441
|
-
// The paste is still in the composer: press Enter, never paste again
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
if (
|
|
445
|
-
submitted =
|
|
445
|
+
// The paste is still in the composer: press Enter, never paste again —
|
|
446
|
+
// unless a dialog is now on screen, where Enter would answer it.
|
|
447
|
+
const current = await dumpOrNull(client, session, delivered.paneId);
|
|
448
|
+
if (current !== null && waitingMarker(current, profile)) {
|
|
449
|
+
submitted = "waiting";
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
await client.sendKeys({ session, paneId: delivered.paneId, keys: ["Enter"] });
|
|
453
|
+
const seen = await dumpOrNull(client, session, delivered.paneId);
|
|
454
|
+
if (seen !== null && before !== null) {
|
|
455
|
+
submitted = classifySubmit(before, seen, body, { queued });
|
|
456
|
+
}
|
|
446
457
|
}
|
|
447
458
|
}
|
|
448
459
|
else {
|
|
@@ -453,6 +464,32 @@ export async function deliverTo(client, state, args, input) {
|
|
|
453
464
|
}
|
|
454
465
|
}
|
|
455
466
|
}
|
|
467
|
+
if (submitted === "waiting") {
|
|
468
|
+
state.appendLog({
|
|
469
|
+
at,
|
|
470
|
+
op,
|
|
471
|
+
session,
|
|
472
|
+
to: delivered.paneId,
|
|
473
|
+
from,
|
|
474
|
+
fromHost: origin.host,
|
|
475
|
+
fromSession: origin.session,
|
|
476
|
+
fromPane: origin.pane,
|
|
477
|
+
via: origin.via,
|
|
478
|
+
bytes: body.length,
|
|
479
|
+
ok: false,
|
|
480
|
+
detail: "submitted=waiting",
|
|
481
|
+
});
|
|
482
|
+
return {
|
|
483
|
+
to: delivered.paneId,
|
|
484
|
+
ok: false,
|
|
485
|
+
delivery: "zellij_paste",
|
|
486
|
+
submitted,
|
|
487
|
+
error: {
|
|
488
|
+
code: "pane_waiting",
|
|
489
|
+
message: "the pane is showing a question/selection dialog; zswarm did not press Enter because that would answer it — answer it in the pane, then resend",
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
456
493
|
if (submitted === "not-delivered") {
|
|
457
494
|
state.appendLog({
|
|
458
495
|
at,
|
package/dist/ops/dispatch.d.ts
CHANGED
|
@@ -9,6 +9,17 @@ export declare function paneBusyMarker(screen: string, pane: {
|
|
|
9
9
|
command?: string | null;
|
|
10
10
|
title?: string | null;
|
|
11
11
|
}, harness?: string | null): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* The harness waiting-prompt regex a dumped screen shows for `pane`, or null.
|
|
14
|
+
* `send` always checks this before pasting: the pane may be showing an
|
|
15
|
+
* interactive question/selection dialog (OpenCode's question tool, Claude
|
|
16
|
+
* Code's AskUserQuestion or permission menu), where a paste lands in the
|
|
17
|
+
* free-text answer and an Enter picks the highlighted option.
|
|
18
|
+
*/
|
|
19
|
+
export declare function paneWaitingMarker(screen: string, pane: {
|
|
20
|
+
command?: string | null;
|
|
21
|
+
title?: string | null;
|
|
22
|
+
}, harness?: string | null): string | null;
|
|
12
23
|
/**
|
|
13
24
|
* Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
|
|
14
25
|
* and serve (and remote IPC). `--ssh` sets the destination for this call and
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { createGitClient } from "../git.js";
|
|
3
|
-
import { busyMarker, resolveHarness } from "../harness.js";
|
|
3
|
+
import { busyMarker, resolveHarness, waitingMarker } from "../harness.js";
|
|
4
4
|
import { findLease } from "../host/lease.js";
|
|
5
5
|
import { normalizeKeys } from "../keys.js";
|
|
6
6
|
import { createZellijClient } from "../zellij/client.js";
|
|
@@ -40,6 +40,16 @@ import { installServeService, uninstallServeService } from "../host/serve-servic
|
|
|
40
40
|
export function paneBusyMarker(screen, pane, harness) {
|
|
41
41
|
return busyMarker(screen, resolveHarness(pane, harness))?.source ?? null;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The harness waiting-prompt regex a dumped screen shows for `pane`, or null.
|
|
45
|
+
* `send` always checks this before pasting: the pane may be showing an
|
|
46
|
+
* interactive question/selection dialog (OpenCode's question tool, Claude
|
|
47
|
+
* Code's AskUserQuestion or permission menu), where a paste lands in the
|
|
48
|
+
* free-text answer and an Enter picks the highlighted option.
|
|
49
|
+
*/
|
|
50
|
+
export function paneWaitingMarker(screen, pane, harness) {
|
|
51
|
+
return waitingMarker(screen, resolveHarness(pane, harness))?.source ?? null;
|
|
52
|
+
}
|
|
43
53
|
/**
|
|
44
54
|
* Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
|
|
45
55
|
* and serve (and remote IPC). `--ssh` sets the destination for this call and
|
|
@@ -453,10 +463,21 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
453
463
|
const { session, pane, panes, firstCallMs, timeoutMs } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
454
464
|
assertWritable(client, pane, args, "send", session);
|
|
455
465
|
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
466
|
+
// Always check for an interactive dialog before pasting, not only with
|
|
467
|
+
// --if-idle: a paste into a question's free-text field and an Enter on
|
|
468
|
+
// its highlighted option can silently answer what the owner was meant
|
|
469
|
+
// to answer. An unreadable pane cannot be checked; delivery proceeds.
|
|
470
|
+
const to = String(args.to ?? "").trim();
|
|
471
|
+
const screen = await client.dumpPane({ session, paneId: pane.id }).then((res) => res.text, () => null);
|
|
472
|
+
if (!isTrue(args.force) && screen !== null) {
|
|
473
|
+
const waiting = paneWaitingMarker(screen, pane, optionalString(args.harness));
|
|
474
|
+
if (waiting) {
|
|
475
|
+
throw new ZellijError("pane_waiting", `pane ${to} is showing a question/selection dialog (prompt: ${waiting}); nothing was pasted — answer it in the pane, or retry when it closes (or pass --force to paste anyway)`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
456
478
|
if (isTrue(args.ifIdle)) {
|
|
457
479
|
// Before delivery starts: a live pane lease (a relay working it) is a
|
|
458
480
|
// hard refusal; a harness that is visibly working is the softer one.
|
|
459
|
-
const to = String(args.to ?? "").trim();
|
|
460
481
|
const held = findLease(state(), {
|
|
461
482
|
serve: typeof args.serve === "string" ? args.serve : env.ZSWARM_SERVE,
|
|
462
483
|
session,
|
|
@@ -465,7 +486,6 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
465
486
|
if (held) {
|
|
466
487
|
throw new ZellijError("pane_leased", `pane ${to} is leased by relay ${held.relayId} (from ${held.from}, since ${new Date(held.at).toISOString()})`, { relayId: held.relayId, from: held.from, at: held.at, dir: held.dir });
|
|
467
488
|
}
|
|
468
|
-
const screen = await client.dumpPane({ session, paneId: pane.id }).then((res) => res.text, () => null);
|
|
469
489
|
if (screen !== null) {
|
|
470
490
|
const marker = paneBusyMarker(screen, pane, optionalString(args.harness));
|
|
471
491
|
if (marker) {
|
package/dist/schema.js
CHANGED
|
@@ -466,7 +466,7 @@ export const PARAMS = [
|
|
|
466
466
|
flags: ["--submit"],
|
|
467
467
|
ops: ["send", "broadcast"],
|
|
468
468
|
values: ["auto", "double-enter", "none"],
|
|
469
|
-
description: 'send/broadcast: auto verifies the paste actually submitted and presses Enter again if not (default). The result reports submitted: true, "queued" (held behind a running turn), false (still in the composer), "unverified" (the screen changed but the body is not visible),
|
|
469
|
+
description: 'send/broadcast: auto verifies the paste actually submitted and presses Enter again if not (default). The result reports submitted: true, "queued" (held behind a running turn), false (still in the composer), "unverified" (the screen changed but the body is not visible), "not-delivered" (the pane was unchanged apart from the composer, so the op fails not_delivered and a resend is safe), or "waiting" (a question/selection dialog appeared, Enter was withheld, and the op fails pane_waiting)',
|
|
470
470
|
},
|
|
471
471
|
{
|
|
472
472
|
name: "observeMs",
|
|
@@ -501,7 +501,7 @@ export const PARAMS = [
|
|
|
501
501
|
type: "boolean",
|
|
502
502
|
flags: ["--if-idle"],
|
|
503
503
|
ops: ["send"],
|
|
504
|
-
description: "send: refuse a pane that a relay has leased (pane_leased) or whose harness is visibly working (pane_busy) instead of pasting into it",
|
|
504
|
+
description: "send: refuse a pane that a relay has leased (pane_leased) or whose harness is visibly working (pane_busy) instead of pasting into it; a pane showing a question/selection dialog is refused as pane_waiting with or without this flag",
|
|
505
505
|
},
|
|
506
506
|
{
|
|
507
507
|
name: "expect",
|
|
@@ -593,7 +593,7 @@ export const PARAMS = [
|
|
|
593
593
|
type: "boolean",
|
|
594
594
|
flags: ["--force"],
|
|
595
595
|
ops: ["send", "keys", "unworktree", "bus", "interrupt", "broadcast", "restart"],
|
|
596
|
-
description: "send/keys: write to a pane whose command has exited; restart: restart a pane that a relay has leased; unworktree: remove a busy or dirty worktree; bus: close orphan bus panes and reload this session's plugin",
|
|
596
|
+
description: "send/keys: write to a pane whose command has exited; send: also paste into a pane showing a question/selection dialog; restart: restart a pane that a relay has leased; unworktree: remove a busy or dirty worktree; bus: close orphan bus panes and reload this session's plugin",
|
|
597
597
|
},
|
|
598
598
|
{
|
|
599
599
|
name: "listen",
|
|
@@ -650,20 +650,31 @@ export const MCP_TOOL_DESCRIPTION = `zSwarm Zellij pane coordination (op=${OP_NA
|
|
|
650
650
|
"Same host as Zellij, or ZSWARM_SSH / ZSWARM_SERVE for a remote crew.";
|
|
651
651
|
const FLAG_INDEX = new Map(PARAMS.flatMap((p) => p.flags.map((flag) => [flag, p])));
|
|
652
652
|
export function cliUsage() {
|
|
653
|
-
|
|
653
|
+
return [
|
|
654
654
|
`usage: zswarm <${OP_NAMES.join("|")}> [options]`,
|
|
655
655
|
"",
|
|
656
656
|
" positional: first bare argument is --to, second is --body for send",
|
|
657
657
|
"",
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
658
|
+
" --session, -s VALUE Zellij session (or ZSWARM_SESSION / ZELLIJ_SESSION_NAME)",
|
|
659
|
+
" --to, -t, --pane VALUE target pane: id, terminal_3, or a unique title; broadcast takes a comma list",
|
|
660
|
+
" --fresh bypass the short-lived session/pane/tab listing cache",
|
|
661
|
+
" --local route this call to the local machine only",
|
|
662
|
+
" --ssh VALUE route this call over SSH to user@host or an alias",
|
|
663
|
+
" --serve VALUE existing serve endpoint: host:port, tcp://, or ssh://user@host",
|
|
664
|
+
" --help, -h this list; `zswarm <op> --help` shows that op's flags",
|
|
665
|
+
"",
|
|
666
|
+
" examples:",
|
|
667
|
+
" zswarm list",
|
|
668
|
+
" zswarm send --session crew --to worker --body \"text\"",
|
|
669
|
+
" zswarm wait --session crew --to worker --match \"RESULT\" --timeout-ms 900000",
|
|
670
|
+
"",
|
|
671
|
+
"Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows), or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. See docs/tailscale.md.",
|
|
672
|
+
"Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.",
|
|
673
|
+
"Bus: `zswarm bus --install` once per Zellij session; `--force` closes orphan bus panes and reloads, not a retry.",
|
|
674
|
+
"Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`, `zswarm gate`; `zswarm <command> --help` for each. Gate: docs/gate.md. Doctor: docs/doctor.md.",
|
|
675
|
+
"Env: ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZSWARM_HOST, ZSWARM_BUS, ZSWARM_SSH, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_CACHE_TTL_MS (full list in docs/hosts.md).",
|
|
676
|
+
"",
|
|
677
|
+
].join("\n");
|
|
667
678
|
}
|
|
668
679
|
/**
|
|
669
680
|
* True when argv asks for help: `--help` or `-h` as a flag of its own, not the
|