@trawlme/cli 1.18.6 → 1.19.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.
@@ -1,10 +1,21 @@
1
1
  import { Command } from 'commander';
2
2
  export declare const scraps: Command;
3
3
  /** The top-of-history snapshot pollRunProgress needs to identify which run
4
- * it's watching — see captureBeforeRunState. */
4
+ * it's watching — see captureBeforeRunState.
5
+ *
6
+ * #97 — `captured` discriminates WHY `id` is undefined: `true` means the GET
7
+ * succeeded and the scrap genuinely has no history yet (an honest "never
8
+ * run" signal pollRunProgress can trust immediately); `false` means the GET
9
+ * itself threw, so `id`/`alreadyInFlight` carry NO information at all — the
10
+ * scrap could easily have prior (possibly terminal) history that this
11
+ * lookup simply never saw. Before this field existed, both cases produced
12
+ * the identical `{id: undefined, alreadyInFlight: false}` shape, so
13
+ * pollRunProgress could not tell them apart (see its own #97 comment).
14
+ */
5
15
  interface BeforeRunState {
6
16
  id?: string;
7
17
  alreadyInFlight: boolean;
18
+ captured: boolean;
8
19
  }
9
20
  /**
10
21
  * #91 P1 — replaces "await the run to completion, THEN open the activities
@@ -38,6 +49,35 @@ interface BeforeRunState {
38
49
  * (the dedup case) — a same-id row that was already TERMINAL at capture is
39
50
  * neither, and must not be latched onto as "done" (it's just the previous
40
51
  * run, still sitting there until a genuinely new run supersedes it).
52
+ *
53
+ * #97 — capture-failed fallback. The dedup-race fix above assumes `before`
54
+ * is trustworthy. When `captureBeforeRunState`'s own GET threw,
55
+ * `before.id` is `undefined` — and that is INDISTINGUISHABLE from "the
56
+ * scrap has genuinely never run" (also `id: undefined`), which is exactly
57
+ * the case `isNewRun` above is designed to match on the very first row that
58
+ * ever appears. So on the very first poll, ANY pre-existing history row —
59
+ * even the STALE PREVIOUS run, already terminal — satisfied
60
+ * `last._id !== undefined` and got reported as "the run we just launched"
61
+ * finishing, when it was really just whatever ran before.
62
+ *
63
+ * Fix: `before.captured === false` defers trusting a baseline at all.
64
+ * Instead of comparing against the (unknown) `beforeId` from the start, the
65
+ * FIRST successful poll read is treated as the deferred capture itself —
66
+ * exactly what `captureBeforeRunState` would have returned had its GET
67
+ * succeeded — and only READS from that point on are compared against it,
68
+ * via the exact same `isNewRun` / `isDedupOntoInFlight` logic above. A
69
+ * pre-existing terminal row observed on that first read becomes `beforeId`
70
+ * (not a match for itself), so it correctly falls into "still the stale
71
+ * previous run" below and the poll keeps waiting; a row still in flight
72
+ * becomes the `alreadyInFlight` baseline, exactly like a successful capture
73
+ * would have recorded. Either way this costs at most one extra poll
74
+ * interval, bounded by the same deadline as everything else. The one
75
+ * remaining edge case — capture failed AND the scrap never ran before AND
76
+ * the triggered run already finished by the very first poll — is genuinely
77
+ * undecidable from "stale pre-existing row" with no more information than
78
+ * this function has, so it resolves to the same honest timeout rather than
79
+ * risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
80
+ * telling the caller to check `doctor`).
41
81
  */
42
82
  export declare function pollRunProgress(id: string, before: BeforeRunState | undefined, opts?: {
43
83
  intervalMs?: number;
@@ -7,6 +7,7 @@ import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
8
8
  import { classifyError, reportError, UsageError } from '../lib/errors.js';
9
9
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
10
+ import { renderPinch, pinchEnabled } from '../lib/pinch.js';
10
11
  /**
11
12
  * Print a usage/validation error consistently: human text to stderr, or a
12
13
  * machine envelope on stdout under --json (never both — reportError is the
@@ -96,17 +97,21 @@ async function watchActivities(id) {
96
97
  * recognize that dedup case too, instead of waiting forever for an `_id`
97
98
  * that will never arrive (#93 item 1).
98
99
  *
99
- * Best-effort: a failed lookup falls back to `{alreadyInFlight:false}`,
100
- * which is still correct for a scrap that has never run (id undefined).
100
+ * #97 a failed lookup used to fall back to `{alreadyInFlight:false}` with
101
+ * no `id`, which LOOKED identical to "scrap has never run" but is not:
102
+ * the scrap may well have prior (possibly terminal) history this GET simply
103
+ * never observed. `captured:false` flags that distinction explicitly so
104
+ * pollRunProgress can defer trusting a baseline instead of risking a stale
105
+ * pre-existing row being reported as the run just launched.
101
106
  */
102
107
  async function captureBeforeRunState(id) {
103
108
  try {
104
109
  const scrap = await api.get(`/api/scraps/${id}`);
105
110
  const top = scrap.history?.[0];
106
- return { id: top?._id, alreadyInFlight: top?.status === null };
111
+ return { id: top?._id, alreadyInFlight: top?.status === null, captured: true };
107
112
  }
108
113
  catch {
109
- return { id: undefined, alreadyInFlight: false };
114
+ return { id: undefined, alreadyInFlight: false, captured: false };
110
115
  }
111
116
  }
112
117
  function sleep(ms) {
@@ -148,12 +153,46 @@ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
148
153
  * (the dedup case) — a same-id row that was already TERMINAL at capture is
149
154
  * neither, and must not be latched onto as "done" (it's just the previous
150
155
  * run, still sitting there until a genuinely new run supersedes it).
156
+ *
157
+ * #97 — capture-failed fallback. The dedup-race fix above assumes `before`
158
+ * is trustworthy. When `captureBeforeRunState`'s own GET threw,
159
+ * `before.id` is `undefined` — and that is INDISTINGUISHABLE from "the
160
+ * scrap has genuinely never run" (also `id: undefined`), which is exactly
161
+ * the case `isNewRun` above is designed to match on the very first row that
162
+ * ever appears. So on the very first poll, ANY pre-existing history row —
163
+ * even the STALE PREVIOUS run, already terminal — satisfied
164
+ * `last._id !== undefined` and got reported as "the run we just launched"
165
+ * finishing, when it was really just whatever ran before.
166
+ *
167
+ * Fix: `before.captured === false` defers trusting a baseline at all.
168
+ * Instead of comparing against the (unknown) `beforeId` from the start, the
169
+ * FIRST successful poll read is treated as the deferred capture itself —
170
+ * exactly what `captureBeforeRunState` would have returned had its GET
171
+ * succeeded — and only READS from that point on are compared against it,
172
+ * via the exact same `isNewRun` / `isDedupOntoInFlight` logic above. A
173
+ * pre-existing terminal row observed on that first read becomes `beforeId`
174
+ * (not a match for itself), so it correctly falls into "still the stale
175
+ * previous run" below and the poll keeps waiting; a row still in flight
176
+ * becomes the `alreadyInFlight` baseline, exactly like a successful capture
177
+ * would have recorded. Either way this costs at most one extra poll
178
+ * interval, bounded by the same deadline as everything else. The one
179
+ * remaining edge case — capture failed AND the scrap never ran before AND
180
+ * the triggered run already finished by the very first poll — is genuinely
181
+ * undecidable from "stale pre-existing row" with no more information than
182
+ * this function has, so it resolves to the same honest timeout rather than
183
+ * risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
184
+ * telling the caller to check `doctor`).
151
185
  */
152
186
  export async function pollRunProgress(id, before, opts = {}) {
153
187
  const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
154
188
  const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
155
- const beforeId = before?.id;
156
- const beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
189
+ let beforeId = before?.id;
190
+ let beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
191
+ // #97 — only an EXPLICIT captured:false (capture's GET actually threw)
192
+ // defers the baseline. `before` itself being undefined (callers that skip
193
+ // capture entirely) or `captured` being true/absent both mean "trust
194
+ // beforeId as given", preserving every existing call site's behavior.
195
+ let baselineEstablished = before?.captured ?? true;
157
196
  console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
158
197
  const deadline = Date.now() + timeoutMs;
159
198
  const seen = new Set();
@@ -172,6 +211,16 @@ export async function pollRunProgress(id, before, opts = {}) {
172
211
  const last = scrap.history?.[0];
173
212
  if (!last?._id)
174
213
  continue; // no history row recorded yet
214
+ if (!baselineEstablished) {
215
+ // #97 — deferred capture: whatever we see on this first successful
216
+ // read becomes the reference point, BEFORE the isNewRun check below
217
+ // ever runs against it. This must happen before that check, not
218
+ // after, so a pre-existing terminal row is recognized as stale on
219
+ // this very same iteration rather than one poll late.
220
+ beforeId = last._id;
221
+ beforeAlreadyInFlight = last.status === null;
222
+ baselineEstablished = true;
223
+ }
175
224
  const isNewRun = last._id !== beforeId;
176
225
  const isDedupOntoInFlight = last._id === beforeId && beforeAlreadyInFlight;
177
226
  if (!isNewRun && !isDedupOntoInFlight)
@@ -194,6 +243,14 @@ export async function pollRunProgress(id, before, opts = {}) {
194
243
  if (last.status !== null) {
195
244
  const outcome = last.statusDetail ?? (last.status ? 'success' : 'failure');
196
245
  console.log(chalk.dim(`Run finished: ${outcome}`));
246
+ // Pinch celebrates a clean run finish (#94) — mirrors doctor.ts's own
247
+ // `status === true` success definition (regardless of statusDetail),
248
+ // never for a failed/regression run. Neither `run --watch` nor
249
+ // `trigger --watch` (the only two callers) has a --json flag, so this
250
+ // is always safe to print. Guarded by pinchEnabled() (NO_COLOR/non-TTY).
251
+ if (last.status === true && pinchEnabled()) {
252
+ console.log(renderPinch('celebrating'));
253
+ }
197
254
  return;
198
255
  }
199
256
  }
package/dist/index.d.ts CHANGED
@@ -36,6 +36,15 @@ export declare function isEntryPoint(argv1: string | undefined, moduleUrl: strin
36
36
  * subcommand cases)
37
37
  */
38
38
  export declare function isHelpOrVersion(argv: string[]): boolean;
39
+ /**
40
+ * True for a bare `trawl` invocation — no subcommand, no flags at all. This
41
+ * is the CLI's "first thing a new user sees" moment (commander prints its
42
+ * own top-level help right after), distinct from `isHelpOrVersion` which is
43
+ * intentionally broader (also matches `--help`/`--version`/`trawl help`
44
+ * anywhere in argv) — the Pinch wave banner (#94) only wants the narrowest
45
+ * case so it never shows up ahead of e.g. `trawl scraps --help`.
46
+ */
47
+ export declare function isBareInvocation(argv: string[]): boolean;
39
48
  /** Best-effort scan for a `--json` flag in raw argv, used only when parsing
40
49
  * itself failed before any command's own `.opts()` could be resolved (a
41
50
  * commander usage error — unknown option/command, missing required arg). Same
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { token } from './commands/token.js';
11
11
  import { autoUpdateInstalledSkills } from './lib/skills.js';
12
12
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
13
13
  import { classifyError, reportError } from './lib/errors.js';
14
+ import { renderPinch, pinchEnabled } from './lib/pinch.js';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
16
17
  /**
@@ -96,6 +97,17 @@ export function isHelpOrVersion(argv) {
96
97
  return true;
97
98
  return false;
98
99
  }
100
+ /**
101
+ * True for a bare `trawl` invocation — no subcommand, no flags at all. This
102
+ * is the CLI's "first thing a new user sees" moment (commander prints its
103
+ * own top-level help right after), distinct from `isHelpOrVersion` which is
104
+ * intentionally broader (also matches `--help`/`--version`/`trawl help`
105
+ * anywhere in argv) — the Pinch wave banner (#94) only wants the narrowest
106
+ * case so it never shows up ahead of e.g. `trawl scraps --help`.
107
+ */
108
+ export function isBareInvocation(argv) {
109
+ return argv.slice(2).length === 0;
110
+ }
99
111
  /** Best-effort scan for a `--json` flag in raw argv, used only when parsing
100
112
  * itself failed before any command's own `.opts()` could be resolved (a
101
113
  * commander usage error — unknown option/command, missing required arg). Same
@@ -171,6 +183,12 @@ export function bestEffortCommandName(argv, allowedNames) {
171
183
  export async function runCli(argv = process.argv) {
172
184
  if (!isHelpOrVersion(argv))
173
185
  autoUpdateInstalledSkills();
186
+ // Pinch wave banner (#94) — bare `trawl` only, guarded so it never shows
187
+ // under NO_COLOR/non-TTY/piped output (pinchEnabled() covers all three).
188
+ if (isBareInvocation(argv) && pinchEnabled()) {
189
+ console.log(renderPinch('wave'));
190
+ console.log();
191
+ }
174
192
  initPostHog();
175
193
  const program = createProgram();
176
194
  // Must run before parseAsync — installs on every node in the tree,
@@ -270,6 +288,12 @@ export async function runCli(argv = process.argv) {
270
288
  // (stderr), never both; `quiet` skips the human line when the raw
271
289
  // stack was already dumped above under --debug.
272
290
  process.exitCode = reportError(err, { json: wantsJson, quiet: isDebug });
291
+ // Pinch confused frame (#94) — never under --json (stdout must stay
292
+ // pure JSON) and guarded by pinchEnabled() (NO_COLOR/non-TTY/piped).
293
+ // Printed to stderr, alongside reportError's own human line above.
294
+ if (!wantsJson && pinchEnabled()) {
295
+ console.error(renderPinch('confused'));
296
+ }
273
297
  }
274
298
  }
275
299
  finally {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pinch — Trawl's voxel lobster mascot, rendered as 24-bit ANSI half-block
3
+ * art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
4
+ * (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
5
+ *
6
+ * The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
7
+ * grid rows into one terminal row: the upper row's color becomes the
8
+ * half-block's foreground, the lower row's becomes its background, using the
9
+ * upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
10
+ * 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
11
+ * are transparent — no color escape is emitted for that half, so the
12
+ * terminal's own background shows through.
13
+ *
14
+ * See comes-io/trawl_cli#94.
15
+ */
16
+ export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
17
+ /**
18
+ * Render Pinch as 24-bit ANSI half-block art for the given state, plus a
19
+ * one-line caption. Pure — never touches process.env/stdout; callers must
20
+ * gate on `pinchEnabled()` before printing the result.
21
+ *
22
+ * `frame` only affects 'thinking' (2-frame spinner alternation); every other
23
+ * state ignores it.
24
+ */
25
+ export declare function renderPinch(state: PinchState, frame?: number): string;
26
+ /**
27
+ * True when it's safe to print Pinch art: a real color-capable interactive
28
+ * terminal. False under NO_COLOR (https://no-color.org — presence, not
29
+ * value, disables color output), a non-TTY stdout (piped/redirected output —
30
+ * covers --json/--quiet/CI log capture), or TERM=dumb.
31
+ */
32
+ export declare function pinchEnabled(): boolean;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pinch — Trawl's voxel lobster mascot, rendered as 24-bit ANSI half-block
3
+ * art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
4
+ * (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
5
+ *
6
+ * The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
7
+ * grid rows into one terminal row: the upper row's color becomes the
8
+ * half-block's foreground, the lower row's becomes its background, using the
9
+ * upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
10
+ * 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
11
+ * are transparent — no color escape is emitted for that half, so the
12
+ * terminal's own background shows through.
13
+ *
14
+ * See comes-io/trawl_cli#94.
15
+ */
16
+ /** Grid-char → RGB. 'C' (cyan) is a "thinking" blip, not present in BASE_GRID. */
17
+ const PALETTE = {
18
+ B: [0x3b, 0x82, 0xf6], // blue — shell
19
+ O: [0xee, 0x90, 0x18], // orange — antennae / claws
20
+ W: [0xff, 0xff, 0xff], // white — eye whites
21
+ K: [0x12, 0x30, 0x33], // navy — pupils / mouth
22
+ P: [0xf9, 0xa8, 0xd4], // pink — claw tips
23
+ C: [0x08, 0x91, 0xb2], // cyan — thinking-spinner antenna blip
24
+ };
25
+ const TRANSPARENT = '.';
26
+ /** Base 12×9 grid — Pinch waving (also the 'wave' state, unmodified). */
27
+ const BASE_GRID = [
28
+ '...O....O...',
29
+ '...O....O...',
30
+ '.BBBBBBBBBB.',
31
+ '.BWWBBBBWWB.',
32
+ '.BWKBBBBKWB.',
33
+ '.PBBBBBBBBP.',
34
+ '.BBBKBBKBBB.',
35
+ '.BBBBKKBBBB.',
36
+ 'OO........OO',
37
+ ];
38
+ const CAPTIONS = {
39
+ wave: 'Pinch says hi.',
40
+ thinking: 'Pinch is thinking…',
41
+ celebrating: 'Pinch is celebrating!',
42
+ confused: 'Pinch looks confused.',
43
+ };
44
+ /** Replace the chars at `indices` in `row` with `ch` — never mutates `row`. */
45
+ function setCells(row, indices, ch) {
46
+ const chars = row.split('');
47
+ for (const i of indices)
48
+ chars[i] = ch;
49
+ return chars.join('');
50
+ }
51
+ /** Derive the per-state grid from BASE_GRID (which is never mutated). */
52
+ function gridForState(state, frame) {
53
+ const rows = [...BASE_GRID];
54
+ switch (state) {
55
+ case 'thinking':
56
+ // Odd frames: both antenna tips (row 0, cols 3 & 8) blip cyan — a
57
+ // 2-frame spinner alternation with no layout shift.
58
+ if (frame % 2 === 1) {
59
+ rows[0] = setCells(rows[0], [3, 8], 'C');
60
+ }
61
+ return rows;
62
+ case 'celebrating':
63
+ // Fists up: the row-8 corner claws move up to row 7's corners; row 8's
64
+ // corners go transparent (arms raised, no longer at the sides).
65
+ rows[7] = setCells(rows[7], [0, 11], 'O');
66
+ rows[8] = setCells(rows[8], [0, 1, 10, 11], TRANSPARENT);
67
+ return rows;
68
+ case 'confused':
69
+ // Pupils removed (row 4, cols 3 & 8) — blank white eyes.
70
+ rows[4] = setCells(rows[4], [3, 8], 'W');
71
+ return rows;
72
+ case 'wave':
73
+ default:
74
+ return rows;
75
+ }
76
+ }
77
+ const RESET = '\x1b[0m';
78
+ const fgCode = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
79
+ const bgCode = ([r, g, b]) => `\x1b[48;2;${r};${g};${b}m`;
80
+ /**
81
+ * One terminal column: combine an upper (fg-half) + lower (bg-half) grid
82
+ * cell into a single half-block character. Either half may be transparent
83
+ * independently — no bg/fg escape is emitted for a transparent half, so the
84
+ * terminal's own background/foreground shows through it.
85
+ */
86
+ function renderCell(upper, lower) {
87
+ const upperColor = upper === TRANSPARENT ? null : PALETTE[upper];
88
+ const lowerColor = lower === TRANSPARENT ? null : PALETTE[lower];
89
+ if (!upperColor && !lowerColor)
90
+ return ' ';
91
+ // Lower half transparent — draw the upper half-block with fg only, no bg
92
+ // (the glyph's own unpainted lower half already shows the terminal bg).
93
+ if (!lowerColor)
94
+ return `${fgCode(upperColor)}▀${RESET}`;
95
+ // Upper half transparent — draw the LOWER half-block glyph instead, with
96
+ // fg set to the lower color and no bg, so the painted half is the bottom.
97
+ if (!upperColor)
98
+ return `${fgCode(lowerColor)}▄${RESET}`;
99
+ return `${fgCode(upperColor)}${bgCode(lowerColor)}▀${RESET}`;
100
+ }
101
+ /** Pack a 9-row grid into 5 terminal lines (rows 0-1, 2-3, 4-5, 6-7, 8-blank). */
102
+ function renderGrid(rows) {
103
+ const lines = [];
104
+ for (let i = 0; i < rows.length; i += 2) {
105
+ const upperRow = rows[i];
106
+ const lowerRow = rows[i + 1] ?? TRANSPARENT.repeat(upperRow.length);
107
+ let line = '';
108
+ for (let c = 0; c < upperRow.length; c++) {
109
+ line += renderCell(upperRow[c], lowerRow[c]);
110
+ }
111
+ lines.push(line);
112
+ }
113
+ return lines;
114
+ }
115
+ /**
116
+ * Render Pinch as 24-bit ANSI half-block art for the given state, plus a
117
+ * one-line caption. Pure — never touches process.env/stdout; callers must
118
+ * gate on `pinchEnabled()` before printing the result.
119
+ *
120
+ * `frame` only affects 'thinking' (2-frame spinner alternation); every other
121
+ * state ignores it.
122
+ */
123
+ export function renderPinch(state, frame = 0) {
124
+ const grid = gridForState(state, frame);
125
+ const lines = renderGrid(grid);
126
+ if (state === 'confused') {
127
+ // "beside the art" — a bold '?' to the right of the eye row (grid rows
128
+ // 4-5 pack into terminal line index 2).
129
+ lines[2] = `${lines[2]} \x1b[1m?${RESET}`;
130
+ }
131
+ return [...lines, CAPTIONS[state]].join('\n');
132
+ }
133
+ /**
134
+ * True when it's safe to print Pinch art: a real color-capable interactive
135
+ * terminal. False under NO_COLOR (https://no-color.org — presence, not
136
+ * value, disables color output), a non-TTY stdout (piped/redirected output —
137
+ * covers --json/--quiet/CI log capture), or TERM=dumb.
138
+ */
139
+ export function pinchEnabled() {
140
+ if (process.env['NO_COLOR'] !== undefined)
141
+ return false;
142
+ if (!process.stdout.isTTY)
143
+ return false;
144
+ if (process.env['TERM'] === 'dumb')
145
+ return false;
146
+ return true;
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.6",
3
+ "version": "1.19.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {