@cruxy/cli 1.11.0 → 1.11.2
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/README.md +12 -0
- package/dist/agent/context.js +6 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +4 -0
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/schema.js +8 -1
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +66 -0
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/resume.js +22 -3
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/tools/file/apply-patch.js +49 -23
- package/dist/tools/file/edit-file.js +15 -1
- package/dist/tools/file/snapshot.js +63 -0
- package/dist/tools/file/write-file.js +26 -5
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/utils/logger.js +52 -6
- package/dist/utils/process-owner.js +107 -0
- package/package.json +3 -2
package/dist/tui/renderer.js
CHANGED
|
@@ -13,7 +13,8 @@ import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
|
|
|
13
13
|
import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
14
14
|
import { limitsPanelLines } from "./limits-panel.js";
|
|
15
15
|
import { installScreenGuard, } from "./restore.js";
|
|
16
|
-
import { usesAltScreen } from "./supports.js";
|
|
16
|
+
import { usesAltScreen, usesMouse } from "./supports.js";
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
17
18
|
/**
|
|
18
19
|
* The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
|
|
19
20
|
* only one that owns the whole screen rather than a single managed line.
|
|
@@ -46,8 +47,31 @@ import { usesAltScreen } from "./supports.js";
|
|
|
46
47
|
* `renderInput`): the real cursor parks wherever the last painted row ended,
|
|
47
48
|
* which is a second, wrong caret blinking somewhere in the frame.
|
|
48
49
|
*/
|
|
49
|
-
const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
|
|
50
|
-
const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
|
|
50
|
+
export const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
|
|
51
|
+
export const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
|
|
52
|
+
/**
|
|
53
|
+
* Mouse reporting on and off (cli#1): `?1000h` (button events) in the
|
|
54
|
+
* `?1006h` (SGR) encoding, and the inverse in reverse order.
|
|
55
|
+
*
|
|
56
|
+
* Without it the terminal handles the wheel itself, and on the alternate
|
|
57
|
+
* screen that means one of two wrong things: Terminal.app scrolls the WINDOW
|
|
58
|
+
* over a buffer that has no scrollback, and iTerm2 translates each notch into
|
|
59
|
+
* arrow keys, which `app.ts` deliberately leaves inert. With it, a notch
|
|
60
|
+
* arrives as a key the loop can map onto the scroll Page Up already has.
|
|
61
|
+
*
|
|
62
|
+
* WRITTEN IN THE SAME BYTES as the alternate-screen pair — appended to the
|
|
63
|
+
* enter, prepended to the leave — and nowhere else, because of what it costs
|
|
64
|
+
* when left on: a terminal in mouse-reporting mode after the process is gone
|
|
65
|
+
* turns every click into escape bytes at the shell prompt. That pair is what
|
|
66
|
+
* the restore guard replays on a signal, so the mouse is released on exactly
|
|
67
|
+
* the paths the screen is.
|
|
68
|
+
*
|
|
69
|
+
* The known trade is that native text selection needs the terminal's modifier
|
|
70
|
+
* (Shift, or Option in Terminal.app) while the TUI is up — the trade `less` and
|
|
71
|
+
* `vim` make. `CRUXY_NO_MOUSE` opts out of just this (see `usesMouse`).
|
|
72
|
+
*/
|
|
73
|
+
export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
74
|
+
export const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
|
|
51
75
|
/** Coalescing window for repaints (~30fps). */
|
|
52
76
|
export const PAINT_INTERVAL_MS = 33;
|
|
53
77
|
/**
|
|
@@ -82,6 +106,19 @@ export const VIEW_PULSE_MS = 500;
|
|
|
82
106
|
export const SCROLLBACK_LINES = 1_000;
|
|
83
107
|
/** Lines a Page Up/Down keeps in common across the jump, so context survives. */
|
|
84
108
|
export const SCROLL_PAGE_OVERLAP = 2;
|
|
109
|
+
/**
|
|
110
|
+
* Display lines one wheel notch scrolls (cli#1). Three is what most terminals
|
|
111
|
+
* and pagers move per notch; a whole page per notch would make the wheel a
|
|
112
|
+
* coarser Page Up rather than the fine control it is everywhere else.
|
|
113
|
+
*/
|
|
114
|
+
export const WHEEL_LINES = 3;
|
|
115
|
+
/**
|
|
116
|
+
* The most rows a view gives up to command output pinned beneath it (cli#7b),
|
|
117
|
+
* as a fraction of the pane. Half: the view stays readable, and `/help` — the
|
|
118
|
+
* longest thing this carries — still shows most of itself, with the notice row
|
|
119
|
+
* saying where the rest is.
|
|
120
|
+
*/
|
|
121
|
+
export const NOTICE_ROWS_FRACTION = 0.5;
|
|
85
122
|
export class TuiRenderer {
|
|
86
123
|
caps;
|
|
87
124
|
theme;
|
|
@@ -95,6 +132,8 @@ export class TuiRenderer {
|
|
|
95
132
|
guard;
|
|
96
133
|
/** Whether this renderer took the alternate screen and owes the inverse. */
|
|
97
134
|
altScreen;
|
|
135
|
+
/** Whether it turned mouse reporting on with it, and owes that inverse too. */
|
|
136
|
+
mouse;
|
|
98
137
|
/** Lines the opening banner occupies — the buffer's contents at construction. */
|
|
99
138
|
openingLines;
|
|
100
139
|
/** Logical lines committed since construction, INCLUDING ones rolled off. */
|
|
@@ -130,6 +169,15 @@ export class TuiRenderer {
|
|
|
130
169
|
views = [];
|
|
131
170
|
/** Which view owns the main column. Always a real id — see {@link setView}. */
|
|
132
171
|
selectedView = CONVERSATION_VIEW;
|
|
172
|
+
/**
|
|
173
|
+
* App-authored lines printed while a view other than the conversation owned
|
|
174
|
+
* the main column (cli#7b), pinned under that view until the user moves on.
|
|
175
|
+
* Every one of them is ALSO in the scrollback: this is a second showing, not
|
|
176
|
+
* a second home. Empty whenever the conversation is selected.
|
|
177
|
+
*/
|
|
178
|
+
notices = [];
|
|
179
|
+
/** Hands the console back to the logger; see the constructor. */
|
|
180
|
+
releaseLogger = null;
|
|
133
181
|
/** Whether the sidebar nav holds the keyboard rather than the input line. */
|
|
134
182
|
sidebarFocused = false;
|
|
135
183
|
/** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
|
|
@@ -205,8 +253,9 @@ export class TuiRenderer {
|
|
|
205
253
|
// The inverse is owed from this line onward, which is why track 1 landed
|
|
206
254
|
// first: `close()` is not the only way out of this constructor's reach.
|
|
207
255
|
this.altScreen = opts.altScreen ?? usesAltScreen(caps);
|
|
256
|
+
this.mouse = this.altScreen && (opts.mouse ?? usesMouse(caps));
|
|
208
257
|
if (this.altScreen)
|
|
209
|
-
out.write(ENTER_ALT_SCREEN);
|
|
258
|
+
out.write(ENTER_ALT_SCREEN + (this.mouse ? MOUSE_ON : ""));
|
|
210
259
|
// ABSOLUTE rows (Q4 track 2). This renderer owns the viewport outright, so
|
|
211
260
|
// it has no reason to infer where its rows are from where the cursor was
|
|
212
261
|
// left — and every reason not to. A frame this tall repaints thirty times a
|
|
@@ -216,6 +265,16 @@ export class TuiRenderer {
|
|
|
216
265
|
this.frame = createFrame((text) => this.out.write(text), caps, {
|
|
217
266
|
absolute: true,
|
|
218
267
|
});
|
|
268
|
+
// THE LOGGER WRITES INTO THIS FRAME from here on (cli#1, the second
|
|
269
|
+
// contributor). Both standard streams are this terminal, and a diagnostic
|
|
270
|
+
// written to stderr while the frame owns every row lands inside it: the
|
|
271
|
+
// retention notice at boot did, and a hook's or an MCP server's warning
|
|
272
|
+
// mid-turn does. Routed through `println` they are conversation lines —
|
|
273
|
+
// visible, scrollable, and pinned under a view like any other command
|
|
274
|
+
// output — instead of bytes the next repaint erases. Released with the
|
|
275
|
+
// screen, in `releaseScreen`, so the exit path's own prints reach the
|
|
276
|
+
// normal buffer.
|
|
277
|
+
this.releaseLogger = logger.capture((_channel, text) => this.println(text));
|
|
219
278
|
// Installed here, not in `close()`'s vicinity, because the window it covers
|
|
220
279
|
// opens with the first paint: from this line on there is a shell on screen
|
|
221
280
|
// that only this object knows how to take down.
|
|
@@ -414,9 +473,24 @@ export class TuiRenderer {
|
|
|
414
473
|
return true;
|
|
415
474
|
this.selectedView = id;
|
|
416
475
|
this.scrollOffset = 0;
|
|
476
|
+
// Notices belong to the view they were pinned under. Leaving it — for the
|
|
477
|
+
// conversation, which holds them all anyway, or for another view — drops
|
|
478
|
+
// them, so a "showing git" cannot follow the user onto the tasks pane.
|
|
479
|
+
this.notices = [];
|
|
417
480
|
this.schedulePaint();
|
|
418
481
|
return true;
|
|
419
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Drop the command output pinned under the selected view (cli#7b). The app
|
|
485
|
+
* calls this when a line is submitted: the user has moved on, and the next
|
|
486
|
+
* command's output must not stack under the last one's.
|
|
487
|
+
*/
|
|
488
|
+
dismissNotices() {
|
|
489
|
+
if (this.closed || this.notices.length === 0)
|
|
490
|
+
return;
|
|
491
|
+
this.notices = [];
|
|
492
|
+
this.schedulePaint();
|
|
493
|
+
}
|
|
420
494
|
/** Move `steps` around the view ring and select what lands. */
|
|
421
495
|
cycleView(steps) {
|
|
422
496
|
const next = cycleView(this.selectedView, this.views, steps);
|
|
@@ -561,11 +635,42 @@ export class TuiRenderer {
|
|
|
561
635
|
overlayWidth() {
|
|
562
636
|
return this.viewportWidth();
|
|
563
637
|
}
|
|
564
|
-
/**
|
|
638
|
+
/**
|
|
639
|
+
* Append an app-authored line to the conversation (help text, command
|
|
640
|
+
* replies, a routed diagnostic).
|
|
641
|
+
*
|
|
642
|
+
* WHILE A VIEW HIDES THE CONVERSATION the line is also pinned under that view
|
|
643
|
+
* (cli#7b). Before this, `/help`, `/view`'s listing, a Tab completion's
|
|
644
|
+
* suggestions and the "showing …" confirmation all went into a buffer the
|
|
645
|
+
* screen was not showing, and the command read as having done nothing. The
|
|
646
|
+
* conversation stays the record; the view keeps the column; the line is
|
|
647
|
+
* simply shown where the user is looking.
|
|
648
|
+
*/
|
|
565
649
|
println(line = "") {
|
|
566
650
|
if (this.closed)
|
|
567
651
|
return;
|
|
568
652
|
this.pushLines([line]);
|
|
653
|
+
if (this.selectedView !== CONVERSATION_VIEW)
|
|
654
|
+
this.notices.push(line);
|
|
655
|
+
this.schedulePaint();
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Empty the scrollback (cli#2) — the visible half of `/clear`.
|
|
659
|
+
*
|
|
660
|
+
* `Session.clear` empties the history the model sees and the log records the
|
|
661
|
+
* event, and both were always right. What stayed wrong was the screen: the
|
|
662
|
+
* whole transcript remained above a muted "history cleared", so a reset that
|
|
663
|
+
* had worked read as one that had not. The buffer goes, the partial line
|
|
664
|
+
* goes, the scroll position goes; `committed` stays, because the exit tail's
|
|
665
|
+
* "N earlier lines" counts what was ever shown, and these were.
|
|
666
|
+
*/
|
|
667
|
+
clearScrollback() {
|
|
668
|
+
if (this.closed)
|
|
669
|
+
return;
|
|
670
|
+
this.buffer = [];
|
|
671
|
+
this.partial = "";
|
|
672
|
+
this.notices = [];
|
|
673
|
+
this.scrollOffset = 0;
|
|
569
674
|
this.schedulePaint();
|
|
570
675
|
}
|
|
571
676
|
// ── StreamRenderer ────────────────────────────────────────────────────────
|
|
@@ -880,12 +985,16 @@ export class TuiRenderer {
|
|
|
880
985
|
* handed over.
|
|
881
986
|
*/
|
|
882
987
|
releaseScreen() {
|
|
988
|
+
// The console comes back first, so anything the exit path logs after this
|
|
989
|
+
// line reaches the terminal rather than a renderer that is closing.
|
|
990
|
+
this.releaseLogger?.();
|
|
991
|
+
this.releaseLogger = null;
|
|
883
992
|
this.closed = true;
|
|
884
993
|
this.frame.clear();
|
|
885
994
|
// The exact inverse of the constructor's pair, and the last bytes this
|
|
886
995
|
// renderer ever writes to the managed screen.
|
|
887
996
|
if (this.altScreen)
|
|
888
|
-
this.out.write(LEAVE_ALT_SCREEN);
|
|
997
|
+
this.out.write((this.mouse ? MOUSE_OFF : "") + LEAVE_ALT_SCREEN);
|
|
889
998
|
}
|
|
890
999
|
/**
|
|
891
1000
|
* Echo the tail of the conversation into the normal buffer (Q4 track 3).
|
|
@@ -1306,10 +1415,21 @@ export class TuiRenderer {
|
|
|
1306
1415
|
// A view's lines are reflowed here rather than trusted at `mainCols`: the
|
|
1307
1416
|
// contract asks a view to lay out to the width it is given, and reflow makes
|
|
1308
1417
|
// that a courtesy rather than a rule it can break the grid by ignoring.
|
|
1418
|
+
//
|
|
1419
|
+
// Command output pinned under a view (cli#7b) rides the same rule the plan
|
|
1420
|
+
// checklist does under the conversation: appended, so the column's tail
|
|
1421
|
+
// rule keeps it on screen, and capped so it cannot evict the view. When the
|
|
1422
|
+
// cap cuts it, the first pinned row says how much is above and where it
|
|
1423
|
+
// all is — the conversation, one Esc away. Absent while scrolled, like the
|
|
1424
|
+
// plan, so a reader holding history still is not moved by it.
|
|
1425
|
+
const notices = active === undefined || scrolled ? [] : this.noticeBlock(rows, mainCols);
|
|
1309
1426
|
const body = active !== undefined
|
|
1310
|
-
?
|
|
1311
|
-
|
|
1312
|
-
|
|
1427
|
+
? [
|
|
1428
|
+
...active
|
|
1429
|
+
.lines(this.theme, mainCols)
|
|
1430
|
+
.flatMap((line) => (line === "" ? [""] : reflow(line, mainCols))),
|
|
1431
|
+
...(notices.length === 0 ? [] : ["", ...notices]),
|
|
1432
|
+
]
|
|
1313
1433
|
: plan.length === 0
|
|
1314
1434
|
? wrapped
|
|
1315
1435
|
: [...wrapped, "", ...plan];
|
|
@@ -1337,11 +1457,13 @@ export class TuiRenderer {
|
|
|
1337
1457
|
// one because they answer different questions — "where am I" and "what
|
|
1338
1458
|
// else have I run" — and because the sessions list is destined to become
|
|
1339
1459
|
// a view of its own, at which point this reduces to the nav.
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1460
|
+
//
|
|
1461
|
+
// Composed to the row budget HERE (cli#7a), the way the rail is: the nav
|
|
1462
|
+
// takes what it needs, the list gets what is left. Handing the layout an
|
|
1463
|
+
// over-long column and letting its tail rule cut it was what removed the
|
|
1464
|
+
// nav heading and the first view rows at 30 rows — and with them the
|
|
1465
|
+
// pointer Ctrl+B was moving.
|
|
1466
|
+
sidebar: this.sidebarBlock(rows),
|
|
1345
1467
|
main,
|
|
1346
1468
|
rail: stacked.lines,
|
|
1347
1469
|
status: this.statusLine(width),
|
|
@@ -1349,6 +1471,57 @@ export class TuiRenderer {
|
|
|
1349
1471
|
overlay: drawer,
|
|
1350
1472
|
};
|
|
1351
1473
|
}
|
|
1474
|
+
/**
|
|
1475
|
+
* The sidebar column, fitted to `rows` (cli#7a): the nav whole, then the
|
|
1476
|
+
* session list in whatever remains, with one blank row between them when
|
|
1477
|
+
* there is room for both. The nav is never cut here — `fitHead` in the
|
|
1478
|
+
* layout trims it only on a terminal too short to hold it at all.
|
|
1479
|
+
*/
|
|
1480
|
+
sidebarBlock(rows) {
|
|
1481
|
+
const nav = navLines(this.theme, this.views, this.selectedView, this.sidebarFocused);
|
|
1482
|
+
const remaining = rows - nav.length - 1;
|
|
1483
|
+
if (remaining <= 0)
|
|
1484
|
+
return nav;
|
|
1485
|
+
return [
|
|
1486
|
+
...nav,
|
|
1487
|
+
"",
|
|
1488
|
+
...sidebarLines(this.theme, this.sessions, this.activeSessionId, Date.now(), remaining),
|
|
1489
|
+
];
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* The pinned command output for the selected view, wrapped at the column
|
|
1493
|
+
* width and capped at {@link NOTICE_ROWS_FRACTION} of the pane. The cap
|
|
1494
|
+
* keeps the NEWEST rows — the most recent command's output is the one just
|
|
1495
|
+
* asked for — and charges its first row to say what was cut.
|
|
1496
|
+
*/
|
|
1497
|
+
noticeBlock(rows, cols) {
|
|
1498
|
+
if (this.notices.length === 0)
|
|
1499
|
+
return [];
|
|
1500
|
+
const wrapped = [];
|
|
1501
|
+
for (const line of this.notices) {
|
|
1502
|
+
if (line === "")
|
|
1503
|
+
wrapped.push("");
|
|
1504
|
+
else
|
|
1505
|
+
wrapped.push(...reflow(line, cols));
|
|
1506
|
+
}
|
|
1507
|
+
const cap = Math.max(1, Math.floor(rows * NOTICE_ROWS_FRACTION));
|
|
1508
|
+
if (wrapped.length <= cap)
|
|
1509
|
+
return wrapped;
|
|
1510
|
+
const shown = Math.max(0, cap - 1);
|
|
1511
|
+
const hidden = wrapped.length - shown;
|
|
1512
|
+
return [
|
|
1513
|
+
noticeOverflow(hidden, this.theme),
|
|
1514
|
+
...wrapped.slice(wrapped.length - shown),
|
|
1515
|
+
];
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* The heading row a pinned-notice block spends when it cannot show every line.
|
|
1520
|
+
* Short enough to survive the narrowest main column (36 at 80 wide with both
|
|
1521
|
+
* side columns up): a marker truncated mid-word would not name the way out.
|
|
1522
|
+
*/
|
|
1523
|
+
function noticeOverflow(hidden, theme) {
|
|
1524
|
+
return theme.muted(`${theme.glyph.ellipsis}${hidden} more above${theme.sep}Esc to see all`);
|
|
1352
1525
|
}
|
|
1353
1526
|
/** Row-wise equality for overlay content — `null` and `[]` both mean "no drawer". */
|
|
1354
1527
|
function sameLines(a, b) {
|
package/dist/tui/supports.js
CHANGED
|
@@ -40,3 +40,18 @@ export function supportsTui(caps) {
|
|
|
40
40
|
export function usesAltScreen(caps, env = process.env) {
|
|
41
41
|
return supportsTui(caps) && !isSet(env.CRUXY_NO_ALT_SCREEN);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether the TUI turns MOUSE REPORTING on (cli#1), so the wheel scrolls the
|
|
45
|
+
* pane instead of the terminal's window.
|
|
46
|
+
*
|
|
47
|
+
* Gated on {@link usesAltScreen}: the mouse is enabled and released in the
|
|
48
|
+
* same byte pair as the alternate screen, and there is no pair to ride without
|
|
49
|
+
* it. `CRUXY_NO_MOUSE` opts out on its own, same set-and-non-empty rule,
|
|
50
|
+
* because reporting has a cost the alternate screen does not: while it is on,
|
|
51
|
+
* native text selection needs the terminal's modifier (Shift, or Option in
|
|
52
|
+
* Terminal.app). Someone who selects text constantly can turn the wheel off
|
|
53
|
+
* without giving up the TUI.
|
|
54
|
+
*/
|
|
55
|
+
export function usesMouse(caps, env = process.env) {
|
|
56
|
+
return usesAltScreen(caps, env) && !isSet(env.CRUXY_NO_MOUSE);
|
|
57
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { format } from "node:util";
|
|
1
2
|
import { shouldUseColor } from "../errors/format.js";
|
|
2
3
|
import { themeForColor } from "../theme/index.js";
|
|
3
4
|
export const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"];
|
|
@@ -12,6 +13,48 @@ class Logger {
|
|
|
12
13
|
level = "info";
|
|
13
14
|
/** Diagnostics go to stderr, so the theme resolves against stderr's color. */
|
|
14
15
|
theme = themeForColor(shouldUseColor(process.stderr));
|
|
16
|
+
/** The surface that currently owns the terminal, or null for the console. */
|
|
17
|
+
sink = null;
|
|
18
|
+
/**
|
|
19
|
+
* Route every line through `sink` instead of the console, until the returned
|
|
20
|
+
* release is called.
|
|
21
|
+
*
|
|
22
|
+
* This exists for the full-screen TUI. While it is up, the frame owns every
|
|
23
|
+
* row of the terminal and BOTH standard streams point at that same terminal —
|
|
24
|
+
* so a `warn` written to stderr does not go "to the side", it lands inside
|
|
25
|
+
* the frame at wherever the cursor was parked, and the next repaint erases
|
|
26
|
+
* it. The retention notice at boot, a hook's announcement and an MCP server's
|
|
27
|
+
* failure mid-turn all did exactly that. The renderer installs itself here
|
|
28
|
+
* for as long as it holds the screen and hands the console back when it
|
|
29
|
+
* releases it, on the clean path and the signal path alike.
|
|
30
|
+
*
|
|
31
|
+
* The level filter still applies: a captured `debug` is dropped at the
|
|
32
|
+
* default level just as an uncaptured one is. Only the destination changes.
|
|
33
|
+
*
|
|
34
|
+
* The release is a no-op once a later capture has replaced this one, so two
|
|
35
|
+
* surfaces releasing out of order cannot strand the console.
|
|
36
|
+
*/
|
|
37
|
+
capture(sink) {
|
|
38
|
+
this.sink = sink;
|
|
39
|
+
return () => {
|
|
40
|
+
if (this.sink === sink)
|
|
41
|
+
this.sink = null;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Whether a surface has captured the streams. */
|
|
45
|
+
captured() {
|
|
46
|
+
return this.sink !== null;
|
|
47
|
+
}
|
|
48
|
+
emit(channel, args) {
|
|
49
|
+
if (this.sink !== null) {
|
|
50
|
+
this.sink(channel, format(...args));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (channel === "print")
|
|
54
|
+
console.log(...args);
|
|
55
|
+
else
|
|
56
|
+
console.error(...args);
|
|
57
|
+
}
|
|
15
58
|
setLevel(level) {
|
|
16
59
|
this.level = level;
|
|
17
60
|
}
|
|
@@ -23,23 +66,26 @@ class Logger {
|
|
|
23
66
|
}
|
|
24
67
|
debug(...args) {
|
|
25
68
|
if (this.enabled("debug"))
|
|
26
|
-
|
|
69
|
+
this.emit("debug", [this.theme.muted("debug"), ...args]);
|
|
27
70
|
}
|
|
28
71
|
info(...args) {
|
|
29
72
|
if (this.enabled("info"))
|
|
30
|
-
|
|
73
|
+
this.emit("info", args);
|
|
31
74
|
}
|
|
32
75
|
warn(...args) {
|
|
33
76
|
if (this.enabled("warn"))
|
|
34
|
-
|
|
77
|
+
this.emit("warn", [this.theme.warning("warn"), ...args]);
|
|
35
78
|
}
|
|
36
79
|
error(...args) {
|
|
37
80
|
if (this.enabled("error"))
|
|
38
|
-
|
|
81
|
+
this.emit("error", [this.theme.danger("error"), ...args]);
|
|
39
82
|
}
|
|
40
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Primary user-facing output — stdout, or the capturing surface while one
|
|
85
|
+
* holds the screen (stdout IS that screen).
|
|
86
|
+
*/
|
|
41
87
|
print(...args) {
|
|
42
|
-
|
|
88
|
+
this.emit("print", args);
|
|
43
89
|
}
|
|
44
90
|
}
|
|
45
91
|
export const logger = new Logger();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** The token when the platform lookup failed: liveness falls back to pid-only. */
|
|
4
|
+
export const UNKNOWN_TOKEN = "unknown";
|
|
5
|
+
/** The start-time token for `pid`, or {@link UNKNOWN_TOKEN} when unobtainable. */
|
|
6
|
+
export function startToken(pid) {
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === "linux")
|
|
9
|
+
return linuxToken(pid);
|
|
10
|
+
if (process.platform === "darwin")
|
|
11
|
+
return darwinToken(pid);
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
return win32Token(pid);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// fall through — the platform said no; degrade to pid-only
|
|
17
|
+
}
|
|
18
|
+
return UNKNOWN_TOKEN;
|
|
19
|
+
}
|
|
20
|
+
/** This process's own stamp. Computed once — a process's identity never changes. */
|
|
21
|
+
export function selfStamp() {
|
|
22
|
+
if (!self) {
|
|
23
|
+
self = {
|
|
24
|
+
pid: process.pid,
|
|
25
|
+
token: startToken(process.pid),
|
|
26
|
+
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return self;
|
|
30
|
+
}
|
|
31
|
+
let self;
|
|
32
|
+
/** Whether a process with `pid` exists (EPERM counts: it exists, just not ours). */
|
|
33
|
+
export function pidAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return err.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Classify a stamp against the live process table. Order matters: `self` is
|
|
44
|
+
* decided by pid AND token, so a stamp our own pid inherited from a crashed
|
|
45
|
+
* predecessor (pid recycled onto us) still reads as stale.
|
|
46
|
+
*/
|
|
47
|
+
export function describeOwner(stamp) {
|
|
48
|
+
const me = selfStamp();
|
|
49
|
+
if (stamp.pid === me.pid) {
|
|
50
|
+
return stamp.token === me.token ? "self" : "stale";
|
|
51
|
+
}
|
|
52
|
+
if (!pidAlive(stamp.pid))
|
|
53
|
+
return "stale";
|
|
54
|
+
// Alive by pid. If both sides have a real token and they differ, the pid was
|
|
55
|
+
// recycled. An unknown token on either side cannot prove that, so the stamp
|
|
56
|
+
// is treated as live — the documented pid-only degradation.
|
|
57
|
+
if (stamp.token === UNKNOWN_TOKEN)
|
|
58
|
+
return "live";
|
|
59
|
+
const now = startToken(stamp.pid);
|
|
60
|
+
if (now === UNKNOWN_TOKEN)
|
|
61
|
+
return "live";
|
|
62
|
+
return now === stamp.token ? "live" : "stale";
|
|
63
|
+
}
|
|
64
|
+
// ── platform lookups ──────────────────────────────────────────────────────────
|
|
65
|
+
function linuxToken(pid) {
|
|
66
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
67
|
+
// The comm field is parenthesised and may itself contain spaces or parens;
|
|
68
|
+
// everything after the LAST `)` is the fixed-position numeric tail, in which
|
|
69
|
+
// starttime is the 20th entry (field 22 of the whole line).
|
|
70
|
+
const tail = stat
|
|
71
|
+
.slice(stat.lastIndexOf(")") + 2)
|
|
72
|
+
.trim()
|
|
73
|
+
.split(/\s+/);
|
|
74
|
+
const starttime = tail[19];
|
|
75
|
+
if (!starttime)
|
|
76
|
+
throw new Error("unexpected /proc stat shape");
|
|
77
|
+
let boot = "";
|
|
78
|
+
try {
|
|
79
|
+
boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Older kernels / locked-down containers: the token is still per-boot in
|
|
83
|
+
// practice (ticks since boot), just not provably so across reboots.
|
|
84
|
+
}
|
|
85
|
+
return `linux:${boot}:${starttime}`;
|
|
86
|
+
}
|
|
87
|
+
function darwinToken(pid) {
|
|
88
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
timeout: 2000,
|
|
92
|
+
}).trim();
|
|
93
|
+
if (out === "")
|
|
94
|
+
throw new Error("no such process");
|
|
95
|
+
return `darwin:${out.replace(/\s+/g, " ")}`;
|
|
96
|
+
}
|
|
97
|
+
function win32Token(pid) {
|
|
98
|
+
const out = execFileSync("powershell", [
|
|
99
|
+
"-NoProfile",
|
|
100
|
+
"-NonInteractive",
|
|
101
|
+
"-Command",
|
|
102
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToString('o')`,
|
|
103
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
104
|
+
if (out === "")
|
|
105
|
+
throw new Error("no such process");
|
|
106
|
+
return `win32:${out}`;
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,8 +32,9 @@
|
|
|
32
32
|
"commander": "^12.1.0",
|
|
33
33
|
"fastembed": "^2.1.0",
|
|
34
34
|
"picocolors": "^1.1.1",
|
|
35
|
+
"tar": "^7.5.22",
|
|
35
36
|
"tinyglobby": "^0.2.10",
|
|
36
|
-
"undici": "^6.
|
|
37
|
+
"undici": "^6.28.1",
|
|
37
38
|
"zod": "^3.23.8",
|
|
38
39
|
"zod-to-json-schema": "^3.23.5",
|
|
39
40
|
"@cruxy/sdk": "0.8.0"
|