@zswarm/core 0.2.6 → 0.2.7

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.
@@ -0,0 +1,416 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ZellijError } from "../errors.js";
5
+ import { readyPrompt, resolveHarness } from "../harness.js";
6
+ import { findLease } from "../host/lease.js";
7
+ import { tokenizeCommand } from "../keys.js";
8
+ import { deliverTo } from "./delivery.js";
9
+ import { assertNotPlugin, assertNotSelf } from "./guards.js";
10
+ import { isTrue, numberArg, optionalString, throwIfAborted } from "./util.js";
11
+ /**
12
+ * Restart the agent in one pane, in place: same pane id, title and position.
13
+ *
14
+ * A Zellij command pane (a `command=` layout entry or `zellij run`) stays open
15
+ * when its command exits and offers ENTER to re-run the same command — that is
16
+ * the in-place relaunch. A shell pane has no such prompt, so the profile's
17
+ * launch command (or `--command`) is typed instead.
18
+ */
19
+ /** The agent prints this once its handoff file is written. */
20
+ export const HANDOFF_MARKER = "ZSWARM-HANDOFF-DONE";
21
+ /** A handoff at or under this many chars is pasted; longer ones get a pointer. */
22
+ export const HANDOFF_INLINE_MAX = 2_000;
23
+ /** Bound on waiting for a self-written handoff marker. */
24
+ export const HANDOFF_SELF_WAIT_MS = 60_000;
25
+ /** Bound on waiting for the pane's command to exit before the Ctrl+C fallback. */
26
+ export const EXIT_WAIT_MS = 10_000;
27
+ /** Bound on waiting for readiness after relaunch (marker or settled screen). */
28
+ export const READY_WAIT_MS = 45_000;
29
+ /** Extra settle after a ready marker before the handoff is pasted. */
30
+ export const READY_SETTLE_MS = 500;
31
+ /** A screen that has not changed for this long counts as settled. */
32
+ export const READY_STABLE_GAP_MS = 1_000;
33
+ /** Screen poll interval while a step waits. */
34
+ export const RESTART_POLL_MS = 250;
35
+ /**
36
+ * Zellij keeps an exited command pane on screen with a re-run prompt. The exact
37
+ * wording has changed between releases, so match the stable phrases.
38
+ */
39
+ const RERUN_PROMPT = /press\s+enter[^\n]*re-?run|re-?run[^\n]*press\s+enter|command exited|exit\s+code/i;
40
+ /** A shell prompt at the end of a line (the agent quit back to the shell). */
41
+ const SHELL_PROMPT = /(?:^|\n)[^\n]*[$#%] ?$|❯ ?$/;
42
+ /** Basenames that mean "this pane is a shell, not an agent command". */
43
+ const SHELL_COMMANDS = new Set([
44
+ "bash", "zsh", "fish", "sh", "dash", "ksh", "csh", "tcsh", "nu", "nushell",
45
+ "pwsh", "powershell", "cmd", "wsl", "login",
46
+ ]);
47
+ /**
48
+ * A command pane ran the agent itself, so ENTER re-runs it. A shell pane had the
49
+ * agent started inside a shell, so the launch command has to be typed again.
50
+ * A pane with no command information is treated as a shell: typing a launch
51
+ * command is recoverable, pressing ENTER into a live agent is not.
52
+ */
53
+ export function restartPaneKind(pane) {
54
+ const command = pane.command?.trim();
55
+ if (!command)
56
+ return "shell";
57
+ const base = command
58
+ .replace(/\\/g, "/")
59
+ .split("/")
60
+ .pop()
61
+ .replace(/\.(exe|cmd|bat)$/i, "")
62
+ .toLowerCase();
63
+ return SHELL_COMMANDS.has(base) ? "shell" : "command";
64
+ }
65
+ /** The command line a shell pane gets to relaunch the agent. */
66
+ export function restartLaunchCommand(args, profile) {
67
+ const raw = args.command;
68
+ if (typeof raw === "string" && raw.trim())
69
+ return raw.trim();
70
+ if (Array.isArray(raw)) {
71
+ const tokens = tokenizeCommand(raw);
72
+ if (tokens.length > 0)
73
+ return tokens.join(" ");
74
+ }
75
+ return profile.launch;
76
+ }
77
+ function readTextFile(path) {
78
+ try {
79
+ return readFileSync(path, "utf8");
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ async function waitForScreen(client, session, paneId, predicate, budgetMs, clock, signal) {
86
+ const started = clock.now();
87
+ const end = started + budgetMs;
88
+ let screen = "";
89
+ for (;;) {
90
+ throwIfAborted(signal);
91
+ try {
92
+ screen = (await client.dumpPane({ session, paneId })).text;
93
+ }
94
+ catch {
95
+ screen = "";
96
+ }
97
+ if (predicate(screen)) {
98
+ return { ok: true, elapsedMs: clock.now() - started, screen };
99
+ }
100
+ if (clock.now() >= end) {
101
+ return { ok: false, elapsedMs: clock.now() - started, screen };
102
+ }
103
+ await clock.sleep(Math.min(RESTART_POLL_MS, Math.max(1, end - clock.now())));
104
+ }
105
+ }
106
+ /**
107
+ * Wait until the screen stops changing: two identical non-empty dumps at least
108
+ * READY_STABLE_GAP_MS apart. This is the readiness evidence for a profile with
109
+ * no declared marker (OpenCode has one, but an unknown or unmarked harness has
110
+ * nothing better than "the UI stopped repainting").
111
+ */
112
+ async function waitForStableScreen(client, session, paneId, budgetMs, clock, signal) {
113
+ const started = clock.now();
114
+ const end = started + budgetMs;
115
+ let previous = null;
116
+ let lastChangeAt = started;
117
+ let screen = "";
118
+ for (;;) {
119
+ throwIfAborted(signal);
120
+ try {
121
+ screen = (await client.dumpPane({ session, paneId })).text;
122
+ }
123
+ catch {
124
+ screen = "";
125
+ }
126
+ const now = clock.now();
127
+ if (previous !== null &&
128
+ screen === previous &&
129
+ screen.trim().length > 0 &&
130
+ now - lastChangeAt >= READY_STABLE_GAP_MS) {
131
+ return { ok: true, elapsedMs: now - started, screen };
132
+ }
133
+ if (previous === null || screen !== previous)
134
+ lastChangeAt = now;
135
+ previous = screen;
136
+ if (now >= end)
137
+ return { ok: false, elapsedMs: now - started, screen };
138
+ await clock.sleep(Math.min(RESTART_POLL_MS, Math.max(1, end - clock.now())));
139
+ }
140
+ }
141
+ /**
142
+ * Readiness after the relaunch. A declared marker is waited for, then a short
143
+ * settle so the composer accepts input. Without one, the screen has to stop
144
+ * changing; skipping the wait is what pasted a handoff into OpenCode before its
145
+ * UI took input.
146
+ */
147
+ async function awaitReady(client, session, paneId, profile, budgetMs, clock, signal) {
148
+ const waitMs = Math.max(1, budgetMs());
149
+ if (profile.ready.length > 0) {
150
+ const seen = await waitForScreen(client, session, paneId, (screen) => readyPrompt(screen, profile), waitMs, clock, signal);
151
+ if (!seen.ok) {
152
+ return { ok: false, detail: `${profile.name} ready marker not seen within ${waitMs}ms` };
153
+ }
154
+ await clock.sleep(READY_SETTLE_MS);
155
+ return { ok: true, detail: `ready after ${seen.elapsedMs}ms` };
156
+ }
157
+ const stable = await waitForStableScreen(client, session, paneId, waitMs, clock, signal);
158
+ if (!stable.ok) {
159
+ return { ok: false, detail: `${profile.name} screen did not settle within ${waitMs}ms` };
160
+ }
161
+ return {
162
+ ok: true,
163
+ detail: `screen stable after ${stable.elapsedMs}ms (no ready marker for ${profile.name})`,
164
+ };
165
+ }
166
+ async function waitForExit(client, session, pane, kind, profile, budgetMs, clock, signal) {
167
+ const end = clock.now() + budgetMs;
168
+ for (;;) {
169
+ throwIfAborted(signal);
170
+ try {
171
+ const panes = await client.listPanes(session, Math.max(1, end - clock.now()), { fresh: true });
172
+ const live = panes.find((p) => p.id === pane.id);
173
+ if (live?.exited)
174
+ return { exited: true, reason: "pane_exited" };
175
+ }
176
+ catch {
177
+ // A transient listing failure is not evidence the pane is gone.
178
+ }
179
+ try {
180
+ const screen = (await client.dumpPane({ session, paneId: pane.id })).text;
181
+ if (kind === "command" && RERUN_PROMPT.test(screen)) {
182
+ return { exited: true, reason: "rerun_prompt" };
183
+ }
184
+ if (kind === "shell" &&
185
+ SHELL_PROMPT.test(screen) &&
186
+ !readyPrompt(screen, profile) &&
187
+ !/\besc to interrupt\b/i.test(screen)) {
188
+ return { exited: true, reason: "shell_prompt" };
189
+ }
190
+ }
191
+ catch {
192
+ // Keep polling; a failed read is not proof of exit.
193
+ }
194
+ if (clock.now() >= end)
195
+ return { exited: false, reason: "timeout" };
196
+ await clock.sleep(Math.min(RESTART_POLL_MS, Math.max(1, end - clock.now())));
197
+ }
198
+ }
199
+ function failStep(step, message, steps) {
200
+ throw new ZellijError("restart_failed", `restart failed at step "${step}": ${message}`, {
201
+ step,
202
+ steps,
203
+ });
204
+ }
205
+ function handoffBodyFor(content, pointer) {
206
+ if (content && content.length > 0) {
207
+ if (content.length <= HANDOFF_INLINE_MAX || !pointer) {
208
+ return { body: content, pointer: false };
209
+ }
210
+ }
211
+ if (pointer) {
212
+ return { body: `Read your handoff from ${pointer} and continue the task.`, pointer: true };
213
+ }
214
+ return null;
215
+ }
216
+ /**
217
+ * Restart one already-resolved pane. `target` comes from dispatch's
218
+ * `resolveTarget`, so policy and pane lookup are shared with the other ops.
219
+ */
220
+ export async function restartPane(client, state, args, target, clock, opts = {}) {
221
+ const { session, pane } = target;
222
+ const signal = opts.signal;
223
+ const started = clock.now();
224
+ const timeoutMs = numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 });
225
+ const deadline = started + timeoutMs;
226
+ const remaining = () => Math.max(0, deadline - clock.now());
227
+ // A bus-resolved pane carries no command, but restart must know whether it
228
+ // is a command pane (ENTER re-run) or a shell pane (type the launch command).
229
+ // Re-list directly so the pane's command is present.
230
+ let observed = pane;
231
+ if (!observed.command) {
232
+ try {
233
+ const fresh = await client.listPanes(session, Math.max(1, remaining()), { fresh: true });
234
+ observed = fresh.find((p) => p.id === pane.id) ?? pane;
235
+ }
236
+ catch {
237
+ // Keep the bus pane; an unknown command falls back to the shell branch.
238
+ }
239
+ }
240
+ assertNotPlugin(observed, "restart");
241
+ assertNotSelf(client, observed, args, "restart");
242
+ // A relay working the pane owns it; restarting under it would kill the task.
243
+ const to = String(args.to ?? "").trim();
244
+ const held = findLease(state, {
245
+ serve: typeof args.serve === "string" && args.serve.trim() ? args.serve.trim() : opts.env?.ZSWARM_SERVE,
246
+ session,
247
+ pane: to,
248
+ });
249
+ if (held && !isTrue(args.force)) {
250
+ 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
+ }
252
+ const profile = resolveHarness(observed);
253
+ const kind = restartPaneKind(observed);
254
+ const handoffSelf = isTrue(args.handoffSelf);
255
+ const supplied = typeof args.handoff === "string" && args.handoff.length > 0 ? args.handoff : null;
256
+ const suppliedPath = optionalString(args.handoffPath) ?? optionalString(args.handoffFile);
257
+ const steps = [];
258
+ const record = (step, ok, detail) => {
259
+ steps.push({ step, ok, detail, elapsedMs: clock.now() - started });
260
+ };
261
+ // (1) Handoff: the running agent writes one, or the caller supplies it.
262
+ let handoffContent = supplied;
263
+ let handoffPath = suppliedPath;
264
+ if (handoffSelf) {
265
+ const path = handoffPath
266
+ ?? join(tmpdir(), `zswarm-handoff-${session}-${pane.numericId}.md`);
267
+ handoffPath = path;
268
+ const instruction = `Write your handoff for the next agent to ${path}. ` +
269
+ `Then print ${HANDOFF_MARKER} on its own line.`;
270
+ const asked = await deliverTo(client, state, args, {
271
+ session,
272
+ pane: observed,
273
+ body: instruction,
274
+ op: "restart",
275
+ at: clock.now(),
276
+ clock,
277
+ timeoutMs: Math.max(1, remaining()),
278
+ });
279
+ if (!asked.ok) {
280
+ record("handoff-self", false, `could not ask the agent: ${asked.error?.message ?? "unknown"}`);
281
+ failStep("handoff-self", `could not ask the agent for its handoff (${asked.error?.message ?? "unknown"})`, steps);
282
+ }
283
+ const waitMs = Math.min(HANDOFF_SELF_WAIT_MS, Math.max(1, remaining()));
284
+ // The instruction embeds the marker inline, so only a line that is exactly
285
+ // the marker counts as the agent's completion signal.
286
+ const seen = await waitForScreen(client, session, pane.id, (screen) => screen.split(/\r?\n/).some((line) => line.trim() === HANDOFF_MARKER), waitMs, clock, signal);
287
+ if (!seen.ok) {
288
+ record("handoff-self", false, `marker ${HANDOFF_MARKER} not seen within ${waitMs}ms`);
289
+ failStep("handoff-self", `marker ${HANDOFF_MARKER} not seen within ${waitMs}ms`, steps);
290
+ }
291
+ record("handoff-self", true, `marker seen after ${seen.elapsedMs}ms; handoff at ${path}`);
292
+ handoffContent = readTextFile(path);
293
+ }
294
+ else if (supplied || suppliedPath) {
295
+ if (!handoffContent && handoffPath)
296
+ handoffContent = readTextFile(handoffPath);
297
+ const chars = handoffContent?.length ?? 0;
298
+ record("handoff", true, handoffPath ? `${chars} chars from ${handoffPath}` : `${chars} chars`);
299
+ }
300
+ else {
301
+ record("handoff", true, "none");
302
+ }
303
+ // (2) Ask the harness to exit; (3) wait for the command to actually stop.
304
+ const exit = profile.exit;
305
+ if (exit?.command) {
306
+ await client.injectPane({ session, paneId: pane.id, text: exit.command });
307
+ record("exit", true, `typed ${exit.command}`);
308
+ }
309
+ else if (exit?.keys && exit.keys.length > 0) {
310
+ await client.sendKeys({ session, paneId: pane.id, keys: [...exit.keys] });
311
+ record("exit", true, `sent ${exit.keys.join(" ")}`);
312
+ }
313
+ else {
314
+ record("exit", true, `no exit recipe for ${profile.name}; Ctrl+C fallback`);
315
+ }
316
+ let outcome;
317
+ try {
318
+ outcome = await waitForExit(client, session, pane, kind, profile, Math.min(EXIT_WAIT_MS, Math.max(1, remaining())), clock, signal);
319
+ }
320
+ catch (err) {
321
+ record("wait-exit", false, err instanceof Error ? err.message : String(err));
322
+ throw err;
323
+ }
324
+ if (!outcome.exited) {
325
+ // Bounded wait elapsed: fall back to two Ctrl+C presses, then wait again.
326
+ await client.sendKeys({ session, paneId: pane.id, keys: ["Ctrl c"] });
327
+ await clock.sleep(RESTART_POLL_MS);
328
+ await client.sendKeys({ session, paneId: pane.id, keys: ["Ctrl c"] });
329
+ const fallback = await waitForExit(client, session, pane, kind, profile, Math.min(EXIT_WAIT_MS, Math.max(1, remaining())), clock, signal);
330
+ if (!fallback.exited) {
331
+ record("wait-exit", false, `pane ${pane.id} did not exit after Ctrl+C x2`);
332
+ failStep("wait-exit", `pane ${pane.id} did not exit after the exit recipe and Ctrl+C x2`, steps);
333
+ }
334
+ record("wait-exit", true, `exited after Ctrl+C x2 (${fallback.reason})`);
335
+ }
336
+ else {
337
+ record("wait-exit", true, `${outcome.reason}`);
338
+ }
339
+ // (4) Relaunch in the same pane.
340
+ if (kind === "command") {
341
+ await client.sendKeys({ session, paneId: pane.id, keys: ["Enter"] });
342
+ record("relaunch", true, "pressed Enter at the re-run prompt");
343
+ }
344
+ else {
345
+ const launch = restartLaunchCommand(args, profile);
346
+ if (!launch) {
347
+ record("relaunch", false, "no launch command");
348
+ failStep("relaunch", `no launch command for ${profile.name}; pass --command`, steps);
349
+ }
350
+ await client.writeChars({ session, paneId: pane.id, chars: launch });
351
+ await client.sendKeys({ session, paneId: pane.id, keys: ["Enter"] });
352
+ record("relaunch", true, `typed ${launch}`);
353
+ }
354
+ // (5) Wait for readiness: a declared marker, or a screen that stopped
355
+ // changing when the profile has none. Never skip this before a handoff.
356
+ const readyBudget = () => Math.min(READY_WAIT_MS, Math.max(1, remaining()));
357
+ const ready = await awaitReady(client, session, pane.id, profile, readyBudget, clock, signal);
358
+ if (!ready.ok) {
359
+ record("ready", false, ready.detail);
360
+ failStep("ready", ready.detail, steps);
361
+ }
362
+ record("ready", true, ready.detail);
363
+ // (6) Deliver the handoff, inline or as a pointer to the file.
364
+ const handoff = handoffBodyFor(handoffContent, handoffPath);
365
+ if (!handoff) {
366
+ record("deliver", true, "skipped: no handoff");
367
+ }
368
+ else {
369
+ const deliverOnce = () => deliverTo(client, state, args, {
370
+ session,
371
+ pane: observed,
372
+ body: handoff.body,
373
+ op: "restart",
374
+ at: clock.now(),
375
+ clock,
376
+ timeoutMs: Math.max(1, remaining()),
377
+ });
378
+ let delivered = await deliverOnce();
379
+ let resent = false;
380
+ // `not-delivered` is positive evidence the paste never landed: wait for the
381
+ // pane to be ready again and resend exactly once. Every other non-true
382
+ // result (false / unverified / queued) is reported, never resent.
383
+ if (!delivered.ok && delivered.error?.code === "not_delivered") {
384
+ const readyAgain = await awaitReady(client, session, pane.id, profile, readyBudget, clock, signal);
385
+ if (!readyAgain.ok) {
386
+ record("deliver", false, `not delivered; ${readyAgain.detail}`);
387
+ failStep("deliver", `handoff not delivered and the pane did not become ready again (${readyAgain.detail})`, steps);
388
+ }
389
+ delivered = await deliverOnce();
390
+ resent = true;
391
+ }
392
+ if (!delivered.ok) {
393
+ record("deliver", false, delivered.error?.message ?? "handoff not delivered");
394
+ failStep("deliver", `handoff not delivered (${delivered.error?.message ?? "unknown"})`, steps);
395
+ }
396
+ 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}`);
399
+ }
400
+ return {
401
+ ok: true,
402
+ data: {
403
+ session,
404
+ to: pane.id,
405
+ paneId: pane.id,
406
+ harness: profile.name,
407
+ paneKind: kind,
408
+ restarted: true,
409
+ handoff: handoffPath
410
+ ? { path: handoffPath, delivered: handoff?.pointer ? "pointer" : "inline" }
411
+ : null,
412
+ steps,
413
+ elapsedMs: clock.now() - started,
414
+ },
415
+ };
416
+ }
@@ -1,5 +1,5 @@
1
1
  import { type DoctorCheck } from "./doctor.js";
2
- import { type ServeHelloData } from "./serve.js";
2
+ import { type ServeHelloData, type ServeTaskTarget } from "./serve.js";
3
3
  import { type NetworkInterfacesFn, type TailscaleStatusRunner } from "./serve-bind.js";
4
4
  import type { ServeInstallDeps, ServePowerShellResult } from "./types.js";
5
5
  /** Overall install/readiness deadline when `timeoutMs` is omitted. */
@@ -34,6 +34,10 @@ export type ServeInstallSuccess = {
34
34
  ready: true;
35
35
  running: true;
36
36
  task: string;
37
+ /** `\zswarm\` for a per-session task, the resolved folder (or `''`) otherwise. */
38
+ taskPath: string;
39
+ /** True only when the legacy root-folder task was adopted for its session. */
40
+ legacy: boolean;
37
41
  listen: string;
38
42
  command: string;
39
43
  principal: {
@@ -72,6 +76,7 @@ export declare function sameWindowsSid(a: string, b: string): boolean;
72
76
  export declare function sameWindowsAccount(a: string, b: string): boolean;
73
77
  export declare function buildServeTaskScript(action: ServeTaskAction, params?: {
74
78
  command?: string;
79
+ target?: ServeTaskTarget;
75
80
  }): string;
76
81
  export declare function serveCommandFingerprint(command: string): string;
77
82
  /**
@@ -81,6 +86,8 @@ export declare function serveCommandFingerprint(command: string): string;
81
86
  export declare function installServeLogon(input?: ServeInstallInput): Promise<ServeInstallSuccess>;
82
87
  export declare function uninstallServeLogon(input?: ServeInstallInput): Promise<{
83
88
  task: string;
89
+ taskPath: string;
90
+ legacy: boolean;
84
91
  cleared: true;
85
92
  stopped?: boolean;
86
93
  missing?: boolean;