@songsid/agend 2.3.4-beta.3 → 2.3.4-beta.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.
- package/dist/backend/claude-code.js +1 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-session-lookup.d.ts +35 -0
- package/dist/backend/codex-session-lookup.js +93 -0
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +27 -0
- package/dist/backend/codex.js +116 -20
- package/dist/backend/codex.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +144 -44
- package/dist/daemon.js.map +1 -1
- package/dist/dialog-screen-text.d.ts +16 -0
- package/dist/dialog-screen-text.js +51 -0
- package/dist/dialog-screen-text.js.map +1 -0
- package/dist/fleet-manager.d.ts +4 -0
- package/dist/fleet-manager.js +46 -12
- package/dist/fleet-manager.js.map +1 -1
- package/dist/install-stream.d.ts +53 -0
- package/dist/install-stream.js +134 -0
- package/dist/install-stream.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +12 -0
- package/dist/instance-lifecycle.js +31 -2
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +26 -6
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +2 -0
- package/dist/log-service.js +7 -3
- package/dist/log-service.js.map +1 -1
- package/dist/login-manager.d.ts +6 -0
- package/dist/login-manager.js +10 -1
- package/dist/login-manager.js.map +1 -1
- package/dist/outbound-handlers.js +56 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/selection-card.d.ts +2 -3
- package/dist/selection-card.js +99 -26
- package/dist/selection-card.js.map +1 -1
- package/dist/tmux-manager.d.ts +6 -0
- package/dist/tmux-manager.js +17 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/ui/chat-store.js +20 -3
- package/dist/ui/fleet-canvas.js +4 -3
- package/dist/ui/fleet-office-model.js +55 -22
- package/dist/ui/fleet-office-overview.js +6 -38
- package/dist/ui/fleet-office-skin.js +12 -4
- package/dist/ui/fleet-office-store.js +5 -0
- package/dist/ui/fleet-office-zones.js +2 -2
- package/dist/ui/fleet-office.js +31 -1
- package/dist/ui/panel-log.js +7 -1
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-shell.js +4 -2
- package/dist/ui/shared/app-stream.js +26 -0
- package/dist/ui/shared/app.css +21 -1
- package/dist/ui/shared/panel-view.js +185 -10
- package/dist/ui/shared/view-strings.js +23 -0
- package/dist/unattended-install.d.ts +6 -0
- package/dist/unattended-install.js +65 -0
- package/dist/unattended-install.js.map +1 -0
- package/dist/view-api.d.ts +15 -0
- package/dist/view-api.js +94 -33
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.js +7 -2
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +20 -0
- package/dist/web-chat-history.js +21 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-guest-access.js +5 -1
- package/dist/web-guest-access.js.map +1 -1
- package/package.json +1 -1
|
@@ -26,7 +26,7 @@ import { viewStore } from "./view-roster-store.js";
|
|
|
26
26
|
/** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
|
|
27
27
|
const fleetUnreachable = () => appStore.get().connection === "reconnecting";
|
|
28
28
|
import { navigate } from "./app-nav.js";
|
|
29
|
-
import { viewPath } from "./app-route.js";
|
|
29
|
+
import { logPath, sectionPath, viewPath } from "./app-route.js";
|
|
30
30
|
import { Dialog } from "./ui-dialog.js";
|
|
31
31
|
import { Empty, ErrorState, Skeleton } from "./ui-states.js";
|
|
32
32
|
import { Icon } from "./ui-icons.js";
|
|
@@ -198,8 +198,8 @@ export function ViewPanel({ route, navKey }) {
|
|
|
198
198
|
else if (!it) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="alert" title=${tn("notFound", { name })} hint=${tn("notFoundHint")} /></div>`;
|
|
199
199
|
else if (guest && !trusted) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="view" title=${tn("guestNeedsTrusted")} hint=${tn("guestNeedsTrustedHint")} /></div>`;
|
|
200
200
|
else if (guest) body = html`<p data-trust="panel-content" class="v-watching" role="note">${tn("guestWatching")}</p>
|
|
201
|
-
<${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} />`;
|
|
202
|
-
else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} />
|
|
201
|
+
<${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} backend=${it.backend || ""} signedIn=${false} />`;
|
|
202
|
+
else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} backend=${it.backend || ""} signedIn=${!viewOnly} />
|
|
203
203
|
<${ViewIdentity} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
|
|
204
204
|
|
|
205
205
|
return html`<div data-effect-panel="view" class="panel p-view">
|
|
@@ -214,11 +214,73 @@ export function ViewPanel({ route, navKey }) {
|
|
|
214
214
|
|
|
215
215
|
/** #2855: how often a guest's View reads the pane (the server allows it two reads a second). */
|
|
216
216
|
const GUEST_PANE_MS = 1000;
|
|
217
|
-
/**
|
|
218
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The live terminal: the pane every 800 ms (passive), drawn as escaped markup, its font fitted to the pane's grid.
|
|
219
|
+
*
|
|
220
|
+
* M-2262 (the user, hmsg-435f6df6): scrolling back. Following (the default), it shows the pane's screen as it is now.
|
|
221
|
+
* Scrolling up — the wheel, a touch drag, PageUp/↑/Home, or "Earlier output" — reads the pane's scrollback once
|
|
222
|
+
* (`?history=1`: tmux's history above the screen, bounded) and shows it: reading. While reading, nothing moves the
|
|
223
|
+
* person: the refresh keeps reading the screen only to say "New output below", and never redraws. "Jump to latest"
|
|
224
|
+
* (or End), or scrolling to the bottom, follows again. Everything happens in this page, over text tmux was asked to
|
|
225
|
+
* capture: never copy-mode and never a key to the pane (hmsg-b8a170c0 — scrolling must never affect delivery).
|
|
226
|
+
*/
|
|
227
|
+
/** How long a scrollback note stays (it covers the terminal's bottom rows). */
|
|
228
|
+
const NOTE_MS = 6000;
|
|
229
|
+
/** A scroll this soon after the person's own wheel, touch, key or pointer is theirs (not one this page made). */
|
|
230
|
+
const GESTURE_MS = 1000;
|
|
231
|
+
/**
|
|
232
|
+
* #2969 review (Prism P3): is `live` (a screen read) still what `shown` ends with — the screen as it was when reading
|
|
233
|
+
* began (the scrollback's last lines, or the screen itself)? Trailing blank rows are not content.
|
|
234
|
+
*/
|
|
235
|
+
export function sameScreen(shown, live) {
|
|
236
|
+
if (shown == null || live == null) return shown === live;
|
|
237
|
+
const trim = (t) => String(t).replace(/\n+$/, "");
|
|
238
|
+
const s = trim(shown).split("\n"), l = trim(live).split("\n");
|
|
239
|
+
return s.length >= l.length && s.slice(s.length - l.length).join("\n") === l.join("\n");
|
|
240
|
+
}
|
|
241
|
+
/** M-2262 survey: CLIs that redraw their whole window (no tmux scrollback even outside the alternate screen). */
|
|
242
|
+
const REDRAWS_IN_PLACE = new Set(["grok"]);
|
|
243
|
+
/** M-2262 survey: the full-screen CLIs (the alternate screen, or redrawn in place) — tmux keeps nothing above their screen. */
|
|
244
|
+
const FULL_SCREEN = new Set(["claude-code", "opencode", "grok"]); // agy: its chat UI unverified — found by the read
|
|
245
|
+
/** The full-screen CLIs whose earlier output the Log tab shows (it reads their transcript: log-service.ts). */
|
|
246
|
+
const LOG_HAS_HISTORY = new Set(["claude-code"]);
|
|
247
|
+
/**
|
|
248
|
+
* M-2267 option (2), the leader's safe default: where a full-screen CLI's earlier output is — its Log tab (or that the
|
|
249
|
+
* Log tab is off, and where to turn it on). Only for whom the Log tab is (a signed-in session on the full app; never a
|
|
250
|
+
* guest or a View-only reader: they get the plain words), and only for a CLI the Log tab reads.
|
|
251
|
+
*/
|
|
252
|
+
function logLinkFor(name, backend, signedIn) {
|
|
253
|
+
if (!signedIn || !LOG_HAS_HISTORY.has(backend)) return null;
|
|
254
|
+
const on = typeof document !== "undefined" && document.body?.dataset?.logTab === "1";
|
|
255
|
+
return on ? { href: logPath(name), text: tn("earlierInLog") } : { href: sectionPath("web-dashboard"), text: tn("logTabOff") };
|
|
256
|
+
}
|
|
257
|
+
function Terminal({ name, lease, density, every = 800, backend = "", signedIn = false }) {
|
|
219
258
|
const term = useRef(null), pre = useRef(null);
|
|
220
259
|
const grid = useRef({ cols: 0, rows: 0, applied: 0, key: "" });
|
|
221
260
|
const [problem, setProblem] = useState(null);
|
|
261
|
+
// "follow" | "loading" | "reading"; the screen's text when reading began, the newest read since; a note about history.
|
|
262
|
+
const [mode, setModeState] = useState("follow");
|
|
263
|
+
const modeRef = useRef("follow");
|
|
264
|
+
const setMode = (m) => { modeRef.current = m; setModeState(m); };
|
|
265
|
+
const [newBelow, setNewBelow] = useState(false);
|
|
266
|
+
const [historyNote, setHistoryNoteState] = useState(null);
|
|
267
|
+
// A CLI found to keep no scrollback (full screen): nothing is offered or read again for it; the note says why, briefly.
|
|
268
|
+
const [noScrollback, setNoScrollback] = useState(FULL_SCREEN.has(backend));
|
|
269
|
+
const noScrollbackRef = useRef(FULL_SCREEN.has(backend));
|
|
270
|
+
const logLink = logLinkFor(name, backend, signedIn);
|
|
271
|
+
const noteTimer = useRef(null);
|
|
272
|
+
const setHistoryNote = (note, brief = false) => {
|
|
273
|
+
lease.clear(noteTimer.current); noteTimer.current = null;
|
|
274
|
+
setHistoryNoteState(note);
|
|
275
|
+
if (note && brief) noteTimer.current = lease.timeout(() => setHistoryNoteState(null), NOTE_MS);
|
|
276
|
+
};
|
|
277
|
+
// `reading`: the text shown while reading (the scrollback, or the screen as it was when the person left the bottom);
|
|
278
|
+
// `latest`: the newest screen read; `history`: whether what is shown has the scrollback in it.
|
|
279
|
+
// `drawn` (#2969 review P3): the text actually on the page now — what reading starts from, never a newer read not drawn.
|
|
280
|
+
const screenAt = useRef({ reading: null, latest: null, history: false, drawn: null });
|
|
281
|
+
// #2969 review (Prism P2): when the person last moved the terminal themselves (wheel, touch, key, pointer).
|
|
282
|
+
const gestureAt = useRef(-Infinity);
|
|
283
|
+
const recentGesture = () => performance.now() - gestureAt.current < GESTURE_MS;
|
|
222
284
|
// #1674: the text size as of this render. The pane refresh and the resize handlers below are set up once per mount
|
|
223
285
|
// and keep the fit() of that render; reading the size here, not from that render's props, keeps them on the current
|
|
224
286
|
// choice (they used to put the mount-time size back within one 800 ms refresh).
|
|
@@ -245,7 +307,27 @@ function Terminal({ name, lease, density, every = 800 }) {
|
|
|
245
307
|
g.applied = size;
|
|
246
308
|
p.style.fontSize = `${size.toFixed(2)}px`; // CSSOM, never a style attribute (#1300)
|
|
247
309
|
};
|
|
310
|
+
/** Keep a following view on the newest line (a grid taller than the box at the larger sizes scrolls). */
|
|
311
|
+
const toBottom = () => { const box = term.current; if (box) box.scrollTop = box.scrollHeight; };
|
|
312
|
+
const draw = (text) => {
|
|
313
|
+
pre.current.innerHTML = ansiToHtml(text); // escaped by construction: markup only from ansiToHtml's own spans
|
|
314
|
+
paintAnsi(pre.current);
|
|
315
|
+
screenAt.current.drawn = text;
|
|
316
|
+
};
|
|
248
317
|
useEffect(() => { grid.current.key = ""; fit(); }, [density]);
|
|
318
|
+
const atBottom = () => { const box = term.current; return !box || box.scrollHeight - box.scrollTop - box.clientHeight <= 2; };
|
|
319
|
+
/** Reading what is shown now (`text`, with or without the scrollback): the refresh only says whether there is more. */
|
|
320
|
+
const startReading = (text, history) => {
|
|
321
|
+
screenAt.current.reading = text; screenAt.current.history = history;
|
|
322
|
+
setNewBelow(false); setMode("reading");
|
|
323
|
+
};
|
|
324
|
+
const grid1 = (r) => {
|
|
325
|
+
const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
|
|
326
|
+
if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
|
|
327
|
+
};
|
|
328
|
+
// The person asked for what scrolled off: one bounded read, then reading (or why there is nothing to read).
|
|
329
|
+
const loadEarlier = useRef(null);
|
|
330
|
+
const follow = useRef(null);
|
|
249
331
|
useEffect(() => {
|
|
250
332
|
const reads = readStream(lease);
|
|
251
333
|
const refresh = async () => {
|
|
@@ -263,15 +345,72 @@ function Terminal({ name, lease, density, every = 800 }) {
|
|
|
263
345
|
if (!r.ok) { setProblem(r.status === 404 ? tn("paneUnavailable") : r.status === 403 && guestPage() ? tn("guestNeedsTrusted") : tn("paneFailed", { name, status: r.status })); return; }
|
|
264
346
|
const text = await r.text();
|
|
265
347
|
if (!reads.live(token) || !pre.current) return;
|
|
266
|
-
const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
|
|
267
|
-
if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
|
|
268
348
|
setProblem(null);
|
|
269
|
-
|
|
270
|
-
|
|
349
|
+
screenAt.current.latest = text;
|
|
350
|
+
// Reading earlier output: nothing is redrawn under the person — the read only says whether there is more.
|
|
351
|
+
if (modeRef.current !== "follow") { if (modeRef.current === "reading") setNewBelow(!sameScreen(screenAt.current.reading, text)); return; }
|
|
352
|
+
// #2969 review (Prism P2): the person is moving a screen taller than its box away from the bottom right now —
|
|
353
|
+
// this is them leaving it, not the latest to follow: read on, nothing redrawn.
|
|
354
|
+
// Reading starts from what is drawn; this newer read (not drawn) is then the new output below.
|
|
355
|
+
if (recentGesture() && !atBottom()) { startReading(screenAt.current.drawn, false); setNewBelow(!sameScreen(screenAt.current.drawn, text)); return; }
|
|
356
|
+
grid1(r);
|
|
357
|
+
draw(text);
|
|
271
358
|
fit();
|
|
359
|
+
toBottom();
|
|
272
360
|
} catch { /* a transient error keeps the last frame */ }
|
|
273
361
|
finally { reads.end(token); }
|
|
274
362
|
};
|
|
363
|
+
loadEarlier.current = async () => {
|
|
364
|
+
// From following, or from reading what was on the screen (the person reached its top): never twice, never over it.
|
|
365
|
+
const from = modeRef.current;
|
|
366
|
+
if (from !== "follow" && !(from === "reading" && !screenAt.current.history)) return;
|
|
367
|
+
if (noScrollbackRef.current) { setHistoryNote(tn("noEarlier"), true); return; } // known: nothing to read
|
|
368
|
+
const box0 = term.current;
|
|
369
|
+
// Where the person is, measured from the end: the screen's lines stay where they are under the scrollback added.
|
|
370
|
+
const fromEnd = box0 ? box0.scrollHeight - box0.scrollTop : 0;
|
|
371
|
+
const back = () => { setMode(from); if (from === "follow") toBottom(); };
|
|
372
|
+
setMode("loading"); setHistoryNote(tn("loadingEarlier"));
|
|
373
|
+
try {
|
|
374
|
+
const r = await lease.fetch(`/api/pane/${encodeURIComponent(name)}?history=1`);
|
|
375
|
+
if (!lease.current() || modeRef.current !== "loading") return;
|
|
376
|
+
// #2969 review (Prism P2): a scrollback read that failed — or answered without its facts — changes nothing shown.
|
|
377
|
+
if (!r.ok || r.headers.get("X-Pane-History-Lines") == null) { back(); setHistoryNote(r.status === 429 ? null : tn("earlierFailed"), true); return; }
|
|
378
|
+
const text = await r.text();
|
|
379
|
+
if (!lease.current() || modeRef.current !== "loading" || !pre.current) return;
|
|
380
|
+
// A full-screen CLI (the alternate screen, or one that redraws its whole window) leaves nothing above its screen;
|
|
381
|
+
// any other with no history yet has simply not scrolled: say which, keep following.
|
|
382
|
+
if (r.headers.get("X-Pane-Alternate") === "1" || REDRAWS_IN_PLACE.has(backend)) {
|
|
383
|
+
noScrollbackRef.current = true; setNoScrollback(true);
|
|
384
|
+
back(); setHistoryNote(tn("noEarlier"), true); return;
|
|
385
|
+
}
|
|
386
|
+
if (r.headers.get("X-Pane-History-Lines") === "0") { back(); setHistoryNote(tn("nothingEarlier"), true); return; }
|
|
387
|
+
grid1(r);
|
|
388
|
+
const lines = text.split("\n").length, rows = grid.current.rows || 0;
|
|
389
|
+
// #2969 review (Prism P3): new output is judged against what is shown — the scrollback's own last screen — and
|
|
390
|
+
// a screen read while this one was out already counts.
|
|
391
|
+
screenAt.current.reading = text; screenAt.current.history = true;
|
|
392
|
+
setNewBelow(screenAt.current.latest != null && !sameScreen(text, screenAt.current.latest));
|
|
393
|
+
setMode("reading"); setHistoryNote(null);
|
|
394
|
+
draw(text);
|
|
395
|
+
fit();
|
|
396
|
+
const box = term.current;
|
|
397
|
+
if (box) {
|
|
398
|
+
const lineH = lines > 0 ? pre.current.scrollHeight / lines : 16;
|
|
399
|
+
// From following (at the bottom): a few lines up — the earlier output right above. From reading: the same lines.
|
|
400
|
+
box.scrollTop = from === "follow"
|
|
401
|
+
? Math.max(0, box.scrollHeight - box.clientHeight - lineH * Math.min(3, Math.max(1, lines - rows)))
|
|
402
|
+
: Math.max(0, box.scrollHeight - fromEnd);
|
|
403
|
+
}
|
|
404
|
+
} catch { if (lease.current()) { back(); setHistoryNote(tn("earlierFailed"), true); } }
|
|
405
|
+
};
|
|
406
|
+
follow.current = () => {
|
|
407
|
+
if (modeRef.current === "follow") return;
|
|
408
|
+
setMode("follow"); setNewBelow(false); setHistoryNote(null); screenAt.current.history = false;
|
|
409
|
+
const latest = screenAt.current.latest;
|
|
410
|
+
if (latest != null && pre.current) { draw(latest); fit(); }
|
|
411
|
+
toBottom();
|
|
412
|
+
refresh();
|
|
413
|
+
};
|
|
275
414
|
refresh();
|
|
276
415
|
lease.interval(refresh, every);
|
|
277
416
|
let timer = null;
|
|
@@ -282,8 +421,44 @@ function Terminal({ name, lease, density, every = 800 }) {
|
|
|
282
421
|
ro.observe(term.current);
|
|
283
422
|
lease.hold(() => ro.disconnect());
|
|
284
423
|
}
|
|
424
|
+
// Scrolling up asks for the earlier output; reaching the bottom while reading follows again.
|
|
425
|
+
const box = term.current;
|
|
426
|
+
if (box) {
|
|
427
|
+
// Asking for the earlier output: from following, or from reading the screen as it was — at the top.
|
|
428
|
+
const mayLoad = () => (modeRef.current === "follow" || (modeRef.current === "reading" && !screenAt.current.history)) && box.scrollTop <= 0;
|
|
429
|
+
const gesture = () => { gestureAt.current = performance.now(); };
|
|
430
|
+
lease.on(box, "wheel", (e) => { gesture(); if (e.deltaY < 0 && mayLoad()) loadEarlier.current(); }, { passive: true });
|
|
431
|
+
let touchY = null;
|
|
432
|
+
lease.on(box, "touchstart", (e) => { gesture(); touchY = e.touches && e.touches[0] ? e.touches[0].clientY : null; }, { passive: true });
|
|
433
|
+
lease.on(box, "touchmove", (e) => {
|
|
434
|
+
gesture();
|
|
435
|
+
const y = e.touches && e.touches[0] ? e.touches[0].clientY : null;
|
|
436
|
+
if (touchY == null || y == null) return;
|
|
437
|
+
if (y - touchY > 24 && mayLoad()) { touchY = null; loadEarlier.current(); }
|
|
438
|
+
}, { passive: true });
|
|
439
|
+
lease.on(box, "pointerdown", gesture, { passive: true }); // the scrollbar, dragged
|
|
440
|
+
lease.on(box, "keydown", (e) => {
|
|
441
|
+
gesture();
|
|
442
|
+
if ((e.key === "PageUp" || e.key === "ArrowUp" || e.key === "Home") && mayLoad()) { e.preventDefault(); loadEarlier.current(); }
|
|
443
|
+
else if (modeRef.current === "reading" && e.key === "End") { e.preventDefault(); follow.current(); }
|
|
444
|
+
});
|
|
445
|
+
lease.on(box, "scroll", () => {
|
|
446
|
+
if (modeRef.current === "reading") { if (atBottom()) follow.current(); return; }
|
|
447
|
+
// #2969 review (Prism P2): a screen taller than its box (a larger Aa), moved up from the bottom by the person:
|
|
448
|
+
// reading what is on it — the refresh no longer pulls them back down.
|
|
449
|
+
if (modeRef.current === "follow" && recentGesture() && !atBottom()) startReading(screenAt.current.drawn, false);
|
|
450
|
+
}, { passive: true });
|
|
451
|
+
}
|
|
285
452
|
}, [lease, name]);
|
|
286
|
-
|
|
453
|
+
const reading = mode === "reading";
|
|
454
|
+
// A full-screen CLI's Log link is a bar above the terminal, not over it: its first row is never covered.
|
|
455
|
+
return html`<div class="v-term-box">
|
|
456
|
+
${noScrollback && logLink ? html`<p class="v-term-logbar"><a class="v-term-log" href=${logLink.href}>${logLink.text}<${Icon} name="forward" size=${14} /></a></p>` : null}
|
|
457
|
+
<div data-trust="panel-content" class=${`v-term${reading ? " is-reading" : ""}`} ref=${term} tabindex="0" aria-label=${tn(reading ? "readingEarlier" : "earlierHint")}>${problem ? html`<p class="v-term-note" role="status">${problem}</p>` : null}<pre ref=${pre} class="v-pre"></pre></div>
|
|
458
|
+
${historyNote ? html`<p class="v-term-history-note" role="status">${historyNote}</p>` : null}
|
|
459
|
+
${mode === "follow" && !noScrollback ? html`<button type="button" class="v-term-earlier btn btn-sm" title=${tn("earlierHint")} onClick=${() => loadEarlier.current && loadEarlier.current()}><${Icon} name="up" size=${14} />${tn("earlier")}</button>` : null}
|
|
460
|
+
${reading ? html`<button type="button" class=${`v-term-latest btn btn-sm${newBelow ? " has-new" : ""}`} onClick=${() => follow.current && follow.current()}>${newBelow ? html`<span class="v-term-new">${tn("newBelow")} · </span>` : null}${tn("jumpLatest")}<${Icon} name="down" size=${14} /></button>` : null}
|
|
461
|
+
</div>`;
|
|
287
462
|
}
|
|
288
463
|
|
|
289
464
|
// The advance of one monospace cell as a fraction of font-size, measured once on this page.
|
|
@@ -215,6 +215,18 @@ register("view", {
|
|
|
215
215
|
"guestNeedsTrustedHint": "Your host gave you chat access to this agent. Ask them to make you Trusted in Settings → Guests.",
|
|
216
216
|
"noInstances": "No instances yet",
|
|
217
217
|
"paneUnavailable": "This instance's terminal is not available (is it stopped?)",
|
|
218
|
+
// M-2262: the terminal's scrollback.
|
|
219
|
+
"earlier": "Earlier output",
|
|
220
|
+
"earlierHint": "Scroll up to read what has scrolled off. Live output keeps coming in below.",
|
|
221
|
+
"loadingEarlier": "Loading earlier output…",
|
|
222
|
+
"earlierFailed": "Could not load earlier output.",
|
|
223
|
+
"noEarlier": "This CLI redraws its whole screen, so the terminal keeps no earlier output to scroll back to.",
|
|
224
|
+
"nothingEarlier": "Nothing has scrolled off the screen yet.",
|
|
225
|
+
"earlierInLog": "Earlier output is in the Log tab",
|
|
226
|
+
"logTabOff": "Earlier output: the Log tab is off. Turn it on in Settings",
|
|
227
|
+
"readingEarlier": "Reading earlier output. Live output continues; scroll to the bottom to follow it again.",
|
|
228
|
+
"jumpLatest": "Jump to latest",
|
|
229
|
+
"newBelow": "New output below",
|
|
218
230
|
"rosterFailedShort": "The instance list could not be loaded.",
|
|
219
231
|
"collapseGroup": "Fold {group}",
|
|
220
232
|
"expandGroup": "Open {group}",
|
|
@@ -461,6 +473,17 @@ register("view", {
|
|
|
461
473
|
"guestNeedsTrustedHint": "主人給你的是這個 agent 的聊天權限。請他在設定 → 訪客把你改成「信任」。",
|
|
462
474
|
"noInstances": "還沒有 instance",
|
|
463
475
|
"paneUnavailable": "無法取得這個 instance 的終端畫面(它是否已停止?)",
|
|
476
|
+
"earlier": "較早的輸出",
|
|
477
|
+
"earlierHint": "往上捲動可以看到已經捲走的內容,新的輸出會繼續出現在下方。",
|
|
478
|
+
"loadingEarlier": "正在載入較早的輸出…",
|
|
479
|
+
"earlierFailed": "無法載入較早的輸出。",
|
|
480
|
+
"noEarlier": "這個 CLI 會重繪整個畫面,終端沒有保留可以往上捲動的較早輸出。",
|
|
481
|
+
"nothingEarlier": "還沒有內容捲出畫面。",
|
|
482
|
+
"earlierInLog": "較早的輸出在「紀錄」分頁",
|
|
483
|
+
"logTabOff": "較早的輸出:「紀錄」分頁目前關閉,請到設定開啟",
|
|
484
|
+
"readingEarlier": "正在看較早的輸出。新的輸出仍會繼續;捲到最下面即可再次跟著最新內容。",
|
|
485
|
+
"jumpLatest": "跳到最新",
|
|
486
|
+
"newBelow": "下方有新的輸出",
|
|
464
487
|
"rosterFailedShort": "無法載入 instance 清單。",
|
|
465
488
|
"collapseGroup": "收合 {group}",
|
|
466
489
|
"expandGroup": "展開 {group}",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Runs `$1` detached from any terminal, at low priority; HUP/INT/TERM on this shell reach its whole process group. */
|
|
2
|
+
export declare const UNATTENDED_INSTALL_SCRIPT: string;
|
|
3
|
+
/** The account's login shell, when it is an absolute path to an executable; else undefined (keep the inherited one). */
|
|
4
|
+
export declare function loginShell(read?: () => string | null | undefined): string | undefined;
|
|
5
|
+
/** The window's command for an install command. `shell`: the $SHELL to give it; null keeps the inherited one. */
|
|
6
|
+
export declare function unattendedInstallCommand(command: string, shell?: string | null): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M-2281 (#2980): how `/login` runs a CLI's installer in its window — nobody types there, so nothing may wait for
|
|
3
|
+
* an answer, and the install must not starve the fleet that is still serving every chat.
|
|
4
|
+
*
|
|
5
|
+
* - **No terminal to ask on.** `setsid -w` gives the install a session of its own with no controlling terminal, so
|
|
6
|
+
* `/dev/tty` cannot be opened, and stdin is `/dev/null`. A prompt that asks there (codex's "Start Codex now?",
|
|
7
|
+
* kiro's "replace it?", sudo's password) fails at once instead of waiting out the session timeout. Without
|
|
8
|
+
* `setsid` (macOS has none), stdin is still `/dev/null`. The same wrapper install-agend.ps1 uses (#2583).
|
|
9
|
+
* - **Low priority.** `nice -n 10`, plus `ionice -c2 -n7` where it exists and is permitted: a ~0.5 GB download and
|
|
10
|
+
* unpack (codex 0.166) yields CPU and disk to the fleet and its CLIs.
|
|
11
|
+
* - **Stoppable.** The install's own session is not the window's, so closing the window (cancel, timeout, fleet
|
|
12
|
+
* stop) would not reach it: the window's shell forwards HUP/INT/TERM to the install's whole process group.
|
|
13
|
+
* - **The user's shell.** Installers pick the rc file for their PATH line from `$SHELL`; the window inherits the
|
|
14
|
+
* tmux server's environment, where it may be missing (a service). The account's login shell is passed, so the
|
|
15
|
+
* line lands in the file the user's own terminals read.
|
|
16
|
+
*
|
|
17
|
+
* - **Its verdict is kept.** In a tmux pane it starts once remain-on-exit is on (see the script): an install that ends
|
|
18
|
+
* at once still leaves a dead pane with its exit code and output for the session to judge.
|
|
19
|
+
*
|
|
20
|
+
* The exit code is the install's own: the session judges success by it.
|
|
21
|
+
*/
|
|
22
|
+
import { accessSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { userInfo } from "node:os";
|
|
24
|
+
import { isAbsolute } from "node:path";
|
|
25
|
+
import { shellQuote } from "./backend/types.js";
|
|
26
|
+
/** Runs `$1` detached from any terminal, at low priority; HUP/INT/TERM on this shell reach its whole process group. */
|
|
27
|
+
export const UNATTENDED_INSTALL_SCRIPT = [
|
|
28
|
+
// In a tmux pane, start only once AgEnD has set remain-on-exit on the window (at most ~10 s): an install that fails
|
|
29
|
+
// at once — a prompt with no terminal does — would otherwise close the window before it is set, taking its exit code
|
|
30
|
+
// and output with it. Outside tmux ($TMUX_PANE unset) there is nothing to wait for.
|
|
31
|
+
'if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then i=0; while [ "$(tmux show-options -wqv -t "$TMUX_PANE" remain-on-exit 2>/dev/null)" != on ] && [ $i -lt 100 ]; do sleep 0.1; i=$((i+1)); done; fi',
|
|
32
|
+
'P=""',
|
|
33
|
+
'if command -v ionice >/dev/null 2>&1 && ionice -c2 -n7 true >/dev/null 2>&1; then P="ionice -c2 -n7"; fi',
|
|
34
|
+
'if command -v setsid >/dev/null 2>&1; then setsid -w nice -n 10 $P sh -c "$1" </dev/null & else nice -n 10 $P sh -c "$1" </dev/null & fi',
|
|
35
|
+
"pid=$!",
|
|
36
|
+
// `kill -TERM -$pid`, not `kill -- -$pid`: dash's kill refuses `--` ("Illegal number"). Before setsid has run there is
|
|
37
|
+
// no such group yet, and the plain pid is what exists.
|
|
38
|
+
"trap 'kill -TERM -$pid 2>/dev/null || kill -TERM $pid 2>/dev/null; exit 129' HUP INT TERM",
|
|
39
|
+
'wait "$pid"',
|
|
40
|
+
].join("\n");
|
|
41
|
+
/** The account's login shell, when it is an absolute path to an executable; else undefined (keep the inherited one). */
|
|
42
|
+
export function loginShell(read = () => userInfo().shell) {
|
|
43
|
+
let shell;
|
|
44
|
+
try {
|
|
45
|
+
shell = read();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!shell || !isAbsolute(shell))
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
accessSync(shell, fsConstants.X_OK);
|
|
54
|
+
return shell;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** The window's command for an install command. `shell`: the $SHELL to give it; null keeps the inherited one. */
|
|
61
|
+
export function unattendedInstallCommand(command, shell = loginShell() ?? null) {
|
|
62
|
+
const env = shell ? `SHELL=${shellQuote(shell)} ` : "";
|
|
63
|
+
return `${env}sh -c ${shellQuote(UNATTENDED_INSTALL_SCRIPT)} agend-install ${shellQuote(command)}`;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=unattended-install.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unattended-install.js","sourceRoot":"","sources":["../src/unattended-install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,UAAU,EAAE,SAAS,IAAI,WAAW,EAAE,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD,uHAAuH;AACvH,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,oHAAoH;IACpH,qHAAqH;IACrH,oFAAoF;IACpF,uNAAuN;IACvN,MAAM;IACN,0GAA0G;IAC1G,0IAA0I;IAC1I,QAAQ;IACR,uHAAuH;IACvH,uDAAuD;IACvD,2FAA2F;IAC3F,aAAa;CACd,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,wHAAwH;AACxH,MAAM,UAAU,UAAU,CAAC,IAAI,GAAoC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK;IACvF,IAAI,KAAgC,CAAC;IACrC,IAAI,CAAC;QAAC,KAAK,GAAG,IAAI,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,SAAS,CAAC;IAAC,CAAC;IACnD,IAAI,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACnD,IAAI,CAAC;QAAC,UAAU,CAAC,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,KAAK,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,SAAS,CAAC;IAAC,CAAC;AACxF,CAAC;AAED,iHAAiH;AACjH,MAAM,UAAU,wBAAwB,CAAC,OAAe,EAAE,KAAK,GAAkB,UAAU,EAAE,IAAI,IAAI;IACnG,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACvD,OAAO,GAAG,GAAG,SAAS,UAAU,CAAC,yBAAyB,CAAC,kBAAkB,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;AACrG,CAAC"}
|
package/dist/view-api.d.ts
CHANGED
|
@@ -137,11 +137,26 @@ export declare function cardPicture(ctx: {
|
|
|
137
137
|
export declare function humanCards(dataDir: string): ReturnType<typeof HumanCardStore.all>;
|
|
138
138
|
/** For tests: forget the guests' read counts and shared captures. */
|
|
139
139
|
export declare function resetGuestPanes(): void;
|
|
140
|
+
/**
|
|
141
|
+
* M-2262 (the user, hmsg-435f6df6): the View's scrollback — the pane's history above its screen, read only when the
|
|
142
|
+
* person scrolls up (never on the 800 ms refresh), at most `lines` lines (tmux's own default history-limit) and at most
|
|
143
|
+
* `maxBytes` (its end, from a whole line). A viewer's tabs asking at once share one read.
|
|
144
|
+
*/
|
|
145
|
+
export declare const VIEW_HISTORY: {
|
|
146
|
+
readonly lines: 2000;
|
|
147
|
+
readonly maxBytes: number;
|
|
148
|
+
};
|
|
140
149
|
/** A pane's logical grid size in cells, as tmux sees it. */
|
|
141
150
|
export interface PaneSize {
|
|
142
151
|
cols: number;
|
|
143
152
|
rows: number;
|
|
144
153
|
}
|
|
154
|
+
/** M-2262: whether the pane shows its alternate screen, and how many lines tmux keeps above it. */
|
|
155
|
+
export interface PaneScrollback {
|
|
156
|
+
alternate: boolean;
|
|
157
|
+
lines: number;
|
|
158
|
+
}
|
|
159
|
+
export declare function parsePaneScrollback(alternate: string | undefined, lines: string | undefined): PaneScrollback | null;
|
|
145
160
|
/** Parse `display-message`'s `<cols>x<rows>` output. Returns null if unusable. */
|
|
146
161
|
export declare function parsePaneSize(out: string): PaneSize | null;
|
|
147
162
|
/** True if the path belongs to the view feature (so the caller can skip the
|
package/dist/view-api.js
CHANGED
|
@@ -7,7 +7,8 @@ import { gatewayRequestContext } from "./web-request-context.js";
|
|
|
7
7
|
*
|
|
8
8
|
* GET /view → static page
|
|
9
9
|
* GET /api/pane/:instance → `tmux capture-pane -ep` output (ANSI text),
|
|
10
|
-
* plus X-Pane-Cols / X-Pane-Rows response headers
|
|
10
|
+
* plus X-Pane-Cols / X-Pane-Rows response headers;
|
|
11
|
+
* `?history=1`: the scrollback above it too, on demand (VIEW_HISTORY)
|
|
11
12
|
* GET /api/profiles → merged roster (live status + config + profile)
|
|
12
13
|
* GET /api/profile/:instance → one profile row
|
|
13
14
|
* POST /api/profile/:instance → upsert profile (signed in)
|
|
@@ -241,7 +242,7 @@ const guestPaneAgents = new Map(); // account → agent → when it was last rea
|
|
|
241
242
|
const sharedCaptures = new Map();
|
|
242
243
|
/** For tests: forget the guests' read counts and shared captures. */
|
|
243
244
|
export function resetGuestPanes() { guestPaneReads = paneReadLimiter(); guestPaneAgents.clear(); sharedCaptures.clear(); }
|
|
244
|
-
function guestPane(ctx, name, guest, res) {
|
|
245
|
+
function guestPane(ctx, name, guest, res, history = false) {
|
|
245
246
|
if (!guest.trusted?.()) {
|
|
246
247
|
json(res, 403, { error: "guest_not_trusted" });
|
|
247
248
|
return;
|
|
@@ -264,6 +265,11 @@ function guestPane(ctx, name, guest, res) {
|
|
|
264
265
|
guestPaneReads.record(account);
|
|
265
266
|
agents.set(name, now);
|
|
266
267
|
guestPaneAgents.set(account, agents);
|
|
268
|
+
// The scrollback (a person scrolled up): one read within the same limits, at most the same bytes (its end).
|
|
269
|
+
if (history) {
|
|
270
|
+
historyCapture(ctx, name).then((c) => sendHistory(res, c, GUEST_PANE_MAX_BYTES, { "Cache-Control": "private, no-store" }), (err) => historyFailed(ctx, res, name, err));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
267
273
|
let shared = sharedCaptures.get(name);
|
|
268
274
|
if (!shared || now - shared.at >= GUEST_PANE_SHARE_MS) {
|
|
269
275
|
shared = { at: now, capture: capturePane(ctx, name) };
|
|
@@ -271,19 +277,68 @@ function guestPane(ctx, name, guest, res) {
|
|
|
271
277
|
shared.capture.catch(() => { if (sharedCaptures.get(name) === shared)
|
|
272
278
|
sharedCaptures.delete(name); });
|
|
273
279
|
}
|
|
274
|
-
shared.capture.then(({
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
280
|
+
shared.capture.then((c) => sendPane(res, c, GUEST_PANE_MAX_BYTES, { "Cache-Control": "private, no-store" }), (err) => paneFailed(ctx, res, name, err));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* M-2262 (the user, hmsg-435f6df6): the View's scrollback — the pane's history above its screen, read only when the
|
|
284
|
+
* person scrolls up (never on the 800 ms refresh), at most `lines` lines (tmux's own default history-limit) and at most
|
|
285
|
+
* `maxBytes` (its end, from a whole line). A viewer's tabs asking at once share one read.
|
|
286
|
+
*/
|
|
287
|
+
export const VIEW_HISTORY = { lines: 2000, maxBytes: 1024 * 1024 };
|
|
288
|
+
const historyCaptures = new Map();
|
|
289
|
+
function historyCapture(ctx, name) {
|
|
290
|
+
let capture = historyCaptures.get(name);
|
|
291
|
+
if (!capture) {
|
|
292
|
+
capture = capturePane(ctx, name, VIEW_HISTORY.lines);
|
|
293
|
+
historyCaptures.set(name, capture);
|
|
294
|
+
const done = () => { if (historyCaptures.get(name) === capture)
|
|
295
|
+
historyCaptures.delete(name); };
|
|
296
|
+
capture.then(done, done);
|
|
297
|
+
}
|
|
298
|
+
return capture;
|
|
299
|
+
}
|
|
300
|
+
/** A capture as the answer: the pane's grid in X-Pane-Cols / X-Pane-Rows; over `maxBytes`, its end from a whole line. */
|
|
301
|
+
function sendPane(res, { text, size, scrollback }, maxBytes, extra = {}) {
|
|
302
|
+
const headers = { "Content-Type": "text/plain; charset=utf-8", ...extra };
|
|
303
|
+
if (size) {
|
|
304
|
+
headers["X-Pane-Cols"] = String(size.cols);
|
|
305
|
+
headers["X-Pane-Rows"] = String(size.rows);
|
|
306
|
+
}
|
|
307
|
+
// M-2262: the scrollback's own facts (the page says when a CLI keeps none).
|
|
308
|
+
if (scrollback) {
|
|
309
|
+
headers["X-Pane-Alternate"] = scrollback.alternate ? "1" : "0";
|
|
310
|
+
headers["X-Pane-History-Lines"] = String(scrollback.lines);
|
|
311
|
+
}
|
|
312
|
+
const bytes = Buffer.from(text, "utf8");
|
|
313
|
+
res.writeHead(200, headers);
|
|
314
|
+
// Over the cap: its end, from the first whole line in it (never mid-character or mid-escape sequence).
|
|
315
|
+
if (bytes.length <= maxBytes) {
|
|
316
|
+
res.end(text);
|
|
317
|
+
return;
|
|
318
|
+
} // whole: the text, as before
|
|
319
|
+
const tail = bytes.subarray(bytes.length - maxBytes);
|
|
320
|
+
const line = tail.indexOf(0x0a);
|
|
321
|
+
res.end(line >= 0 ? tail.subarray(line + 1) : tail);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* #2969 review (Prism P2): the scrollback says so when it is not there — never the plain read's empty 200, which the
|
|
325
|
+
* page would draw as the earlier output (blanking the screen it had). No pane yet, or no scrollback facts: 503.
|
|
326
|
+
*/
|
|
327
|
+
function sendHistory(res, c, maxBytes, extra = {}) {
|
|
328
|
+
if (!c.scrollback) {
|
|
329
|
+
json(res, 503, { error: "scrollback_unavailable" });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
sendPane(res, c, maxBytes, extra);
|
|
333
|
+
}
|
|
334
|
+
function historyFailed(ctx, res, name, err) {
|
|
335
|
+
ctx.logger.debug({ err, name }, "capture-pane (scrollback) failed");
|
|
336
|
+
json(res, 503, { error: "scrollback_unavailable" });
|
|
337
|
+
}
|
|
338
|
+
function paneFailed(ctx, res, name, err) {
|
|
339
|
+
ctx.logger.debug({ err, name }, "capture-pane failed");
|
|
340
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
341
|
+
res.end("");
|
|
287
342
|
}
|
|
288
343
|
/**
|
|
289
344
|
* Capture a pane's contents (with ANSI escapes) **and** its logical grid size.
|
|
@@ -293,22 +348,33 @@ function guestPane(ctx, name, guest, res) {
|
|
|
293
348
|
* sparse frame (a freshly-started CLI printing three short lines) would
|
|
294
349
|
* otherwise fit to ~3 columns and blow the font up to the 48px ceiling.
|
|
295
350
|
*/
|
|
296
|
-
async function capturePane(ctx, name) {
|
|
351
|
+
async function capturePane(ctx, name, historyLines = 0) {
|
|
297
352
|
const target = paneTarget(ctx, name);
|
|
298
353
|
if (!target)
|
|
299
354
|
return { text: "", size: null };
|
|
355
|
+
// M-2262: with the scrollback, whether the CLI keeps any (a full-screen one — the alternate screen, or one that redraws
|
|
356
|
+
// the whole window — leaves tmux nothing above the screen), from the same one display-message.
|
|
357
|
+
const format = historyLines > 0 ? "#{pane_width}x#{pane_height} #{alternate_on} #{history_size}" : "#{pane_width}x#{pane_height}";
|
|
358
|
+
// M-2262: with `historyLines`, from that many lines above the screen (tmux stops at what it kept) to the screen's end.
|
|
359
|
+
const capture = ["capture-pane", "-p", "-e", ...(historyLines > 0 ? ["-S", `-${Math.floor(historyLines)}`] : []), "-t", target];
|
|
300
360
|
const [text, size] = await Promise.all([
|
|
301
361
|
// #1956: through the fleet's spawn helper when it runs (a viewer polls this every 800 ms).
|
|
302
|
-
tmuxExecHelper.exec("tmux", tmuxArgs(
|
|
362
|
+
tmuxExecHelper.exec("tmux", tmuxArgs(capture), { maxBuffer: 8 * 1024 * 1024 }, () => execFileP("tmux", tmuxArgs(capture), { maxBuffer: 8 * 1024 * 1024 }))
|
|
303
363
|
.then(r => r.stdout),
|
|
304
364
|
// display-message resolves the window's *active* pane, matching capture-pane.
|
|
305
|
-
tmuxExecHelper.exec("tmux", tmuxArgs(["display-message", "-p", "-t", target,
|
|
306
|
-
.then(r =>
|
|
365
|
+
tmuxExecHelper.exec("tmux", tmuxArgs(["display-message", "-p", "-t", target, format]), {}, () => execFileP("tmux", tmuxArgs(["display-message", "-p", "-t", target, format])))
|
|
366
|
+
.then(r => r.stdout)
|
|
307
367
|
// A missing size must not fail the whole capture — the client falls back
|
|
308
368
|
// to its last known size (or skips refitting) when the headers are absent.
|
|
309
|
-
.catch(() =>
|
|
369
|
+
.catch(() => ""),
|
|
310
370
|
]);
|
|
311
|
-
|
|
371
|
+
const [grid = "", alternate, kept] = size.trim().split(/\s+/);
|
|
372
|
+
return { text, size: parsePaneSize(grid), ...(historyLines > 0 ? { scrollback: parsePaneScrollback(alternate, kept) } : {}) };
|
|
373
|
+
}
|
|
374
|
+
export function parsePaneScrollback(alternate, lines) {
|
|
375
|
+
if ((alternate !== "0" && alternate !== "1") || !/^\d+$/.test(lines ?? ""))
|
|
376
|
+
return null;
|
|
377
|
+
return { alternate: alternate === "1", lines: Number(lines) };
|
|
312
378
|
}
|
|
313
379
|
/** Parse `display-message`'s `<cols>x<rows>` output. Returns null if unusable. */
|
|
314
380
|
export function parsePaneSize(out) {
|
|
@@ -389,22 +455,17 @@ export function handleViewRequest(req, res, url, ctx) {
|
|
|
389
455
|
}
|
|
390
456
|
// #2855: a guest (the gate bound it, and checked the agent is in its scope) — Trusted only, within its limits, and
|
|
391
457
|
// reading the capture every guest viewer of this agent shares.
|
|
458
|
+
// M-2262: `?history=1` — the scrollback too (on demand: the person scrolled up). Any other query is the screen.
|
|
459
|
+
const history = url.searchParams.get("history") === "1";
|
|
392
460
|
const guest = guestRequestOf(req);
|
|
393
461
|
if (guest) {
|
|
394
|
-
guestPane(ctx, name, guest, res);
|
|
462
|
+
guestPane(ctx, name, guest, res, history);
|
|
395
463
|
return true;
|
|
396
464
|
}
|
|
397
|
-
|
|
398
|
-
.then((
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
headers["X-Pane-Cols"] = String(size.cols);
|
|
402
|
-
headers["X-Pane-Rows"] = String(size.rows);
|
|
403
|
-
}
|
|
404
|
-
res.writeHead(200, headers);
|
|
405
|
-
res.end(text);
|
|
406
|
-
})
|
|
407
|
-
.catch(err => { ctx.logger.debug({ err, name }, "capture-pane failed"); res.writeHead(200, { "Content-Type": "text/plain" }); res.end(""); });
|
|
465
|
+
if (history)
|
|
466
|
+
historyCapture(ctx, name).then((c) => sendHistory(res, c, VIEW_HISTORY.maxBytes), (err) => historyFailed(ctx, res, name, err));
|
|
467
|
+
else
|
|
468
|
+
capturePane(ctx, name).then((c) => sendPane(res, c, Number.MAX_SAFE_INTEGER), (err) => paneFailed(ctx, res, name, err));
|
|
408
469
|
return true;
|
|
409
470
|
}
|
|
410
471
|
// ── GET /api/profiles — merged roster ──
|