@zswarm/core 0.2.2 → 0.2.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/assets/host-install.sh +36 -2
- package/assets/host-report.sh +15 -1
- package/dist/harness.d.ts +10 -0
- package/dist/harness.js +33 -1
- package/dist/host/cli.js +50 -4
- package/dist/host/crew.d.ts +2 -0
- package/dist/host/crew.js +13 -0
- package/dist/host/host.js +13 -0
- package/dist/host/relay.d.ts +63 -1
- package/dist/host/relay.js +239 -2
- package/dist/host/services.d.ts +9 -2
- package/dist/host/services.js +105 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ops/delivery.d.ts +51 -2
- package/dist/ops/delivery.js +162 -31
- package/dist/ops/dispatch.js +83 -10
- package/dist/schema.js +44 -3
- package/dist/zellij/client.d.ts +1 -0
- package/dist/zellij/client.js +16 -0
- package/dist/zellij/session.d.ts +11 -0
- package/dist/zellij/session.js +35 -3
- package/package.json +1 -1
package/dist/ops/delivery.js
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
|
-
import { resolveHarness } from "../harness.js";
|
|
1
|
+
import { queuedPrompt, resolveHarness } from "../harness.js";
|
|
2
2
|
import { isTrue, numberArg } from "./util.js";
|
|
3
|
+
/** Fixed send windows before adaptive scaling; explicit flags always win. */
|
|
4
|
+
export const SEND_BUDGETS = {
|
|
5
|
+
/** Pause before checking the paste landed. */
|
|
6
|
+
settleMs: 300,
|
|
7
|
+
/** Pane-lookup retry window for an absent pane. */
|
|
8
|
+
observeMs: 1_000,
|
|
9
|
+
/** The scaled window never exceeds this multiple of the default. */
|
|
10
|
+
maxScale: 5,
|
|
11
|
+
/** A first Zellij call at or under this many ms is not "slow". */
|
|
12
|
+
slowMs: 1_000,
|
|
13
|
+
settleMaxMs: 5_000,
|
|
14
|
+
observeMaxMs: 30_000,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export function scaleBudgets(input) {
|
|
24
|
+
const raw = input.firstCallMs > SEND_BUDGETS.slowMs ? input.firstCallMs / SEND_BUDGETS.slowMs : 1;
|
|
25
|
+
const scale = Math.min(SEND_BUDGETS.maxScale, Math.max(1, raw));
|
|
26
|
+
const scaled = (base, hardMax) => Math.max(1, Math.floor(Math.min(base * scale, hardMax, input.timeoutMs)));
|
|
27
|
+
return {
|
|
28
|
+
settleMs: input.settleMs !== undefined ? Math.min(input.settleMs, SEND_BUDGETS.settleMaxMs) : scaled(SEND_BUDGETS.settleMs, SEND_BUDGETS.settleMaxMs),
|
|
29
|
+
observeMs: input.observeMs !== undefined ? Math.min(input.observeMs, SEND_BUDGETS.observeMaxMs) : scaled(SEND_BUDGETS.observeMs, SEND_BUDGETS.observeMaxMs),
|
|
30
|
+
scale,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
3
33
|
/** Cursor / Claude leave this in scrollback after a successful submit. */
|
|
4
34
|
const PASTE_MARKER = "[Pasted text";
|
|
5
35
|
const SENDER_LABEL_MAX = 48;
|
|
@@ -78,44 +108,71 @@ function nonEmptyLines(screen) {
|
|
|
78
108
|
.map((line) => line.replace(/\s+$/, ""))
|
|
79
109
|
.filter((line) => line.length > 0);
|
|
80
110
|
}
|
|
111
|
+
/** All whitespace removed, so a body match survives a pane's line wrapping. */
|
|
112
|
+
function withoutSpaces(text) {
|
|
113
|
+
return text.replace(/\s+/g, "");
|
|
114
|
+
}
|
|
115
|
+
/** True when `needle` appears in `text`, ignoring whitespace (wrapped panes). */
|
|
116
|
+
function containsNeedle(text, needle) {
|
|
117
|
+
const wanted = withoutSpaces(needle);
|
|
118
|
+
return wanted.length > 0 && withoutSpaces(text).includes(wanted);
|
|
119
|
+
}
|
|
120
|
+
const ZSWARM_HEADER = /^\[zswarm from=/i;
|
|
121
|
+
/**
|
|
122
|
+
* The body text to look for on screen: its first non-empty line, trimmed to 40
|
|
123
|
+
* chars. A prefix of the whole body (`body.slice(0, 40)`) can span a newline and
|
|
124
|
+
* then never matches any single screen line. A leading `[zswarm from=…]` header
|
|
125
|
+
* is skipped so we look for the caller's own first line.
|
|
126
|
+
*/
|
|
127
|
+
export function bodyNeedle(body) {
|
|
128
|
+
const lines = body
|
|
129
|
+
.replace(/\r/g, "")
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((line) => line.trim())
|
|
132
|
+
.filter((line) => line.length > 0);
|
|
133
|
+
const first = lines.length > 0 && ZSWARM_HEADER.test(lines[0]) ? lines[1] : lines[0];
|
|
134
|
+
return (first ?? "").slice(0, 40);
|
|
135
|
+
}
|
|
81
136
|
function isPasteLine(line, needle) {
|
|
82
137
|
if (line.includes(PASTE_MARKER))
|
|
83
138
|
return true;
|
|
84
|
-
return
|
|
139
|
+
return containsNeedle(line, needle);
|
|
85
140
|
}
|
|
86
141
|
/** True when the paste still sits in the composer (bottom of the screen). */
|
|
87
142
|
export function composerHolds(screen, body) {
|
|
88
|
-
const needle = body
|
|
143
|
+
const needle = bodyNeedle(body);
|
|
89
144
|
const lines = nonEmptyLines(screen);
|
|
90
145
|
const last3 = lines.slice(-3);
|
|
91
146
|
const last = lines[lines.length - 1] ?? "";
|
|
92
147
|
if (last.includes(PASTE_MARKER))
|
|
93
148
|
return true;
|
|
94
|
-
return
|
|
149
|
+
return containsNeedle(last3.join("\n"), needle);
|
|
95
150
|
}
|
|
96
151
|
/**
|
|
97
152
|
* Compare the pane before vs after the paste.
|
|
98
153
|
* Composer clearing (pasted text left the bottom input region) is submission.
|
|
99
154
|
* `[Pasted text` in scrollback is not evidence.
|
|
155
|
+
*
|
|
156
|
+
* `not-delivered` is deliberately conservative: only when the screen is
|
|
157
|
+
* unchanged once the paste/composer itself is discounted and nothing is queued.
|
|
158
|
+
* A screen that changed while the body is gone is `unverified` — the pane may
|
|
159
|
+
* have taken the message and scrolled it away, and a false `not-delivered`
|
|
160
|
+
* makes the caller resend the whole message.
|
|
100
161
|
*/
|
|
101
|
-
export function classifySubmit(before, after, body) {
|
|
102
|
-
const needle = body
|
|
162
|
+
export function classifySubmit(before, after, body, opts = {}) {
|
|
163
|
+
const needle = bodyNeedle(body);
|
|
103
164
|
const beforeLines = nonEmptyLines(before);
|
|
104
165
|
const afterLines = nonEmptyLines(after);
|
|
105
166
|
const beforeNorm = beforeLines.join("\n");
|
|
106
|
-
const afterNorm = afterLines.join("\n");
|
|
107
167
|
const last3 = afterLines.slice(-3);
|
|
108
168
|
const last = afterLines[afterLines.length - 1] ?? "";
|
|
109
169
|
const markerInLastLine = last.includes(PASTE_MARKER);
|
|
110
|
-
const bodyInLastRegion =
|
|
170
|
+
const bodyInLastRegion = containsNeedle(last3.join("\n"), needle);
|
|
111
171
|
const holds = bodyInLastRegion || markerInLastLine;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return false;
|
|
117
|
-
return "unverified";
|
|
118
|
-
}
|
|
172
|
+
// The harness says it accepted the paste but queued it behind a running turn.
|
|
173
|
+
if (opts.queued?.(after))
|
|
174
|
+
return "queued";
|
|
175
|
+
// The paste is still in the composer: not submitted (Enter may rescue it).
|
|
119
176
|
if (holds) {
|
|
120
177
|
const withoutPaste = afterLines.filter((line) => !isPasteLine(line, needle));
|
|
121
178
|
if (withoutPaste.join("\n") === beforeNorm)
|
|
@@ -128,9 +185,15 @@ export function classifySubmit(before, after, body) {
|
|
|
128
185
|
return belowNovel.length > 0 ? true : false;
|
|
129
186
|
}
|
|
130
187
|
// Paste left the input region: still on screen, just not at the bottom.
|
|
131
|
-
if (
|
|
188
|
+
if (containsNeedle(after, needle))
|
|
132
189
|
return true;
|
|
133
|
-
|
|
190
|
+
// Nothing moved apart from the paste/composer itself and nothing is queued:
|
|
191
|
+
// positive evidence the paste never reached the pane, so a resend is safe.
|
|
192
|
+
const beforeApart = beforeLines.filter((line) => !isPasteLine(line, needle));
|
|
193
|
+
const afterApart = afterLines.filter((line) => !isPasteLine(line, needle));
|
|
194
|
+
if (afterApart.join("\n") === beforeApart.join("\n"))
|
|
195
|
+
return "not-delivered";
|
|
196
|
+
// The screen changed while the body is not visible: inconclusive, never a resend.
|
|
134
197
|
return "unverified";
|
|
135
198
|
}
|
|
136
199
|
async function dumpOrNull(client, session, paneId) {
|
|
@@ -142,7 +205,8 @@ async function dumpOrNull(client, session, paneId) {
|
|
|
142
205
|
}
|
|
143
206
|
}
|
|
144
207
|
async function verifySubmit(client, input) {
|
|
145
|
-
const { session, paneId, body, mode, settleMs, clock, before } = input;
|
|
208
|
+
const { session, paneId, body, mode, settleMs, clock, before, queued } = input;
|
|
209
|
+
const classify = (base, screen) => classifySubmit(base, screen, body, { queued });
|
|
146
210
|
if (mode === "none")
|
|
147
211
|
return "unverified";
|
|
148
212
|
if (mode === "double-enter") {
|
|
@@ -156,47 +220,57 @@ async function verifySubmit(client, input) {
|
|
|
156
220
|
const after = await dumpOrNull(client, session, paneId);
|
|
157
221
|
if (after === null)
|
|
158
222
|
return "unverified";
|
|
159
|
-
let verdict =
|
|
160
|
-
if (verdict === true)
|
|
161
|
-
return
|
|
223
|
+
let verdict = classify(before, after);
|
|
224
|
+
if (verdict === true || verdict === "queued")
|
|
225
|
+
return verdict;
|
|
162
226
|
if (verdict === false) {
|
|
163
227
|
await client.sendKeys({ session, paneId, keys: ["Enter"] });
|
|
164
228
|
await clock.sleep(settleMs);
|
|
165
229
|
const rescued = await dumpOrNull(client, session, paneId);
|
|
166
230
|
if (rescued === null)
|
|
167
231
|
return "unverified";
|
|
168
|
-
verdict =
|
|
169
|
-
if (verdict === true)
|
|
170
|
-
return
|
|
232
|
+
verdict = classify(before, rescued);
|
|
233
|
+
if (verdict === true || verdict === "queued")
|
|
234
|
+
return verdict;
|
|
171
235
|
if (verdict === false)
|
|
172
236
|
return false;
|
|
173
237
|
}
|
|
174
|
-
//
|
|
238
|
+
// Inconclusive or not (yet) on screen; TUIs often redraw after the first settle.
|
|
175
239
|
await clock.sleep(Math.min(Math.max(settleMs * 2, 800), 5000));
|
|
176
240
|
const later = await dumpOrNull(client, session, paneId);
|
|
177
241
|
if (later === null)
|
|
178
|
-
return
|
|
242
|
+
return verdict;
|
|
179
243
|
if (composerHolds(after, body) && !composerHolds(later, body))
|
|
180
244
|
return true;
|
|
181
|
-
return
|
|
245
|
+
return classify(before, later);
|
|
182
246
|
}
|
|
183
247
|
/** Paste a body into one pane and record the attempt. */
|
|
184
248
|
export async function deliverTo(client, state, args, input) {
|
|
185
|
-
const { session, pane, body, op, at, clock } = input;
|
|
249
|
+
const { session, pane, body, op, at, clock, firstCallMs = 0, timeoutMs = 30_000 } = input;
|
|
186
250
|
const from = senderLabel(args);
|
|
187
251
|
const mode = resolveSubmitMode(args, pane);
|
|
188
|
-
const
|
|
252
|
+
const profile = resolveHarness(pane);
|
|
253
|
+
const queued = (screen) => queuedPrompt(screen, profile);
|
|
254
|
+
const explicitSettle = args.settleMs !== undefined && args.settleMs !== null && args.settleMs !== "";
|
|
255
|
+
const budgets = scaleBudgets({
|
|
256
|
+
firstCallMs,
|
|
257
|
+
timeoutMs,
|
|
258
|
+
settleMs: explicitSettle ? numberArg(args, "settleMs", SEND_BUDGETS.settleMs, { min: 50, max: SEND_BUDGETS.settleMaxMs }) : undefined,
|
|
259
|
+
});
|
|
260
|
+
const settleMs = budgets.settleMs;
|
|
261
|
+
const confirmOnce = isTrue(args.confirm);
|
|
189
262
|
try {
|
|
190
263
|
let before = null;
|
|
191
264
|
if (mode === "auto") {
|
|
192
265
|
before = await dumpOrNull(client, session, pane.id);
|
|
193
266
|
}
|
|
194
|
-
const
|
|
267
|
+
const paste = () => client.injectPane({
|
|
195
268
|
session,
|
|
196
269
|
paneId: pane.id,
|
|
197
270
|
text: bodyText(client, args, body),
|
|
198
271
|
});
|
|
199
|
-
|
|
272
|
+
let delivered = await paste();
|
|
273
|
+
let submitted = await verifySubmit(client, {
|
|
200
274
|
session,
|
|
201
275
|
paneId: delivered.paneId,
|
|
202
276
|
body,
|
|
@@ -204,7 +278,64 @@ export async function deliverTo(client, state, args, input) {
|
|
|
204
278
|
settleMs,
|
|
205
279
|
clock,
|
|
206
280
|
before,
|
|
281
|
+
queued,
|
|
207
282
|
});
|
|
283
|
+
// Opt-in confirm. A second paste is only safe on positive evidence the
|
|
284
|
+
// first never landed; a still-held composer gets Enter, and unverified just
|
|
285
|
+
// looks again. Never paste the body twice.
|
|
286
|
+
if (confirmOnce && mode === "auto" && submitted !== true && submitted !== "queued") {
|
|
287
|
+
if (submitted === "not-delivered") {
|
|
288
|
+
const retryBefore = await dumpOrNull(client, session, delivered.paneId);
|
|
289
|
+
delivered = await paste();
|
|
290
|
+
submitted = await verifySubmit(client, {
|
|
291
|
+
session,
|
|
292
|
+
paneId: delivered.paneId,
|
|
293
|
+
body,
|
|
294
|
+
mode,
|
|
295
|
+
settleMs,
|
|
296
|
+
clock,
|
|
297
|
+
before: retryBefore,
|
|
298
|
+
queued,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (submitted === false) {
|
|
302
|
+
// The paste is still in the composer: press Enter, never paste again.
|
|
303
|
+
await client.sendKeys({ session, paneId: delivered.paneId, keys: ["Enter"] });
|
|
304
|
+
const seen = await dumpOrNull(client, session, delivered.paneId);
|
|
305
|
+
if (seen !== null && before !== null) {
|
|
306
|
+
submitted = classifySubmit(before, seen, body, { queued });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// unverified: one more dump to report what is actually on screen.
|
|
311
|
+
const seen = await dumpOrNull(client, session, delivered.paneId);
|
|
312
|
+
if (seen !== null && before !== null) {
|
|
313
|
+
submitted = classifySubmit(before, seen, body, { queued });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (submitted === "not-delivered") {
|
|
318
|
+
state.appendLog({
|
|
319
|
+
at,
|
|
320
|
+
op,
|
|
321
|
+
session,
|
|
322
|
+
to: delivered.paneId,
|
|
323
|
+
from,
|
|
324
|
+
bytes: body.length,
|
|
325
|
+
ok: false,
|
|
326
|
+
detail: "submitted=not-delivered",
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
to: delivered.paneId,
|
|
330
|
+
ok: false,
|
|
331
|
+
delivery: "zellij_paste",
|
|
332
|
+
submitted,
|
|
333
|
+
error: {
|
|
334
|
+
code: "not_delivered",
|
|
335
|
+
message: "the pane was unchanged apart from the composer after the send; the body never landed, so a resend is safe",
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
208
339
|
state.appendLog({
|
|
209
340
|
at,
|
|
210
341
|
op,
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ZellijError } from "../errors.js";
|
|
|
2
2
|
import { createGitClient } from "../git.js";
|
|
3
3
|
import { normalizeKeys } from "../keys.js";
|
|
4
4
|
import { createZellijClient } from "../zellij/client.js";
|
|
5
|
+
import { parseCreatedAge } from "../zellij/session.js";
|
|
5
6
|
import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
|
|
6
7
|
import { createStateStore } from "../state.js";
|
|
7
8
|
import { busToPanes } from "../zellij/bus.js";
|
|
@@ -9,7 +10,7 @@ import { broadcast } from "./broadcast.js";
|
|
|
9
10
|
import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
|
|
10
11
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
12
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
|
-
import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
13
|
+
import { attachKnownSender, deliverTo, scaleBudgets, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
13
14
|
import { assertNotPlugin, assertNotSelf, assertExpectedScreen, assertWritable, } from "./guards.js";
|
|
14
15
|
import { readDeliveryLog } from "./log.js";
|
|
15
16
|
import { awaitSignal, listSignals, postSignal } from "./signals.js";
|
|
@@ -100,7 +101,9 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
100
101
|
const to = String(args.to ?? "").trim();
|
|
101
102
|
if (!to)
|
|
102
103
|
throw new ZellijError("missing_peer", "to required");
|
|
103
|
-
const
|
|
104
|
+
const timeoutMs = numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 });
|
|
105
|
+
const budget = observationBudget(clock, timeoutMs, signal);
|
|
106
|
+
const explicitObserve = args.observeMs !== undefined && args.observeMs !== null && args.observeMs !== "";
|
|
104
107
|
const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
|
|
105
108
|
const { session } = await client.resolveSession(optionalString(args.session), budget.require());
|
|
106
109
|
const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
|
|
@@ -121,13 +124,25 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
121
124
|
let until = Number.POSITIVE_INFINITY;
|
|
122
125
|
let pane;
|
|
123
126
|
let fresh = isTrue(args.fresh) || Boolean(op);
|
|
127
|
+
let firstCallMs = 0;
|
|
128
|
+
let timed = false;
|
|
124
129
|
for (;;) {
|
|
125
130
|
const left = until === Number.POSITIVE_INFINITY || observeMs === 0
|
|
126
131
|
? budget.require()
|
|
127
132
|
: Math.min(budget.require(), Math.max(1, until - clock.now()));
|
|
133
|
+
const listingStart = clock.now();
|
|
134
|
+
const wantedListing = panes === null;
|
|
128
135
|
panes ??= await client.listPanes(session, left, { fresh });
|
|
129
|
-
if (
|
|
130
|
-
|
|
136
|
+
if (wantedListing && !timed) {
|
|
137
|
+
// The send's first Zellij call: a slow one scales the pane-observe and
|
|
138
|
+
// settle windows rather than making callers hand-tune --observe-ms.
|
|
139
|
+
firstCallMs = Math.max(0, clock.now() - listingStart);
|
|
140
|
+
timed = true;
|
|
141
|
+
}
|
|
142
|
+
if (until === Number.POSITIVE_INFINITY) {
|
|
143
|
+
const window = explicitObserve ? observeMs : scaleBudgets({ firstCallMs, timeoutMs }).observeMs;
|
|
144
|
+
until = Math.min(budget.deadline, clock.now() + window);
|
|
145
|
+
}
|
|
131
146
|
throwIfAborted(signal);
|
|
132
147
|
try {
|
|
133
148
|
pane = client.resolvePane(panes, to);
|
|
@@ -145,7 +160,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
145
160
|
}
|
|
146
161
|
if (policy && op)
|
|
147
162
|
assertPaneAllowed(policy, pane, op);
|
|
148
|
-
return { session, panes, pane };
|
|
163
|
+
return { session, panes, pane, firstCallMs, timeoutMs };
|
|
149
164
|
}
|
|
150
165
|
async function dispatchOperation(args, injected, deps, context, env) {
|
|
151
166
|
const op = String(args.op ?? "");
|
|
@@ -275,15 +290,25 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
275
290
|
ipc.status === "cancelled")) {
|
|
276
291
|
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
292
|
}
|
|
293
|
+
if (isTrue(args.pruneExited)) {
|
|
294
|
+
const pruned = await pruneExitedSessions(client, listed, args);
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
data: { ...pruned, zellij: client.zellijPath, transport, filter: "exited" },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const onlyExited = isTrue(args.exited);
|
|
278
301
|
const includeExited = isTrue(args.all);
|
|
279
|
-
const sessions =
|
|
280
|
-
? listed
|
|
281
|
-
:
|
|
302
|
+
const sessions = onlyExited
|
|
303
|
+
? listed.filter((s) => s.exited)
|
|
304
|
+
: includeExited
|
|
305
|
+
? listed
|
|
306
|
+
: listed.filter((s) => !s.exited);
|
|
282
307
|
const data = {
|
|
283
308
|
sessions,
|
|
284
309
|
zellij: client.zellijPath,
|
|
285
310
|
transport,
|
|
286
|
-
filter: includeExited ? "all" : "live",
|
|
311
|
+
filter: onlyExited ? "exited" : includeExited ? "all" : "live",
|
|
287
312
|
};
|
|
288
313
|
return { ok: true, data };
|
|
289
314
|
}
|
|
@@ -313,7 +338,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
313
338
|
const body = String(args.body ?? args.text ?? "");
|
|
314
339
|
if (!body.trim())
|
|
315
340
|
throw new ZellijError("missing_body", "body required");
|
|
316
|
-
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
341
|
+
const { session, pane, panes, firstCallMs, timeoutMs } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
317
342
|
assertWritable(client, pane, args, "send");
|
|
318
343
|
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
319
344
|
const labeled = withSenderLabel(args, {
|
|
@@ -327,6 +352,8 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
327
352
|
op: "send",
|
|
328
353
|
at: clock.now(),
|
|
329
354
|
clock,
|
|
355
|
+
firstCallMs,
|
|
356
|
+
timeoutMs,
|
|
330
357
|
});
|
|
331
358
|
if (!result.ok && result.error) {
|
|
332
359
|
throw new ZellijError(result.error.code, result.error.message);
|
|
@@ -591,6 +618,52 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
591
618
|
return fail(err);
|
|
592
619
|
}
|
|
593
620
|
}
|
|
621
|
+
/** Delete EXITED sessions only: `zellij delete-session` has no --force. */
|
|
622
|
+
async function pruneExitedSessions(client, listed, args) {
|
|
623
|
+
const olderThanRaw = optionalString(args.olderThan);
|
|
624
|
+
let minAge = null;
|
|
625
|
+
if (olderThanRaw) {
|
|
626
|
+
minAge = parseCreatedAge(olderThanRaw);
|
|
627
|
+
if (minAge === null) {
|
|
628
|
+
throw new ZellijError("bad_arg", `--older-than needs a duration like 7d, 12h, 30m (got ${olderThanRaw})`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const dryRun = isTrue(args.dryRun);
|
|
632
|
+
const deleted = [];
|
|
633
|
+
const skipped = [];
|
|
634
|
+
for (const session of listed.filter((s) => s.exited)) {
|
|
635
|
+
const row = { name: session.name, age: session.age, ageSeconds: session.ageSeconds };
|
|
636
|
+
if (minAge !== null && (session.ageSeconds === null || session.ageSeconds < minAge)) {
|
|
637
|
+
skipped.push({
|
|
638
|
+
...row,
|
|
639
|
+
reason: session.ageSeconds === null
|
|
640
|
+
? "age unknown"
|
|
641
|
+
: `created less than ${olderThanRaw} ago`,
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (dryRun) {
|
|
646
|
+
deleted.push(row);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
await client.deleteSession(session.name);
|
|
651
|
+
deleted.push(row);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
skipped.push({ ...row, reason: err instanceof Error ? err.message : String(err) });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
pruneExited: true,
|
|
659
|
+
dryRun,
|
|
660
|
+
olderThanSeconds: minAge,
|
|
661
|
+
deleted,
|
|
662
|
+
deletedCount: deleted.length,
|
|
663
|
+
skipped,
|
|
664
|
+
skippedCount: skipped.length,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
594
667
|
/** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
|
|
595
668
|
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
596
669
|
try {
|
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,25 @@ export const PARAMS = [
|
|
|
344
379
|
type: "string",
|
|
345
380
|
flags: ["--submit"],
|
|
346
381
|
values: ["auto", "double-enter", "none"],
|
|
347
|
-
description:
|
|
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',
|
|
360
401
|
},
|
|
361
402
|
{
|
|
362
403
|
name: "expect",
|
package/dist/zellij/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/zellij/client.js
CHANGED
|
@@ -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)));
|
|
@@ -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,
|
package/dist/zellij/session.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/zellij/session.js
CHANGED
|
@@ -13,6 +13,34 @@ export function resolveSelfPaneId(env = process.env) {
|
|
|
13
13
|
return raw.toLowerCase();
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
const AGE_UNITS = [
|
|
17
|
+
[/^years?$|^yrs?$|^y$/, 31_536_000],
|
|
18
|
+
[/^months?$|^mons?$|^mo$/, 2_592_000],
|
|
19
|
+
[/^days?$|^d$/, 86_400],
|
|
20
|
+
[/^hours?$|^hrs?$|^h$/, 3_600],
|
|
21
|
+
[/^minutes?$|^mins?$|^m$/, 60],
|
|
22
|
+
[/^seconds?$|^secs?$|^s$/, 1],
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* Zellij's human created age (`"1month 16days 2h 30m 5s ago"`) as seconds.
|
|
26
|
+
* Handles the units Zellij prints (s, m, h, days, months, years, singular and
|
|
27
|
+
* plural) with or without spaces between the pairs. Returns null when no
|
|
28
|
+
* number+unit pair is present (e.g. a bare `"demo"` listing).
|
|
29
|
+
*/
|
|
30
|
+
export function parseCreatedAge(text) {
|
|
31
|
+
const re = /(\d+)\s*(years?|yrs?|y|months?|mons?|mo|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/g;
|
|
32
|
+
let total = 0;
|
|
33
|
+
let found = false;
|
|
34
|
+
for (const match of String(text).toLowerCase().matchAll(re)) {
|
|
35
|
+
const unit = match[2];
|
|
36
|
+
const seconds = AGE_UNITS.find(([pattern]) => pattern.test(unit))?.[1];
|
|
37
|
+
if (seconds === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
total += Number(match[1]) * seconds;
|
|
40
|
+
found = true;
|
|
41
|
+
}
|
|
42
|
+
return found ? total : null;
|
|
43
|
+
}
|
|
16
44
|
/**
|
|
17
45
|
* Parse `list-sessions --no-formatting` output.
|
|
18
46
|
*
|
|
@@ -21,9 +49,9 @@ export function resolveSelfPaneId(env = process.env) {
|
|
|
21
49
|
*/
|
|
22
50
|
export function parseSessionList(stdout) {
|
|
23
51
|
const sessions = [];
|
|
24
|
-
for (const
|
|
52
|
+
for (const entry of stdout.split(/\r?\n/)) {
|
|
25
53
|
// Drop ANSI in case a caller forgot --no-formatting.
|
|
26
|
-
const line =
|
|
54
|
+
const line = entry.replace(/\x1B\[[0-9;]*m/g, "").trim();
|
|
27
55
|
if (!line)
|
|
28
56
|
continue;
|
|
29
57
|
const created = line.indexOf(" [Created ");
|
|
@@ -32,15 +60,19 @@ export function parseSessionList(stdout) {
|
|
|
32
60
|
if (!name)
|
|
33
61
|
continue;
|
|
34
62
|
const suffix = line.slice(created);
|
|
63
|
+
const raw = (/\[Created ([^\]]*)\]/.exec(suffix)?.[1] ?? "").trim();
|
|
64
|
+
const age = raw ? raw.replace(/\s*ago\s*$/i, "").trim() || null : null;
|
|
35
65
|
sessions.push({
|
|
36
66
|
name,
|
|
37
67
|
exited: /\(EXITED\b/i.test(suffix),
|
|
38
68
|
current: /\(current\)/i.test(suffix),
|
|
69
|
+
age,
|
|
70
|
+
ageSeconds: raw ? parseCreatedAge(raw) : null,
|
|
39
71
|
});
|
|
40
72
|
continue;
|
|
41
73
|
}
|
|
42
74
|
// Bare name from --short or a mock.
|
|
43
|
-
sessions.push({ name: line, exited: false, current: false });
|
|
75
|
+
sessions.push({ name: line, exited: false, current: false, age: null, ageSeconds: null });
|
|
44
76
|
}
|
|
45
77
|
return sessions;
|
|
46
78
|
}
|