@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.
@@ -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
- /** Append an app-authored line to the conversation (help text, command replies). */
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
- ? active
1311
- .lines(this.theme, mainCols)
1312
- .flatMap((line) => (line === "" ? [""] : reflow(line, mainCols)))
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
- sidebar: [
1341
- ...navLines(this.theme, this.views, this.selectedView, this.sidebarFocused),
1342
- "",
1343
- ...sidebarLines(this.theme, this.sessions, this.activeSessionId),
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) {
@@ -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
+ }
@@ -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
- console.error(this.theme.muted("debug"), ...args);
69
+ this.emit("debug", [this.theme.muted("debug"), ...args]);
27
70
  }
28
71
  info(...args) {
29
72
  if (this.enabled("info"))
30
- console.error(...args);
73
+ this.emit("info", args);
31
74
  }
32
75
  warn(...args) {
33
76
  if (this.enabled("warn"))
34
- console.error(this.theme.warning("warn"), ...args);
77
+ this.emit("warn", [this.theme.warning("warn"), ...args]);
35
78
  }
36
79
  error(...args) {
37
80
  if (this.enabled("error"))
38
- console.error(this.theme.danger("error"), ...args);
81
+ this.emit("error", [this.theme.danger("error"), ...args]);
39
82
  }
40
- /** Primary user-facing output — always written to stdout. */
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
- console.log(...args);
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.0",
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.21.0",
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"