@zswarm/core 0.2.2 → 0.2.4

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.
@@ -1,7 +1,10 @@
1
1
  import { ZellijError } from "../errors.js";
2
2
  import { createGitClient } from "../git.js";
3
+ import { busyPrompt, resolveHarness } from "../harness.js";
4
+ import { findLease } from "../host/lease.js";
3
5
  import { normalizeKeys } from "../keys.js";
4
6
  import { createZellijClient } from "../zellij/client.js";
7
+ import { parseCreatedAge } from "../zellij/session.js";
5
8
  import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
6
9
  import { createStateStore } from "../state.js";
7
10
  import { busToPanes } from "../zellij/bus.js";
@@ -9,7 +12,7 @@ import { broadcast } from "./broadcast.js";
9
12
  import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
10
13
  import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
11
14
  import { peerCheckpoint, peerDiff } from "./review.js";
12
- import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
15
+ import { attachKnownSender, deliverTo, scaleBudgets, withSenderLabel, selfPaneTitle } from "./delivery.js";
13
16
  import { assertNotPlugin, assertNotSelf, assertExpectedScreen, assertWritable, } from "./guards.js";
14
17
  import { readDeliveryLog } from "./log.js";
15
18
  import { awaitSignal, listSignals, postSignal } from "./signals.js";
@@ -19,7 +22,7 @@ import { invocationContext } from "./routing.js";
19
22
  import { DEFAULT_STATUS_TIMEOUT_MS, peerStatus, } from "./status.js";
20
23
  import { tailPane } from "./tail.js";
21
24
  import { OP_NAMES } from "../schema.js";
22
- import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
25
+ import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, unfoldScreen, } from "./util.js";
23
26
  import { waitForPane } from "./wait.js";
24
27
  import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
25
28
  import { DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, uninstallServeLogon, } from "./serve-install.js";
@@ -100,18 +103,22 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
100
103
  const to = String(args.to ?? "").trim();
101
104
  if (!to)
102
105
  throw new ZellijError("missing_peer", "to required");
103
- const budget = observationBudget(clock, numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 }), signal);
106
+ const timeoutMs = numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 });
107
+ const budget = observationBudget(clock, timeoutMs, signal);
108
+ const explicitObserve = args.observeMs !== undefined && args.observeMs !== null && args.observeMs !== "";
104
109
  const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
105
110
  const { session } = await client.resolveSession(optionalString(args.session), budget.require());
106
111
  const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
107
112
  deadlineAt: budget.deadline, signal,
108
113
  });
109
114
  let panes = null;
115
+ let fromBus = false;
110
116
  if (bus) {
111
117
  const snapshot = busToPanes(bus.snapshot);
112
118
  try {
113
119
  client.resolvePane(snapshot, to);
114
120
  panes = snapshot;
121
+ fromBus = true;
115
122
  }
116
123
  catch { /* An absent title/command/ID gets a fresh direct observation. */ }
117
124
  }
@@ -121,13 +128,25 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
121
128
  let until = Number.POSITIVE_INFINITY;
122
129
  let pane;
123
130
  let fresh = isTrue(args.fresh) || Boolean(op);
131
+ let firstCallMs = 0;
132
+ let timed = false;
124
133
  for (;;) {
125
134
  const left = until === Number.POSITIVE_INFINITY || observeMs === 0
126
135
  ? budget.require()
127
136
  : Math.min(budget.require(), Math.max(1, until - clock.now()));
137
+ const listingStart = clock.now();
138
+ const wantedListing = panes === null;
128
139
  panes ??= await client.listPanes(session, left, { fresh });
129
- if (until === Number.POSITIVE_INFINITY)
130
- until = Math.min(budget.deadline, clock.now() + observeMs);
140
+ if (wantedListing && !timed) {
141
+ // The send's first Zellij call: a slow one scales the pane-observe and
142
+ // settle windows rather than making callers hand-tune --observe-ms.
143
+ firstCallMs = Math.max(0, clock.now() - listingStart);
144
+ timed = true;
145
+ }
146
+ if (until === Number.POSITIVE_INFINITY) {
147
+ const window = explicitObserve ? observeMs : scaleBudgets({ firstCallMs, timeoutMs }).observeMs;
148
+ until = Math.min(budget.deadline, clock.now() + window);
149
+ }
131
150
  throwIfAborted(signal);
132
151
  try {
133
152
  pane = client.resolvePane(panes, to);
@@ -145,7 +164,48 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
145
164
  }
146
165
  if (policy && op)
147
166
  assertPaneAllowed(policy, pane, op);
148
- return { session, panes, pane };
167
+ return { session, panes, pane, fromBus, firstCallMs, timeoutMs };
168
+ }
169
+ /**
170
+ * A pane's content width, for unfolding a glued dump. Only a bus-resolved pane
171
+ * reaches this: a direct listing already carried the field, and the manifest
172
+ * the bus pushes has no geometry. A single-line dump is the only caller.
173
+ */
174
+ async function paneContentColumns(client, session, pane) {
175
+ const panes = await client.listPanes(session);
176
+ return panes.find((p) => p.id === pane.id)?.contentColumns ?? 0;
177
+ }
178
+ /**
179
+ * Zellij puts a newline before a row only when the row is marked canonical, and
180
+ * a full-screen TUI repaints by writing full-width rows back to back, so every
181
+ * row is a wrap continuation: `dump-screen` then hands back the whole viewport
182
+ * as one line. A pane whose rows are ended by a line feed reads normally and is
183
+ * left alone. The `--ansi` dump keeps the repainted rows full width, so it
184
+ * unfolds back into rows when it divides into the pane's column count exactly;
185
+ * anything else keeps the original text.
186
+ */
187
+ async function unglueScreen(client, session, pane, text, full, fromBus) {
188
+ // The Zellij client prints the dump, so even a glued screen ends in one
189
+ // `println!` newline; only a newline *inside* the text separates rows.
190
+ const body = text.replace(/\n+$/, "");
191
+ if (!body || body.includes("\n"))
192
+ return text;
193
+ const columns = pane.contentColumns && pane.contentColumns > 1
194
+ ? pane.contentColumns
195
+ : fromBus
196
+ ? await paneContentColumns(client, session, pane)
197
+ : 0;
198
+ if (columns < 2 || body.length <= columns)
199
+ return text;
200
+ let ansi;
201
+ try {
202
+ ansi = (await client.dumpPane({ session, paneId: pane.id, full, ansi: true })).text;
203
+ }
204
+ catch {
205
+ // A Zellij too old for `--ansi` (or a failing call) must not break dump.
206
+ return text;
207
+ }
208
+ return unfoldScreen(ansi, columns) ?? text;
149
209
  }
150
210
  async function dispatchOperation(args, injected, deps, context, env) {
151
211
  const op = String(args.op ?? "");
@@ -275,15 +335,25 @@ async function dispatchOperation(args, injected, deps, context, env) {
275
335
  ipc.status === "cancelled")) {
276
336
  throw new ZellijError("ipc_unreachable", `ZSWARM_TMP=auto could not discover the remote Zellij IPC directory (status=${ipc.status}). Set ZSWARM_TMP to the desktop TEMP, confirm ZSWARM_SSH_MODE=interactive with a logged-on user, or use zswarm serve. Refusing to treat EXITED rows as "no live crew".`);
277
337
  }
338
+ if (isTrue(args.pruneExited)) {
339
+ const pruned = await pruneExitedSessions(client, listed, args);
340
+ return {
341
+ ok: true,
342
+ data: { ...pruned, zellij: client.zellijPath, transport, filter: "exited" },
343
+ };
344
+ }
345
+ const onlyExited = isTrue(args.exited);
278
346
  const includeExited = isTrue(args.all);
279
- const sessions = includeExited
280
- ? listed
281
- : listed.filter((s) => !s.exited);
347
+ const sessions = onlyExited
348
+ ? listed.filter((s) => s.exited)
349
+ : includeExited
350
+ ? listed
351
+ : listed.filter((s) => !s.exited);
282
352
  const data = {
283
353
  sessions,
284
354
  zellij: client.zellijPath,
285
355
  transport,
286
- filter: includeExited ? "all" : "live",
356
+ filter: onlyExited ? "exited" : includeExited ? "all" : "live",
287
357
  };
288
358
  return { ok: true, data };
289
359
  }
@@ -313,9 +383,26 @@ async function dispatchOperation(args, injected, deps, context, env) {
313
383
  const body = String(args.body ?? args.text ?? "");
314
384
  if (!body.trim())
315
385
  throw new ZellijError("missing_body", "body required");
316
- const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
386
+ const { session, pane, panes, firstCallMs, timeoutMs } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
317
387
  assertWritable(client, pane, args, "send");
318
388
  await assertExpectedScreen(client, session, pane.id, args, signal);
389
+ if (isTrue(args.ifIdle)) {
390
+ // Before delivery starts: a live pane lease (a relay working it) is a
391
+ // hard refusal; a harness that is visibly working is the softer one.
392
+ const to = String(args.to ?? "").trim();
393
+ const held = findLease(state(), {
394
+ serve: typeof args.serve === "string" ? args.serve : env.ZSWARM_SERVE,
395
+ session,
396
+ pane: to,
397
+ });
398
+ if (held) {
399
+ 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 });
400
+ }
401
+ const screen = await client.dumpPane({ session, paneId: pane.id }).then((res) => res.text, () => null);
402
+ if (screen !== null && busyPrompt(screen, resolveHarness(pane))) {
403
+ throw new ZellijError("pane_busy", `pane ${to} is busy with a task; retry when it is idle`);
404
+ }
405
+ }
319
406
  const labeled = withSenderLabel(args, {
320
407
  env,
321
408
  selfTitle: selfPaneTitle(client, panes),
@@ -327,6 +414,8 @@ async function dispatchOperation(args, injected, deps, context, env) {
327
414
  op: "send",
328
415
  at: clock.now(),
329
416
  clock,
417
+ firstCallMs,
418
+ timeoutMs,
330
419
  });
331
420
  if (!result.ok && result.error) {
332
421
  throw new ZellijError(result.error.code, result.error.message);
@@ -401,15 +490,17 @@ async function dispatchOperation(args, injected, deps, context, env) {
401
490
  };
402
491
  }
403
492
  case "dump": {
404
- const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
493
+ const { session, pane, fromBus } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
494
+ const full = isTrue(args.full);
405
495
  const dumped = await client.dumpPane({
406
496
  session,
407
497
  paneId: pane.id,
408
- full: isTrue(args.full),
498
+ full,
409
499
  });
500
+ const text = await unglueScreen(client, session, pane, dumped.text, full, fromBus);
410
501
  const max = dumpMaxChars(args);
411
502
  const keep = isTrue(args.head) ? "head" : "tail";
412
- const clipped = truncateDumpText(dumped.text, max, keep);
503
+ const clipped = truncateDumpText(text, max, keep);
413
504
  return {
414
505
  ok: true,
415
506
  data: {
@@ -591,6 +682,52 @@ async function dispatchOperation(args, injected, deps, context, env) {
591
682
  return fail(err);
592
683
  }
593
684
  }
685
+ /** Delete EXITED sessions only: `zellij delete-session` has no --force. */
686
+ async function pruneExitedSessions(client, listed, args) {
687
+ const olderThanRaw = optionalString(args.olderThan);
688
+ let minAge = null;
689
+ if (olderThanRaw) {
690
+ minAge = parseCreatedAge(olderThanRaw);
691
+ if (minAge === null) {
692
+ throw new ZellijError("bad_arg", `--older-than needs a duration like 7d, 12h, 30m (got ${olderThanRaw})`);
693
+ }
694
+ }
695
+ const dryRun = isTrue(args.dryRun);
696
+ const deleted = [];
697
+ const skipped = [];
698
+ for (const session of listed.filter((s) => s.exited)) {
699
+ const row = { name: session.name, age: session.age, ageSeconds: session.ageSeconds };
700
+ if (minAge !== null && (session.ageSeconds === null || session.ageSeconds < minAge)) {
701
+ skipped.push({
702
+ ...row,
703
+ reason: session.ageSeconds === null
704
+ ? "age unknown"
705
+ : `created less than ${olderThanRaw} ago`,
706
+ });
707
+ continue;
708
+ }
709
+ if (dryRun) {
710
+ deleted.push(row);
711
+ continue;
712
+ }
713
+ try {
714
+ await client.deleteSession(session.name);
715
+ deleted.push(row);
716
+ }
717
+ catch (err) {
718
+ skipped.push({ ...row, reason: err instanceof Error ? err.message : String(err) });
719
+ }
720
+ }
721
+ return {
722
+ pruneExited: true,
723
+ dryRun,
724
+ olderThanSeconds: minAge,
725
+ deleted,
726
+ deletedCount: deleted.length,
727
+ skipped,
728
+ skippedCount: skipped.length,
729
+ };
730
+ }
594
731
  /** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
595
732
  export async function dispatchZswarm(args, injected, deps = {}) {
596
733
  try {
@@ -44,6 +44,15 @@ export declare function truncateDumpText(text: string, maxChars: number, keep?:
44
44
  };
45
45
  /** Screen text with trailing blanks removed, so idle detection ignores padding. */
46
46
  export declare function normalizeScreen(text: string): string;
47
+ /**
48
+ * Zellij's plain `dump-screen` puts a newline only before rows it marks
49
+ * canonical; a full-width repaint (a TUI) leaves every row a wrap
50
+ * continuation, so the whole viewport arrives as one line. The `--ansi`
51
+ * dump keeps those rows full width, so the text is a whole number of
52
+ * `columns`-wide rows once the SGR sequences are dropped. Returns null when
53
+ * that shape does not hold and the caller must keep the original text.
54
+ */
55
+ export declare function unfoldScreen(text: string, columns: number): string | null;
47
56
  export declare function numberArg(args: Record<string, unknown>, key: string, fallback: number, limits: {
48
57
  min: number;
49
58
  max: number;
package/dist/ops/util.js CHANGED
@@ -71,6 +71,30 @@ export function normalizeScreen(text) {
71
71
  .join("\n")
72
72
  .replace(/\n+$/, "");
73
73
  }
74
+ /**
75
+ * Zellij's plain `dump-screen` puts a newline only before rows it marks
76
+ * canonical; a full-width repaint (a TUI) leaves every row a wrap
77
+ * continuation, so the whole viewport arrives as one line. The `--ansi`
78
+ * dump keeps those rows full width, so the text is a whole number of
79
+ * `columns`-wide rows once the SGR sequences are dropped. Returns null when
80
+ * that shape does not hold and the caller must keep the original text.
81
+ */
82
+ export function unfoldScreen(text, columns) {
83
+ if (columns < 2)
84
+ return null;
85
+ const body = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\n+$/, "");
86
+ if (body.length === 0)
87
+ return null;
88
+ if (body.length % columns !== 0)
89
+ return null;
90
+ if (body.length / columns < 2)
91
+ return null;
92
+ const rows = [];
93
+ for (let i = 0; i < body.length; i += columns) {
94
+ rows.push(body.slice(i, i + columns).replace(/\s+$/, ""));
95
+ }
96
+ return rows.join("\n");
97
+ }
74
98
  export function numberArg(args, key, fallback, limits) {
75
99
  const raw = args[key];
76
100
  if (raw === undefined || raw === null || raw === "")
package/dist/schema.js CHANGED
@@ -72,6 +72,41 @@ export const PARAMS = [
72
72
  flags: ["--live", "--active"],
73
73
  description: "sessions: only live (non-EXITED) sessions — this is the default; use --all to include EXITED",
74
74
  },
75
+ {
76
+ name: "exited",
77
+ type: "boolean",
78
+ flags: ["--exited"],
79
+ cliOnly: true,
80
+ description: "sessions: list only EXITED (resurrectable) sessions; rows carry name, exited, age and ageSeconds",
81
+ },
82
+ {
83
+ name: "pruneExited",
84
+ type: "boolean",
85
+ flags: ["--prune-exited"],
86
+ cliOnly: true,
87
+ description: "sessions: delete EXITED sessions (all, or those matching --older-than) with zellij delete-session; a live session is never removed",
88
+ },
89
+ {
90
+ name: "olderThan",
91
+ type: "string",
92
+ flags: ["--older-than"],
93
+ cliOnly: true,
94
+ description: "sessions --prune-exited: only delete sessions created at least this long ago (e.g. 7d, 12h, 30m; default all exited)",
95
+ },
96
+ {
97
+ name: "dryRun",
98
+ type: "boolean",
99
+ flags: ["--dry-run"],
100
+ cliOnly: true,
101
+ description: "sessions --prune-exited: report what would be deleted without deleting",
102
+ },
103
+ {
104
+ name: "json",
105
+ type: "boolean",
106
+ flags: ["--json"],
107
+ cliOnly: true,
108
+ description: "sessions: rows are name, exited, age and ageSeconds; the CLI prints JSON either way",
109
+ },
75
110
  {
76
111
  name: "local",
77
112
  type: "boolean",
@@ -344,19 +379,31 @@ export const PARAMS = [
344
379
  type: "string",
345
380
  flags: ["--submit"],
346
381
  values: ["auto", "double-enter", "none"],
347
- description: "send/broadcast: auto verifies the paste actually submitted and presses Enter again if not (default)",
382
+ 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), or "not-delivered" (the pane was unchanged apart from the composer, so the op fails not_delivered and a resend is safe)',
348
383
  },
349
384
  {
350
385
  name: "observeMs",
351
386
  type: "number",
352
387
  flags: ["--observe-ms"],
353
- description: "spawn: observe creation/alias for up to 3000ms; pane lookup: retry absence for 1000ms; 0 disables retries",
388
+ description: "spawn: observe creation/alias for up to 3000ms; pane lookup: retry absence for 1000ms (scaled up to 5x when the first Zellij call is slow); 0 disables retries",
354
389
  },
355
390
  {
356
391
  name: "settleMs",
357
392
  type: "number",
358
393
  flags: ["--settle-ms"],
359
- description: "send/broadcast: pause before checking the paste landed (default 300)",
394
+ description: "send/broadcast: pause before checking the paste landed (default 300, scaled up to 5x when the first Zellij call is slow; explicit value wins)",
395
+ },
396
+ {
397
+ name: "confirm",
398
+ type: "boolean",
399
+ flags: ["--confirm"],
400
+ 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',
401
+ },
402
+ {
403
+ name: "ifIdle",
404
+ type: "boolean",
405
+ flags: ["--if-idle"],
406
+ 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",
360
407
  },
361
408
  {
362
409
  name: "expect",
package/dist/state.d.ts CHANGED
@@ -29,6 +29,24 @@ export type BusMarkerRecord = {
29
29
  configKey: string;
30
30
  installedAt: number;
31
31
  };
32
+ /**
33
+ * One held pane lease: a relay is working this target until it ends. `key`
34
+ * names the target (serve endpoint or local, session, pane) so two processes
35
+ * racing to take it are serialized by the lease lock.
36
+ */
37
+ export type LeaseRecord = {
38
+ key: string;
39
+ relayId: string;
40
+ from: string;
41
+ at: number;
42
+ /** The relay run directory; a missing/finished one makes the lease stale. */
43
+ dir: string;
44
+ serve?: string;
45
+ session?: string;
46
+ pane: string;
47
+ /** Relays that forced through while this lease was held. */
48
+ sharedBy?: string[];
49
+ };
32
50
  /** How much of the end of the log `readLog` reads; older entries are not shown. */
33
51
  export declare const LOG_TAIL_BYTES: number;
34
52
  /**
@@ -55,6 +73,8 @@ export declare function createStateStore(options?: StateStoreOptions): {
55
73
  readBus: (session: string) => BusMarkerRecord | null;
56
74
  writeBus: (session: string, marker: BusMarkerRecord) => void;
57
75
  clearBus: (session?: string) => void;
76
+ readLeases: () => Record<string, LeaseRecord>;
77
+ withLeases: <T>(fn: (leases: Record<string, LeaseRecord>) => T) => T;
58
78
  reset: () => void;
59
79
  };
60
80
  export type StateStore = ReturnType<typeof createStateStore>;
package/dist/state.js CHANGED
@@ -7,6 +7,8 @@ const SIGNALS_LOCK = "signals.lock";
7
7
  const CURSORS_FILE = "cursors.json";
8
8
  const CURSORS_LOCK = "cursors.lock";
9
9
  const BUS_FILE = "bus.json";
10
+ const LEASES_FILE = "leases.json";
11
+ const LEASES_LOCK = "leases.lock";
10
12
  /** How much of the end of the log `readLog` reads; older entries are not shown. */
11
13
  export const LOG_TAIL_BYTES = 512 * 1024;
12
14
  /**
@@ -462,6 +464,22 @@ export function createStateStore(options = {}) {
462
464
  }
463
465
  writeJson(BUS_FILE, { sessions });
464
466
  }
467
+ function readLeases() {
468
+ return readJson(LEASES_FILE, {});
469
+ }
470
+ /**
471
+ * Atomic lease check-then-take/update. The callback mutates the map and its
472
+ * return value goes to the caller; the map is persisted while the state lock
473
+ * is held, so two processes racing to take one pane cannot both win.
474
+ */
475
+ function withLeases(fn) {
476
+ return withFileLock(LEASES_LOCK, () => {
477
+ const leases = readJson(LEASES_FILE, {});
478
+ const result = fn(leases);
479
+ writeJson(LEASES_FILE, leases);
480
+ return result;
481
+ });
482
+ }
465
483
  /** Test helper: drop everything this store wrote. */
466
484
  function reset() {
467
485
  rmSync(dir, { recursive: true, force: true });
@@ -480,6 +498,8 @@ export function createStateStore(options = {}) {
480
498
  readBus,
481
499
  writeBus,
482
500
  clearBus,
501
+ readLeases,
502
+ withLeases,
483
503
  reset,
484
504
  };
485
505
  }
@@ -83,7 +83,7 @@ export declare function buildLaunchPluginArgs(input: LaunchPluginInput): string[
83
83
  export declare function buildListPanesArgs(session: string): string[];
84
84
  export declare function buildPasteArgs(session: string, paneId: string, text: string): string[];
85
85
  export declare function buildSendEnterArgs(session: string, paneId: string): string[];
86
- export declare function buildDumpArgs(session: string, paneId: string, full?: boolean): string[];
86
+ export declare function buildDumpArgs(session: string, paneId: string, full?: boolean, ansi?: boolean): string[];
87
87
  export declare function buildSendKeysArgs(session: string, paneId: string, keys: string[]): string[];
88
88
  export declare function buildWriteCharsArgs(session: string, paneId: string, chars: string): string[];
89
89
  export declare function buildRenamePaneArgs(session: string, paneId: string, name: string): string[];
@@ -122,7 +122,7 @@ export function buildSendEnterArgs(session, paneId) {
122
122
  "Enter",
123
123
  ];
124
124
  }
125
- export function buildDumpArgs(session, paneId, full = false) {
125
+ export function buildDumpArgs(session, paneId, full = false, ansi = false) {
126
126
  const args = [
127
127
  ...sessionPrefix(session),
128
128
  "action",
@@ -132,6 +132,8 @@ export function buildDumpArgs(session, paneId, full = false) {
132
132
  ];
133
133
  if (full)
134
134
  args.push("--full");
135
+ if (ansi)
136
+ args.push("--ansi");
135
137
  return args;
136
138
  }
137
139
  export function buildSendKeysArgs(session, paneId, keys) {
@@ -295,6 +295,7 @@ export function busToPanes(snapshot) {
295
295
  focused: pane.focused,
296
296
  exited: pane.exited,
297
297
  floating: false,
298
+ contentColumns: null,
298
299
  };
299
300
  });
300
301
  }
@@ -41,6 +41,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
41
41
  listSessions: (callTimeoutMs?: number, opts?: {
42
42
  fresh?: boolean;
43
43
  }) => Promise<ZellijSession[]>;
44
+ deleteSession: (name: string, callTimeoutMs?: number) => Promise<void>;
44
45
  resolveSession: (explicit?: string | null, callTimeoutMs?: number) => Promise<ZellijSessionResolve>;
45
46
  listPanes: (session: string, callTimeoutMs?: number, opts?: {
46
47
  fresh?: boolean;
@@ -58,6 +59,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
58
59
  session: string;
59
60
  paneId: string;
60
61
  full?: boolean;
62
+ ansi?: boolean;
61
63
  timeoutMs?: number;
62
64
  }) => Promise<{
63
65
  paneId: string;
@@ -173,6 +173,21 @@ export function createZellijClient(options = {}) {
173
173
  return parseSessionList(result.stdout);
174
174
  });
175
175
  }
176
+ /**
177
+ * Delete one session by name. No `--force`: Zellij refuses to delete a live
178
+ * session, so this can only remove an EXITED (resurrectable) one.
179
+ */
180
+ async function deleteSession(name, callTimeoutMs = timeoutMs) {
181
+ const result = await exec(["delete-session", name], { timeoutMs: callTimeoutMs });
182
+ if (result.code === NOT_FOUND_EXIT) {
183
+ throw zellijMissingError(zellijPath, result);
184
+ }
185
+ if (result.code !== 0) {
186
+ const detail = result.stderr.trim() || result.stdout.trim() || "no output";
187
+ throw new ZellijError("zellij_failed", `zellij delete-session ${name} failed (exit ${result.code}): ${detail}`, zellijExecDetails(result));
188
+ }
189
+ invalidateListings(name);
190
+ }
176
191
  async function resolveSession(explicit, callTimeoutMs = timeoutMs) {
177
192
  return (sessionFromEnv(env, explicit) ??
178
193
  sessionFromList(await listSessions(callTimeoutMs)));
@@ -194,7 +209,7 @@ export function createZellijClient(options = {}) {
194
209
  }
195
210
  async function dumpPane(input) {
196
211
  const paneId = normalizePaneId(input.paneId);
197
- const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
212
+ const result = await run(buildDumpArgs(input.session, paneId, input.full, input.ansi), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
198
213
  return { paneId, session: input.session, text: result.stdout };
199
214
  }
200
215
  async function sendKeys(input) {
@@ -362,6 +377,7 @@ export function createZellijClient(options = {}) {
362
377
  /** A `file:` plugin url only names a path on the machine running Zellij. */
363
378
  remote: ssh !== null,
364
379
  listSessions,
380
+ deleteSession,
365
381
  resolveSession,
366
382
  listPanes,
367
383
  resolvePane,
@@ -10,6 +10,7 @@ export type ZellijPane = {
10
10
  focused: boolean;
11
11
  exited: boolean;
12
12
  floating: boolean;
13
+ contentColumns: number | null;
13
14
  };
14
15
  export declare function normalizePaneId(raw: string, isPlugin?: boolean): string;
15
16
  /** Parse `list-panes --json` output. */
@@ -32,6 +32,9 @@ function parsePaneRow(row) {
32
32
  focused: row.is_focused === true,
33
33
  exited: row.exited === true,
34
34
  floating: row.is_floating === true,
35
+ contentColumns: typeof row.pane_content_columns === "number"
36
+ ? row.pane_content_columns
37
+ : null,
35
38
  };
36
39
  }
37
40
  /** Parse `list-panes --json` output. */
@@ -9,12 +9,23 @@ export type ZellijSession = {
9
9
  exited: boolean;
10
10
  /** True when the listing marks this session as the caller's current one. */
11
11
  current: boolean;
12
+ /** The printed created-duration text without the trailing "ago" (`"1month 16days 2h"`), or null. */
13
+ age: string | null;
14
+ /** That duration in seconds, or null when it cannot be read. */
15
+ ageSeconds: number | null;
12
16
  };
13
17
  /**
14
18
  * Pane hosting the caller, so writes can refuse to loop back into it.
15
19
  * `ZSWARM_SELF_PANE` wins; Zellij exports `ZELLIJ_PANE_ID` inside a pane.
16
20
  */
17
21
  export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
22
+ /**
23
+ * Zellij's human created age (`"1month 16days 2h 30m 5s ago"`) as seconds.
24
+ * Handles the units Zellij prints (s, m, h, days, months, years, singular and
25
+ * plural) with or without spaces between the pairs. Returns null when no
26
+ * number+unit pair is present (e.g. a bare `"demo"` listing).
27
+ */
28
+ export declare function parseCreatedAge(text: string): number | null;
18
29
  /**
19
30
  * Parse `list-sessions --no-formatting` output.
20
31
  *