moshcode 0.56.0 → 0.58.0
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/package.json +1 -1
- package/src/news-sources.mjs +20 -18
- package/src/news.mjs +123 -17
- package/src/tui.mjs +37 -0
package/package.json
CHANGED
package/src/news-sources.mjs
CHANGED
|
@@ -272,16 +272,24 @@ export const DEFAULT_FEEDS = [
|
|
|
272
272
|
* one. They are somebody else's lists, and that is the point: the feeds inside
|
|
273
273
|
* them stay current without moshcode shipping a release.
|
|
274
274
|
*
|
|
275
|
-
*
|
|
275
|
+
* Three shapes, because the lists worth reading come in three:
|
|
276
276
|
*
|
|
277
277
|
* `format: "opml"` — an OPML document, parsed by parseOpml.
|
|
278
|
-
* `format: "text"` — one feed URL per line, `#` comments ignored.
|
|
279
|
-
*
|
|
278
|
+
* `format: "text"` — one feed URL per line, `#` comments ignored.
|
|
279
|
+
* `format: "feed"` — the URL is not a catalogue at all but a single feed that
|
|
280
|
+
* already aggregates one. Nothing is fetched to list it; `add` subscribes
|
|
281
|
+
* to the one URL. This is how smallweb is carried — see below.
|
|
280
282
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
283
|
+
* Kagi's Small Web used to be here as its catalogue: a 5MB OPML of 33,000
|
|
284
|
+
* personal blogs, plus a 1.1MB plain-text fork of the same thing. Both were
|
|
285
|
+
* marked search-only, because subscribing to 33,000 feeds is not a thing a
|
|
286
|
+
* reader can do — but `find` reads every list on every search, so *searching*
|
|
287
|
+
* loaded and parsed 33,000 outlines each time, which is what took `/rss` and
|
|
288
|
+
* `/news find` down. Lazily paging a catalogue that size is a feature nobody
|
|
289
|
+
* asked for, so the catalogue is gone and the firehose stands in for it:
|
|
290
|
+
* kagi.com/smallweb/feed is those same blogs already merged into one Atom
|
|
291
|
+
* document, newest first, with titles and summaries. One feed, one fetch, and
|
|
292
|
+
* `/news --feed smallweb` reads it on its own.
|
|
285
293
|
*/
|
|
286
294
|
export const FEED_LISTS = [
|
|
287
295
|
{
|
|
@@ -310,17 +318,11 @@ export const FEED_LISTS = [
|
|
|
310
318
|
},
|
|
311
319
|
{
|
|
312
320
|
name: "smallweb",
|
|
313
|
-
description: "Kagi Small Web — 33k personal blogs,
|
|
314
|
-
url: "https://kagi.com/smallweb/
|
|
315
|
-
format: "
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{
|
|
319
|
-
name: "smallweb-txt",
|
|
320
|
-
description: "Kagi Small Web, profullstack's fork — the plain-text list",
|
|
321
|
-
url: "https://raw.githubusercontent.com/ralyodio/smallweb/refs/heads/main/smallweb.txt",
|
|
322
|
-
format: "text",
|
|
323
|
-
searchOnly: true,
|
|
321
|
+
description: "Kagi Small Web — 33k personal blogs, merged into one feed",
|
|
322
|
+
url: "https://kagi.com/smallweb/feed",
|
|
323
|
+
format: "feed",
|
|
324
|
+
title: "Kagi Small Web",
|
|
325
|
+
site: "https://kagi.com/smallweb",
|
|
324
326
|
},
|
|
325
327
|
];
|
|
326
328
|
|
package/src/news.mjs
CHANGED
|
@@ -99,6 +99,40 @@ const FILE_MODE = 0o600;
|
|
|
99
99
|
/** Enough for a large feed, small enough that one bad URL cannot eat the pit. */
|
|
100
100
|
const MAX_BYTES = 8 * 1024 * 1024;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Where a *feed* read stops, and — unlike MAX_BYTES — stopping is not failing.
|
|
104
|
+
*
|
|
105
|
+
* An aggregated feed has no natural size: smallweb's firehose is 10MB of
|
|
106
|
+
* today's posts, which the 8MB cap would refuse outright, leaving the reader
|
|
107
|
+
* with nothing rather than with the newest few hundred entries. Feeds are
|
|
108
|
+
* newest-first, so the first two megabytes are the part worth having; the rest
|
|
109
|
+
* of the transfer is cancelled mid-stream and what arrived is parsed. parseFeed
|
|
110
|
+
* matches whole `<item>`/`<entry>` blocks, so a cut tail simply is not an item.
|
|
111
|
+
*
|
|
112
|
+
* Ordinary feeds never reach this — the largest in the defaults is under 1MB.
|
|
113
|
+
*/
|
|
114
|
+
const FEED_MAX_BYTES = 2 * 1024 * 1024;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The most headlines one feed may put into a merged listing.
|
|
118
|
+
*
|
|
119
|
+
* Without it, subscribing to an aggregator is the same as unsubscribing from
|
|
120
|
+
* everything else: the merge sorts by date, and 4,000 small-web posts from
|
|
121
|
+
* today's firehose sit above every headline the other feeds have. Set at
|
|
122
|
+
* MAX_LIMIT so no listing can be starved by the cap — `--feed smallweb --limit
|
|
123
|
+
* 200` still fills, because a single feed can supply every row.
|
|
124
|
+
*/
|
|
125
|
+
const PER_FEED_ITEMS = 200;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The most feeds one `add` may subscribe to at once.
|
|
129
|
+
*
|
|
130
|
+
* Whatever the source — a named list, a URL, a local file. The largest list
|
|
131
|
+
* published by name holds 611 feeds, so this refuses only the thing that is a
|
|
132
|
+
* catalogue rather than a reading list.
|
|
133
|
+
*/
|
|
134
|
+
const MAX_IMPORT_FEEDS = 2000;
|
|
135
|
+
|
|
102
136
|
/** How many feeds are in flight at once. Politeness, not throughput. */
|
|
103
137
|
const CONCURRENCY = 6;
|
|
104
138
|
|
|
@@ -413,11 +447,11 @@ export function findFeed(feeds, needle) {
|
|
|
413
447
|
/**
|
|
414
448
|
* Parse a newline-delimited feed list: one URL per line, `#` comments ignored.
|
|
415
449
|
*
|
|
416
|
-
* The other shape a published list comes in
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
450
|
+
* The other shape a published list comes in: plenty of them are a text file of
|
|
451
|
+
* URLs and nothing else, so a reader that only speaks OPML cannot read them.
|
|
452
|
+
* No titles are invented beyond the hostname — naming a list's feeds properly
|
|
453
|
+
* would mean fetching every one of them, and the host is what the URL already
|
|
454
|
+
* tells us for free.
|
|
421
455
|
*/
|
|
422
456
|
export function parseFeedList(text) {
|
|
423
457
|
const feeds = [];
|
|
@@ -440,7 +474,7 @@ export function parseListDocument(body, format = "") {
|
|
|
440
474
|
return looksLikeOpml(body) ? parseOpml(body) : parseFeedList(body);
|
|
441
475
|
}
|
|
442
476
|
|
|
443
|
-
/** Where a fetched list is cached, so searching does not refetch
|
|
477
|
+
/** Where a fetched list is cached, so searching does not refetch every list. */
|
|
444
478
|
export function listCacheFile(name, env = process.env) {
|
|
445
479
|
return path.join(path.dirname(opmlFile(env)), "lists", `${slugify(name) || "list"}.json`);
|
|
446
480
|
}
|
|
@@ -462,6 +496,24 @@ const LIST_CACHE_MS = 24 * 60 * 60 * 1000;
|
|
|
462
496
|
export async function loadListFeeds(list, {
|
|
463
497
|
fetchImpl, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, now = Date.now(), refresh = false,
|
|
464
498
|
} = {}) {
|
|
499
|
+
// A `format: "feed"` list is a single feed that has already done the
|
|
500
|
+
// aggregating — smallweb's firehose. There is no catalogue to fetch or cache:
|
|
501
|
+
// the entry itself is the one feed, so a search costs nothing and can never
|
|
502
|
+
// be the thing that makes `find` slow.
|
|
503
|
+
if (list.format === "feed") {
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
cached: false,
|
|
507
|
+
feeds: [{
|
|
508
|
+
name: list.name,
|
|
509
|
+
title: list.title || list.name,
|
|
510
|
+
url: list.url,
|
|
511
|
+
site: list.site || "",
|
|
512
|
+
category: list.name,
|
|
513
|
+
}],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
465
517
|
const file = listCacheFile(list.name, env);
|
|
466
518
|
const cached = () => {
|
|
467
519
|
try {
|
|
@@ -512,7 +564,7 @@ export function parseKeywords(raw) {
|
|
|
512
564
|
*
|
|
513
565
|
* Ranked rather than filtered, because filtering cannot win both halves of this:
|
|
514
566
|
*
|
|
515
|
-
* · Plain substring matching searches
|
|
567
|
+
* · Plain substring matching searches every list for `rust` and returns
|
|
516
568
|
* Trust Machines, Trustnodes, frustrat.com and popthruster.com.
|
|
517
569
|
* · Requiring the keyword to start a word fixes that and then finds nothing
|
|
518
570
|
* at all for `homelab`, because the list's only metadata is the hostname
|
|
@@ -689,8 +741,15 @@ export function tidyTitle(title) {
|
|
|
689
741
|
*
|
|
690
742
|
* Size-capped while streaming rather than after: a feed that turns out to be a
|
|
691
743
|
* disk image should cost a few megabytes of transfer, not all of it.
|
|
744
|
+
*
|
|
745
|
+
* What happens at the cap is the caller's to choose. A document read whole —
|
|
746
|
+
* an OPML catalogue — fails, because half a catalogue is a wrong answer rather
|
|
747
|
+
* than a partial one. A feed read for its newest entries truncates instead:
|
|
748
|
+
* see FEED_MAX_BYTES.
|
|
692
749
|
*/
|
|
693
|
-
export async function fetchDocument(url, {
|
|
750
|
+
export async function fetchDocument(url, {
|
|
751
|
+
fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = MAX_BYTES, truncate = false,
|
|
752
|
+
} = {}) {
|
|
694
753
|
const impl = fetchImpl || globalThis.fetch;
|
|
695
754
|
if (typeof impl !== "function") return { ok: false, error: "no fetch available in this runtime" };
|
|
696
755
|
const safe = safeUrl(url);
|
|
@@ -713,7 +772,10 @@ export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
|
|
|
713
772
|
// text() for any fetch implementation (tests included) that has no body.
|
|
714
773
|
if (!res.body || typeof res.body.getReader !== "function") {
|
|
715
774
|
const body = await res.text();
|
|
716
|
-
if (body.length >
|
|
775
|
+
if (body.length > maxBytes) {
|
|
776
|
+
if (!truncate) return { ok: false, error: `feed is larger than ${maxBytes} bytes` };
|
|
777
|
+
return { ok: true, body: body.slice(0, maxBytes), truncated: true };
|
|
778
|
+
}
|
|
717
779
|
return { ok: true, body };
|
|
718
780
|
}
|
|
719
781
|
const reader = res.body.getReader();
|
|
@@ -724,9 +786,11 @@ export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
|
|
|
724
786
|
const { done, value } = await reader.read();
|
|
725
787
|
if (done) break;
|
|
726
788
|
bytes += value.byteLength;
|
|
727
|
-
if (bytes >
|
|
789
|
+
if (bytes > maxBytes) {
|
|
728
790
|
try { await reader.cancel(); } catch { /* already gone */ }
|
|
729
|
-
return { ok: false, error: `feed is larger than ${
|
|
791
|
+
if (!truncate) return { ok: false, error: `feed is larger than ${maxBytes} bytes` };
|
|
792
|
+
body += decoder.decode(value, { stream: true });
|
|
793
|
+
return { ok: true, body: body + decoder.decode(), truncated: true };
|
|
730
794
|
}
|
|
731
795
|
body += decoder.decode(value, { stream: true });
|
|
732
796
|
}
|
|
@@ -772,7 +836,7 @@ async function mapLimit(items, limit, worker) {
|
|
|
772
836
|
*/
|
|
773
837
|
export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
774
838
|
const results = await mapLimit(feeds, CONCURRENCY, async (feed) => {
|
|
775
|
-
const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs });
|
|
839
|
+
const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs, maxBytes: FEED_MAX_BYTES, truncate: true });
|
|
776
840
|
if (!res.ok) return { feed, error: res.error };
|
|
777
841
|
let parsed;
|
|
778
842
|
try { parsed = parseFeed(res.body, { url: feed.url }); }
|
|
@@ -787,7 +851,8 @@ export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
|
|
|
787
851
|
let skipped = 0;
|
|
788
852
|
for (const result of results) {
|
|
789
853
|
if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; }
|
|
790
|
-
|
|
854
|
+
// Newest first inside a feed, so the cap keeps the entries worth keeping.
|
|
855
|
+
for (const item of result.parsed.items.slice(0, PER_FEED_ITEMS)) {
|
|
791
856
|
// Aggregator feeds wrap the publisher's URL in one of their own. Unwrap
|
|
792
857
|
// before deduping, so the same story arriving via Google News and via the
|
|
793
858
|
// publisher's own feed is recognised as one story rather than two.
|
|
@@ -1398,9 +1463,13 @@ const FIND_LIMIT = 25;
|
|
|
1398
1463
|
/**
|
|
1399
1464
|
* `news find <keyword,…>` — search every published list for feeds to subscribe to.
|
|
1400
1465
|
*
|
|
1401
|
-
* This is the way into the
|
|
1402
|
-
* a fine thing to search and
|
|
1403
|
-
*
|
|
1466
|
+
* This is the way into the lists: they hold a few thousand feeds between them,
|
|
1467
|
+
* which is a fine thing to search and a poor thing to read end to end, so the
|
|
1468
|
+
* catalogues are read here and only the matches become candidates for `add`.
|
|
1469
|
+
*
|
|
1470
|
+
* Every list is loaded on every search, which is why none of them may be
|
|
1471
|
+
* enormous — a 33,000-feed catalogue in here made this the slowest thing in the
|
|
1472
|
+
* pit and took `/rss` down with it.
|
|
1404
1473
|
*/
|
|
1405
1474
|
async function findCommand(request, { out, fail, fetchImpl, env }) {
|
|
1406
1475
|
const hits = [];
|
|
@@ -1418,7 +1487,7 @@ async function findCommand(request, { out, fail, fetchImpl, env }) {
|
|
|
1418
1487
|
}
|
|
1419
1488
|
|
|
1420
1489
|
// Ranked across every list, not within each one. Sorting per list and then
|
|
1421
|
-
// concatenating puts all of web3's weak matches above
|
|
1490
|
+
// concatenating puts all of web3's weak matches above journalists' exact ones
|
|
1422
1491
|
// purely because web3 is fetched first.
|
|
1423
1492
|
hits.sort((a, b) => scoreFeed(b, request.keywords) - scoreFeed(a, request.keywords));
|
|
1424
1493
|
|
|
@@ -1472,6 +1541,32 @@ async function addCommand(request, { out, fail, fetchImpl, env }) {
|
|
|
1472
1541
|
fail(` ${ash("too large to subscribe to wholesale — search it instead:")} ${bone(`/rss search <keyword>`)}`);
|
|
1473
1542
|
return 1;
|
|
1474
1543
|
}
|
|
1544
|
+
// A `format: "feed"` list is one feed wearing a list's name. Subscribed
|
|
1545
|
+
// without fetching it first: smallweb's firehose is ten megabytes of
|
|
1546
|
+
// headlines and not one of them is needed to decide the feed exists. Tagged
|
|
1547
|
+
// with the list name like any other list, so `rm smallweb` still takes it
|
|
1548
|
+
// back out.
|
|
1549
|
+
if (bundle?.format === "feed") {
|
|
1550
|
+
const { feeds, added, existed } = withFeed(loadFeeds(env), {
|
|
1551
|
+
name: bundle.name,
|
|
1552
|
+
title: bundle.title || bundle.name,
|
|
1553
|
+
url: bundle.url,
|
|
1554
|
+
site: bundle.site || "",
|
|
1555
|
+
category: bundle.name,
|
|
1556
|
+
});
|
|
1557
|
+
if (existed) { out(`${ash("· ")}already subscribed to ${bone(added.name)} ${ash(added.url)}`); return 0; }
|
|
1558
|
+
try { saveFeeds(feeds, env); }
|
|
1559
|
+
catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
|
|
1560
|
+
// Named from the OPML on the way back in, like every other feed, so the
|
|
1561
|
+
// name printed here is the one `--feed` will answer to rather than the one
|
|
1562
|
+
// that went in.
|
|
1563
|
+
const saved = findFeed(loadFeeds(env), bundle.url) ?? added;
|
|
1564
|
+
if (request.json) { out(JSON.stringify({ added: saved, skipped: 0 }, null, 2)); return 0; }
|
|
1565
|
+
out(`${acid("✓ ")}subscribed to ${bone(saved.name)} ${ash(`— ${bundle.description}`)}`);
|
|
1566
|
+
out(` ${ash("read it on its own with")} ${bone(`/news --feed ${saved.name}`)}`);
|
|
1567
|
+
return 0;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1475
1570
|
const target = bundle ? bundle.url : request.target;
|
|
1476
1571
|
if (bundle) out(`${ash("· ")}fetching ${bone(bundle.name)} ${ash(`— ${bundle.description}`)}`);
|
|
1477
1572
|
|
|
@@ -1491,6 +1586,17 @@ async function addCommand(request, { out, fail, fetchImpl, env }) {
|
|
|
1491
1586
|
// Tagged with the list it came from, so `rm <list>` can take it back out.
|
|
1492
1587
|
const incoming = bundle ? tagWithList(parsedList, bundle.name) : parsedList;
|
|
1493
1588
|
if (!incoming.length) { fail(danger(`✗ that ${bundle?.format === "text" ? "list" : "OPML file"} lists no feeds`)); return 1; }
|
|
1589
|
+
// A catalogue is not a subscription. `searchOnly` guarded the *name* but
|
|
1590
|
+
// never the URL, so `add https://kagi.com/smallweb/opml` wrote 32,969
|
|
1591
|
+
// outlines into news.opml and every `/rss` afterwards tried to fetch all of
|
|
1592
|
+
// them — the crash this was supposed to prevent, through the one door left
|
|
1593
|
+
// open. The limit sits well above every published list (the largest is 611)
|
|
1594
|
+
// and well below a catalogue.
|
|
1595
|
+
if (incoming.length > MAX_IMPORT_FEEDS) {
|
|
1596
|
+
fail(danger(`✗ that list holds ${incoming.length.toLocaleString()} feeds — more than ${MAX_IMPORT_FEEDS.toLocaleString()}, which is more than a reader can fetch`));
|
|
1597
|
+
fail(` ${ash("it is a catalogue, not a subscription — search it instead:")} ${bone("/rss search <keyword>")}`);
|
|
1598
|
+
return 1;
|
|
1599
|
+
}
|
|
1494
1600
|
let feeds = existing;
|
|
1495
1601
|
const added = [];
|
|
1496
1602
|
let skipped = 0;
|
package/src/tui.mjs
CHANGED
|
@@ -464,6 +464,35 @@ let activeMirror = null;
|
|
|
464
464
|
*/
|
|
465
465
|
const childSink = () => (activeMirror ? (chunk) => activeMirror?.write(chunk) : undefined);
|
|
466
466
|
|
|
467
|
+
/**
|
|
468
|
+
* How fast an exit has to be before it is worth remarking on.
|
|
469
|
+
*
|
|
470
|
+
* A person who opens an agent and immediately quits takes longer than this.
|
|
471
|
+
* Nothing that actually started a session lands under it.
|
|
472
|
+
*/
|
|
473
|
+
const INSTANT_EXIT_MS = 1500;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The note for a hand-off that ended the moment it began, or null.
|
|
477
|
+
*
|
|
478
|
+
* A CLI that exits 0 without doing anything is indistinguishable, from out
|
|
479
|
+
* here, from one the operator opened and closed — both are "exited (code 0)",
|
|
480
|
+
* which reads as success and sent somebody looking for the bug in the wrong
|
|
481
|
+
* program. It happens for real: @serjm/deepseek-code 0.5.0 compares
|
|
482
|
+
* `resolve(process.argv[1])` against `import.meta.url` to decide whether it is
|
|
483
|
+
* the entrypoint, and npm installs every global bin as a symlink, so the
|
|
484
|
+
* comparison fails and the whole CLI silently runs nothing.
|
|
485
|
+
*
|
|
486
|
+
* Timing is all we have — the child owns the terminal, so its output is not
|
|
487
|
+
* ours to inspect — and it is enough to say "this looks wrong" without
|
|
488
|
+
* claiming to know why.
|
|
489
|
+
*/
|
|
490
|
+
export function instantExitNote({ key, bin, code, ms }) {
|
|
491
|
+
if (code !== 0 || ms >= INSTANT_EXIT_MS) return null;
|
|
492
|
+
return `${key} exited instantly without running — that usually means a broken install, not a clean session.`
|
|
493
|
+
+ ` check it directly with \`${bin} --version\`, and reinstall with /install ${key} if that prints nothing.`;
|
|
494
|
+
}
|
|
495
|
+
|
|
467
496
|
async function openEngine(key, engine, args, { agentMode = false } = {}) {
|
|
468
497
|
if (!engine.installed && !args.length) {
|
|
469
498
|
console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
|
|
@@ -479,7 +508,9 @@ async function openEngine(key, engine, args, { agentMode = false } = {}) {
|
|
|
479
508
|
console.log(info(`opening ${bone(key)}${agentMode ? " autonomously" : " raw"} — hand-off to its CLI, exit it to come back…`));
|
|
480
509
|
console.log(hr());
|
|
481
510
|
activeMirror?.setEngine(key);
|
|
511
|
+
const startedAt = Date.now();
|
|
482
512
|
const r = await openSession(engine, agentMode ? agentLaunchArgs(engine, args) : args, { onOutput: childSink() });
|
|
513
|
+
const elapsed = Date.now() - startedAt;
|
|
483
514
|
activeMirror?.setEngine(null);
|
|
484
515
|
console.log(hr());
|
|
485
516
|
if (!r.ok) {
|
|
@@ -488,6 +519,8 @@ async function openEngine(key, engine, args, { agentMode = false } = {}) {
|
|
|
488
519
|
: err(`couldn't launch ${key}: ${r.error?.message || r.error}`));
|
|
489
520
|
} else {
|
|
490
521
|
console.log(info(`${key} exited${r.code != null ? ` (code ${r.code})` : ""}. back in the pit.`));
|
|
522
|
+
const note = instantExitNote({ key, bin: engine.bin, code: r.code, ms: elapsed });
|
|
523
|
+
if (note) console.log(warn(note));
|
|
491
524
|
}
|
|
492
525
|
}
|
|
493
526
|
|
|
@@ -497,7 +530,9 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
497
530
|
}
|
|
498
531
|
console.log(info(`opening ${bone(key)} — native CLI owns the terminal until it exits…`));
|
|
499
532
|
console.log(hr());
|
|
533
|
+
const toolStartedAt = Date.now();
|
|
500
534
|
const result = await openTool(tool, args, { onOutput: childSink() });
|
|
535
|
+
const toolElapsed = Date.now() - toolStartedAt;
|
|
501
536
|
console.log(hr());
|
|
502
537
|
if (!result.ok) {
|
|
503
538
|
console.log(result.error?.code === "ENOENT"
|
|
@@ -505,6 +540,8 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
505
540
|
: err(`couldn't launch ${key}: ${result.error?.message || result.error}`));
|
|
506
541
|
} else {
|
|
507
542
|
console.log(info(`${key} exited${result.code != null ? ` (code ${result.code})` : result.signal ? ` (${result.signal})` : ""}. back in the pit.`));
|
|
543
|
+
const note = instantExitNote({ key, bin: tool.bin, code: result.code, ms: toolElapsed });
|
|
544
|
+
if (note) console.log(warn(note));
|
|
508
545
|
}
|
|
509
546
|
}
|
|
510
547
|
|