cli-jaw 2.2.12 → 2.2.13

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.
Files changed (64) hide show
  1. package/dist/src/agent/error-classifier.js +11 -2
  2. package/dist/src/agent/error-classifier.js.map +1 -1
  3. package/dist/src/agent/events/claude.js +7 -1
  4. package/dist/src/agent/events/claude.js.map +1 -1
  5. package/dist/src/agent/events/fulltext-bound.js +32 -0
  6. package/dist/src/agent/events/fulltext-bound.js.map +1 -0
  7. package/dist/src/agent/events/grok.js +7 -1
  8. package/dist/src/agent/events/grok.js.map +1 -1
  9. package/dist/src/agent/events/helpers.js +17 -4
  10. package/dist/src/agent/events/helpers.js.map +1 -1
  11. package/dist/src/agent/lifecycle-handler.js +56 -5
  12. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  13. package/dist/src/agent/pi-runtime.js +30 -1
  14. package/dist/src/agent/pi-runtime.js.map +1 -1
  15. package/dist/src/agent/spawn/exit-drain.js +96 -0
  16. package/dist/src/agent/spawn/exit-drain.js.map +1 -0
  17. package/dist/src/agent/spawn/line-buffer.js +26 -0
  18. package/dist/src/agent/spawn/line-buffer.js.map +1 -0
  19. package/dist/src/agent/spawn/process-kill.js +31 -0
  20. package/dist/src/agent/spawn/process-kill.js.map +1 -1
  21. package/dist/src/agent/spawn.js +80 -23
  22. package/dist/src/agent/spawn.js.map +1 -1
  23. package/dist/src/agent/watchdog.js +21 -5
  24. package/dist/src/agent/watchdog.js.map +1 -1
  25. package/dist/src/routes/link-preview.js +14 -0
  26. package/dist/src/routes/link-preview.js.map +1 -1
  27. package/package.json +3 -1
  28. package/public/dist/assets/CodeCanvas-CbfPP7Ad.js +3 -0
  29. package/public/dist/assets/{DocPanel-DtYwv3n3.js → DocPanel-Dz-J_l5M.js} +1 -1
  30. package/public/dist/assets/MarkdownRenderer-CXrilOsG.js +15 -0
  31. package/public/dist/assets/MarkdownRenderer-DZVh_xX2.js +1 -0
  32. package/public/dist/assets/{MilkdownWysiwygEditor-BNoLGbFC.js → MilkdownWysiwygEditor-Em7iC6y9.js} +8 -8
  33. package/public/dist/assets/{react-dom-AWKMTIe7.js → bounded-set-CFlZiEF4.js} +1 -1
  34. package/public/dist/assets/manager-DaN201mN.js +13 -0
  35. package/public/dist/assets/{wiki-link-resolver-BfNXN9px.js → wiki-link-resolver-DyFzBTIQ.js} +1 -1
  36. package/public/dist/manager/index.html +1 -1
  37. package/public/manager/src/SidebarRailRouter.tsx +9 -3
  38. package/public/manager/src/browser-panel/use-embedded-target-sync.ts +19 -1
  39. package/public/manager/src/code/CodeCanvas.tsx +14 -2
  40. package/public/manager/src/code/CodeTranscript.tsx +33 -9
  41. package/public/manager/src/code/CodeWorkbench.tsx +1 -1
  42. package/public/manager/src/code/code-event-dedupe.ts +27 -3
  43. package/public/manager/src/code/use-code-transcript-scroll.ts +28 -3
  44. package/public/manager/src/code/use-throttled-markdown.ts +75 -0
  45. package/public/manager/src/code/useCodeTranscriptVirtualRows.ts +19 -0
  46. package/public/manager/src/electron-metrics.tsx +7 -0
  47. package/public/manager/src/jaw-ceo/useJawCeo.ts +6 -1
  48. package/public/manager/src/jaw-ceo/useJawCeoVirtualTimeline.ts +5 -0
  49. package/public/manager/src/lib/bounded-set.ts +15 -0
  50. package/public/manager/src/lib/fnv1a.ts +14 -0
  51. package/public/manager/src/notes/rendering/CodeBlock.tsx +13 -4
  52. package/public/manager/src/notes/rendering/MarkdownRenderer.tsx +14 -2
  53. package/public/manager/src/notes/rendering/highlight-cache.ts +111 -0
  54. package/public/manager/src/notes/useNotesExternalSync.ts +6 -1
  55. package/scripts/bundle-sidecar.sh +8 -0
  56. package/scripts/check-electron-no-native.cjs +90 -4
  57. package/scripts/check-native-load.cjs +157 -0
  58. package/scripts/check-sidecar-smoke.mjs +151 -0
  59. package/scripts/ensure-native-modules.cjs +222 -18
  60. package/scripts/release-gates.mjs +29 -0
  61. package/public/dist/assets/CodeCanvas-BZo3dohZ.js +0 -3
  62. package/public/dist/assets/MarkdownRenderer-CHacL2M_.js +0 -15
  63. package/public/dist/assets/MarkdownRenderer-DyrB9FbZ.js +0 -1
  64. package/public/dist/assets/manager-DL1GygQL.js +0 -13
@@ -0,0 +1,111 @@
1
+ import { highlightCode, normalizeCodeLanguage, type HighlightResult } from './highlight-languages';
2
+
3
+ /**
4
+ * D2 (260803 unit, 020 phase): `highlightCode` is pure but was called straight
5
+ * from a render body, so a large block was re-tokenized on every parent render
6
+ * and again every time a virtualized row scrolled back into view.
7
+ *
8
+ * Bounds are deliberately smaller than t3code's 500 entries / 50MB
9
+ * (apps/web/src/components/ChatMarkdown.tsx:110): highlighting is one panel
10
+ * among many here and this phase exists to reduce RAM, not trade it.
11
+ */
12
+ const MAX_ENTRIES = 200;
13
+ const MAX_BYTES = 8 * 1024 * 1024;
14
+
15
+ /**
16
+ * Skip highlighting entirely past this size. hljs cost is superlinear in
17
+ * pathological input, and a minified bundle pasted into a message should not
18
+ * stall the main thread. t3code caps per line (tokenizeMaxLineLength: 1_000);
19
+ * hljs has no such knob, so we cap the whole block instead.
20
+ */
21
+ const MAX_HIGHLIGHT_CHARS = 100_000;
22
+
23
+ /**
24
+ * Reject pathological single lines before hljs ever sees them. A minified
25
+ * bundle pasted into a message is one enormous line, and tokenizer cost is
26
+ * superlinear there. t3code enforces the same idea with
27
+ * `tokenizeMaxLineLength: 1_000` (apps/web/src/components/DiffWorkerPoolProvider.tsx:75);
28
+ * hljs exposes no equivalent option, so we pre-check instead.
29
+ */
30
+ const MAX_HIGHLIGHT_LINE_CHARS = 1_000;
31
+
32
+ type Entry = { result: HighlightResult; bytes: number };
33
+
34
+ // Map preserves insertion order, which gives LRU for free: delete + re-set on
35
+ // hit moves an entry to the newest position.
36
+ const cache = new Map<string, Entry>();
37
+ let cachedBytes = 0;
38
+
39
+ function estimateBytes(key: string, result: HighlightResult): number {
40
+ // The key embeds a full copy of the source, so counting only the html
41
+ // would undercount retention by roughly half.
42
+ return (key.length + result.html.length) * 2;
43
+ }
44
+
45
+ function evictUntilWithinBounds(): void {
46
+ while ((cache.size > MAX_ENTRIES || cachedBytes > MAX_BYTES) && cache.size > 0) {
47
+ const oldest = cache.keys().next();
48
+ if (oldest.done) break;
49
+ const entry = cache.get(oldest.value);
50
+ cache.delete(oldest.value);
51
+ if (entry) cachedBytes -= entry.bytes;
52
+ }
53
+ }
54
+
55
+ function escapeHtml(code: string): string {
56
+ return code.replace(/[&<>"']/g, value => ({
57
+ '&': '&amp;',
58
+ '<': '&lt;',
59
+ '>': '&gt;',
60
+ '"': '&quot;',
61
+ "'": '&#39;',
62
+ })[value] ?? value);
63
+ }
64
+
65
+ function hasOverlongLine(code: string): boolean {
66
+ let lineStart = 0;
67
+ for (let i = 0; i < code.length; i += 1) {
68
+ if (code.charCodeAt(i) !== 10) continue;
69
+ if (i - lineStart > MAX_HIGHLIGHT_LINE_CHARS) return true;
70
+ lineStart = i + 1;
71
+ }
72
+ return code.length - lineStart > MAX_HIGHLIGHT_LINE_CHARS;
73
+ }
74
+
75
+ export function highlightCodeCached(code: string, language?: string): HighlightResult {
76
+ // Normalize first: `TypeScript`, `typescript`, and `language-typescript`
77
+ // all resolve to one result, so they must share one cache entry.
78
+ const normalized = normalizeCodeLanguage(language);
79
+
80
+ if (code.length > MAX_HIGHLIGHT_CHARS || hasOverlongLine(code)) {
81
+ return { html: escapeHtml(code), language: normalized, highlighted: false };
82
+ }
83
+
84
+ const key = `${normalized}\u0000${code}`;
85
+ const hit = cache.get(key);
86
+ if (hit) {
87
+ cache.delete(key);
88
+ cache.set(key, hit);
89
+ return hit.result;
90
+ }
91
+
92
+ const result = highlightCode(code, language);
93
+ const bytes = estimateBytes(key, result);
94
+ // A single oversized entry would evict everything else for no benefit.
95
+ if (bytes <= MAX_BYTES) {
96
+ cache.set(key, { result, bytes });
97
+ cachedBytes += bytes;
98
+ evictUntilWithinBounds();
99
+ }
100
+ return result;
101
+ }
102
+
103
+ /** Test seam. */
104
+ export function __resetHighlightCache(): void {
105
+ cache.clear();
106
+ cachedBytes = 0;
107
+ }
108
+
109
+ export function __highlightCacheStats(): { entries: number; bytes: number } {
110
+ return { entries: cache.size, bytes: cachedBytes };
111
+ }
@@ -31,7 +31,12 @@ export function useNotesExternalSync(active: boolean): void {
31
31
  }
32
32
 
33
33
  void poll();
34
- const timer = setInterval(() => void poll(), POLL_INTERVAL_MS);
34
+ // D4: a hidden window cannot show an external change, so polling for
35
+ // one is pure idle cost. Matches useRemindersFeed's guard.
36
+ const timer = setInterval(() => {
37
+ if (typeof document !== 'undefined' && document.hidden) return;
38
+ void poll();
39
+ }, POLL_INTERVAL_MS);
35
40
  return () => { cancelled = true; clearInterval(timer); };
36
41
  }, [active]);
37
42
  }
@@ -165,5 +165,13 @@ fi
165
165
 
166
166
  node "$PROJECT_ROOT/scripts/check-electron-sidecar-no-jwc.cjs" --server-root "$SIDECAR_DIR"
167
167
 
168
+ # Static prune analysis runs before the build; this runs after, on the artifact
169
+ # that will actually ship. The prune guard reasons about bare specifiers and
170
+ # cannot see a computed `import(spec)`, so it can only ever be as complete as
171
+ # its manual RUNTIME_LOADED list. Importing the critical modules for real
172
+ # closes that gap by construction — a dashboard returning 200 never proved the
173
+ # Telegram bot could load.
174
+ node "$PROJECT_ROOT/scripts/check-sidecar-smoke.mjs" --server-root "$SIDECAR_DIR"
175
+
168
176
  echo "=== Sidecar ready ==="
169
177
  du -sh "$SIDECAR_DIR"
@@ -1,7 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  const { execSync } = require('node:child_process');
3
- const NATIVE = ['better-sqlite3', 'playwright-core', 'sharp', 'canvas'];
4
- const pat = NATIVE.join('|');
3
+
4
+ // Split, because a single list encoded a false invariant. node-pty IS a native
5
+ // addon, IS an electron dependency, and IS imported by the terminal module —
6
+ // so "electron source has no native deps" was simply untrue, and the one addon
7
+ // that actually ships was the one nothing checked.
8
+ //
9
+ // FORBIDDEN: must never appear in electron/src. Any of these would drag a
10
+ // second native build into the Electron ABI.
11
+ const FORBIDDEN = ['better-sqlite3', 'playwright-core', 'sharp', 'canvas'];
12
+ // EXPECTED_NATIVE: legitimately shipped. Presence here is not a failure, but it
13
+ // IS a promise that a runtime load probe covers it — see check-native-load.cjs.
14
+ const EXPECTED_NATIVE = ['node-pty'];
15
+ const pat = FORBIDDEN.join('|');
5
16
  // Match any of:
6
17
  // from "pkg" / from 'pkg'
7
18
  // require("pkg") / require('pkg')
@@ -13,7 +24,82 @@ try {
13
24
  } catch (e) { out = ''; }
14
25
  const matches = out.trim();
15
26
  if (matches) {
16
- console.error('❌ Native dep imports detected in electron/src:\n' + matches);
27
+ console.error('❌ Forbidden native dep imports detected in electron/src:\n' + matches);
17
28
  process.exit(1);
18
29
  }
19
- console.log('✅ Electron source has no native deps');
30
+
31
+ // A bare-specifier grep only sees the first hop. electron/src already reaches
32
+ // across the tree boundary with relative paths (electron/src/main/lib/folder/ipc.ts
33
+ // imports into src/manager/git/*), and src/manager/reminders/store.ts imports
34
+ // better-sqlite3 — so the guard is one careless import away from being wrong
35
+ // while still printing a pass. Follow the relative graph transitively.
36
+ const { existsSync, readFileSync } = require('node:fs');
37
+ const { dirname, resolve, join } = require('node:path');
38
+
39
+ function resolveModule(spec, fromFile) {
40
+ const base = resolve(dirname(fromFile), spec.replace(/\.js$/, ''));
41
+ for (const candidate of [
42
+ `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`,
43
+ join(base, 'index.ts'), join(base, 'index.tsx'),
44
+ `${base}.js`,
45
+ ]) {
46
+ if (existsSync(candidate)) return candidate;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** `sharp` and `sharp/lib/x.js` are the same dependency. */
52
+ function isForbidden(spec) {
53
+ return FORBIDDEN.some(pkg => spec === pkg || spec.startsWith(`${pkg}/`));
54
+ }
55
+
56
+ function crawl(entryFiles) {
57
+ const seen = new Set();
58
+ const queue = [...entryFiles];
59
+ const violations = [];
60
+ let unresolved = 0;
61
+ while (queue.length > 0) {
62
+ const file = queue.pop();
63
+ if (!file || seen.has(file)) continue;
64
+ seen.add(file);
65
+ let source = '';
66
+ try { source = readFileSync(file, 'utf8'); } catch { continue; }
67
+ // Covers `from 'x'`, `require('x')`, `import('x')`, and the bare
68
+ // side-effect form `import 'x';` — the last one is easy to miss and is
69
+ // exactly how an unnoticed dependency creeps in.
70
+ const specRe = /(?:from|require\(|import\(|^\s*import)\s*['"]([^'"]+)['"]/gm;
71
+ let match;
72
+ while ((match = specRe.exec(source)) !== null) {
73
+ const spec = match[1];
74
+ if (isForbidden(spec)) {
75
+ violations.push(`${file}: imports ${spec}`);
76
+ continue;
77
+ }
78
+ if (!spec.startsWith('.')) continue;
79
+ const resolved = resolveModule(spec, file);
80
+ if (resolved) queue.push(resolved);
81
+ // A silently dropped specifier is a hole in the crawl that looks
82
+ // identical to a clean result, so count and report them.
83
+ else unresolved += 1;
84
+ }
85
+ }
86
+ return { violations, scanned: seen.size, unresolved };
87
+ }
88
+
89
+ let entries = [];
90
+ try {
91
+ entries = execSync('find electron/src -name "*.ts" -o -name "*.tsx"', { encoding: 'utf8' })
92
+ .split('\n').map(s => s.trim()).filter(Boolean).map(f => resolve(f));
93
+ } catch { entries = []; }
94
+
95
+ const { violations, scanned, unresolved } = crawl(entries);
96
+ if (violations.length > 0) {
97
+ console.error('❌ Forbidden native dep reachable from electron/src via relative imports:');
98
+ for (const line of violations) console.error(` - ${line}`);
99
+ console.error('\nThese would need an Electron-ABI rebuild; the sidecar builds for Node.');
100
+ process.exit(1);
101
+ }
102
+ // Known limit, stated rather than implied: computed specifiers
103
+ // (`import(`../${name}.js`)`) and `createRequire(...)('pkg')` are invisible to
104
+ // a text scan — the same blind spot the sidecar prune guard documents.
105
+ console.log(`✅ Electron source has no forbidden native deps (scanned ${scanned} files transitively${unresolved ? `, ${unresolved} relative specifiers unresolved` : ''}; expected native: ${EXPECTED_NATIVE.join(', ')})`);
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime load probe for shipped native addons (260803 unit, 040 phase D1).
4
+ *
5
+ * Static grep proves a module is imported. It does not prove the binary loads.
6
+ * The v2.2.10 incident made that concrete: the dashboard returned HTTP 200
7
+ * while the Telegram bot could not resolve a pruned dependency. Presence is
8
+ * not liveness.
9
+ *
10
+ * node-pty@1.1.0 is N-API based (node-addon-api, 38 napi_ symbols, zero v8/Nan),
11
+ * so a NODE_MODULE_VERSION mismatch is NOT the risk here. What can still break
12
+ * is architecture, asarUnpack placement, and the executable bit on
13
+ * `spawn-helper` — none of which a grep can see. So we dlopen the binary and,
14
+ * where possible, actually run a pty.
15
+ *
16
+ * Usage:
17
+ * node scripts/check-native-load.cjs # check the repo tree
18
+ * node scripts/check-native-load.cjs --app <path/to.app> # check a packaged app
19
+ */
20
+ const { existsSync, statSync, readdirSync, constants, accessSync } = require('node:fs');
21
+ const { join } = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ const args = process.argv.slice(2);
25
+ const appIndex = args.indexOf('--app');
26
+ const appPath = appIndex >= 0 ? args[appIndex + 1] : null;
27
+
28
+ const failures = [];
29
+ const notes = [];
30
+
31
+ function fail(message) { failures.push(message); }
32
+ function note(message) { notes.push(message); }
33
+
34
+ function findPtyRoot() {
35
+ if (appPath) {
36
+ // electron-builder unpacks asarUnpack entries next to the asar.
37
+ const unpacked = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules', 'node-pty');
38
+ if (existsSync(unpacked)) return unpacked;
39
+ const plain = join(appPath, 'Contents', 'Resources', 'app', 'node_modules', 'node-pty');
40
+ if (existsSync(plain)) return plain;
41
+ return null;
42
+ }
43
+ const local = join(process.cwd(), 'electron', 'node_modules', 'node-pty');
44
+ return existsSync(local) ? local : null;
45
+ }
46
+
47
+ const ptyRoot = findPtyRoot();
48
+ // Exit 3 = nothing was probed. The caller must not present this as a pass;
49
+ // a green "loads fine" when nothing loaded is the exact dishonesty this
50
+ // script exists to remove.
51
+ const EXIT_SKIPPED = 3;
52
+ if (!ptyRoot) {
53
+ if (appPath) {
54
+ fail(`node-pty not found in ${appPath}. asarUnpack must keep it outside the asar (electron-builder.yml asarUnpack: node_modules/node-pty/**/*); a packed addon cannot be dlopen'd.`);
55
+ } else if (process.env.JAW_GATE_REQUIRE_NATIVE === '1') {
56
+ // Opt-in requirement rather than a blanket CI check. The node-tests
57
+ // workflow runs `npm ci --ignore-scripts` at the root only, so it never
58
+ // has electron/node_modules at all: keying on CI made this gate demand an
59
+ // artifact that context cannot produce, and every PR went red for it.
60
+ // The flag is set where the artifact really exists (desktop-release, right
61
+ // after `npm ci --prefix electron`), so a miss there is a genuine failure.
62
+ fail('electron/node_modules/node-pty is absent but JAW_GATE_REQUIRE_NATIVE=1 demanded a real probe (run npm i in electron/)');
63
+ } else {
64
+ console.log('ℹ node-pty not installed in electron/node_modules — nothing probed (run npm i in electron/)');
65
+ process.exit(EXIT_SKIPPED);
66
+ }
67
+ } else {
68
+ const binary = join(ptyRoot, 'build', 'Release', 'pty.node');
69
+ if (!existsSync(binary)) {
70
+ fail(`missing native binary: ${binary}`);
71
+ } else {
72
+ // 1. The binary must actually dlopen under this runtime.
73
+ try {
74
+ const handle = { exports: {} };
75
+ process.dlopen(handle, binary, constants.dlopen?.RTLD_NOW ?? undefined);
76
+ note(`dlopen ok: ${binary}`);
77
+ } catch (error) {
78
+ fail(`dlopen failed for ${binary}: ${(error && error.message) || error}`);
79
+ }
80
+
81
+ // 2. spawn-helper must be present AND executable, or every pty dies at
82
+ // runtime with a permission error that no import check would catch.
83
+ const helper = join(ptyRoot, 'build', 'Release', 'spawn-helper');
84
+ if (process.platform !== 'win32') {
85
+ if (!existsSync(helper)) {
86
+ fail(`missing spawn-helper: ${helper}`);
87
+ } else {
88
+ try {
89
+ accessSync(helper, constants.X_OK);
90
+ note(`spawn-helper executable: mode ${(statSync(helper).mode & 0o777).toString(8)}`);
91
+ } catch {
92
+ fail(`spawn-helper is not executable: ${helper} (mode ${(statSync(helper).mode & 0o777).toString(8)})`);
93
+ }
94
+ }
95
+ }
96
+
97
+ // 3. Architecture must match the host, or dlopen succeeds nowhere useful.
98
+ try {
99
+ const prebuilds = join(ptyRoot, 'prebuilds');
100
+ if (existsSync(prebuilds)) {
101
+ note(`prebuilds present: ${readdirSync(prebuilds).join(', ')}`);
102
+ }
103
+ } catch { /* informational only */ }
104
+
105
+ // 4. Actually spawn a pty and read from it. access(X_OK) says the bit is
106
+ // set; it does not say codesign, quarantine, or arch let the helper run.
107
+ // Only a real spawn covers that, and it is the failure a user would hit
108
+ // on their first terminal session in the shipped app.
109
+ if (!appPath) {
110
+ // Run the round-trip in a child so we can wait on it without blocking
111
+ // this process's event loop — pty data arrives via callbacks, so a
112
+ // synchronous wait here would guarantee a false negative.
113
+ const probe = `
114
+ const pty = require(${JSON.stringify(ptyRoot)});
115
+ // cmd.exe does not understand POSIX -c; it needs /c (with /d /s to skip
116
+ // AutoRun and keep quote handling predictable). Passing -c made the
117
+ // shell exit non-zero and print usage, so the probe would have reported
118
+ // a broken spawn-helper on Windows even when the addon was healthy.
119
+ const isWin = process.platform === 'win32';
120
+ const shell = isWin ? 'cmd.exe' : '/bin/sh';
121
+ const shellArgs = isWin ? ['/d', '/s', '/c', 'echo jaw-pty-ok'] : ['-c', 'echo jaw-pty-ok'];
122
+ const term = pty.spawn(shell, shellArgs, {
123
+ name: 'xterm-color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
124
+ });
125
+ let seen = '';
126
+ const done = (code) => { try { term.kill(); } catch {} process.exit(code); };
127
+ term.onData((chunk) => { seen += chunk; if (seen.includes('jaw-pty-ok')) done(0); });
128
+ setTimeout(() => done(seen.includes('jaw-pty-ok') ? 0 : 7), 5000);
129
+ `;
130
+ try {
131
+ execFileSync(process.execPath, ['-e', probe], { timeout: 15_000, stdio: 'ignore' });
132
+ note('pty spawn round-trip ok (spawn-helper genuinely executes)');
133
+ } catch (error) {
134
+ const status = error && error.status;
135
+ if (status === 7) {
136
+ fail('pty spawned but produced no output within 5s — spawn-helper may be blocked by codesign/quarantine');
137
+ } else {
138
+ // The child's message embeds the whole probe source; keep only the
139
+ // first meaningful line so the build log stays readable.
140
+ const raw = String((error && error.message) || error);
141
+ const firstLine = raw.split('\n').find(l => /Error|EACCES|EPERM|ENOENT/.test(l)) || raw.split('\n')[0];
142
+ fail(`pty spawn failed: ${firstLine.trim()}`);
143
+ }
144
+ }
145
+ } else {
146
+ note('packaged app: spawn round-trip skipped (needs the Electron runtime); dlopen + permissions checked');
147
+ }
148
+ }
149
+ }
150
+
151
+ for (const line of notes) console.log(` ${line}`);
152
+ if (failures.length > 0) {
153
+ console.error('❌ native load probe failed:');
154
+ for (const line of failures) console.error(` - ${line}`);
155
+ process.exit(1);
156
+ }
157
+ console.log(`✅ native addons load${appPath ? ` in ${appPath}` : ''}`);
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-bundle import smoke for the packaged sidecar (260803 unit, 040 phase D3).
4
+ *
5
+ * `check-sidecar-prune-safety.mjs` reasons about the prune list statically: it
6
+ * parses PRUNE_PKGS out of the shell script, scans source for bare specifiers,
7
+ * and walks the transitive closure through dependencies/optionalDependencies/
8
+ * peerDependencies. That closure is what catches the second half of the v2.2.10
9
+ * incident (`node-fetch → fetch-blob → web-streams-polyfill`).
10
+ *
11
+ * What it cannot see is a computed specifier — `import(someVariable)`. The
12
+ * authors knew, which is why RUNTIME_LOADED exists as a manual escape hatch
13
+ * with exactly one entry. Any future dynamic import that nobody remembers to
14
+ * register reproduces the incident in a new shape.
15
+ *
16
+ * This closes that by construction: after the bundle exists, actually import
17
+ * the modules whose failure was invisible last time. A dashboard returning 200
18
+ * never proved the Telegram bot could load; importing it does.
19
+ *
20
+ * Usage: node scripts/check-sidecar-smoke.mjs [--server-root <dir>]
21
+ */
22
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
23
+ import { join, resolve } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { tmpdir } from 'node:os';
27
+
28
+ const args = process.argv.slice(2);
29
+ const rootIndex = args.indexOf('--server-root');
30
+ // An explicit --server-root means the caller built the bundle and knows where
31
+ // it should be (bundle-sidecar.sh passes it immediately after bundling), so a
32
+ // missing tree there is a real failure rather than "nothing to check".
33
+ const explicitServerRoot = rootIndex >= 0;
34
+ if (explicitServerRoot && !args[rootIndex + 1]) {
35
+ console.error('❌ --server-root requires a directory argument');
36
+ process.exit(2);
37
+ }
38
+ const serverRoot = resolve(explicitServerRoot ? args[rootIndex + 1] : 'electron/sidecar/server');
39
+
40
+ /**
41
+ * Entry surfaces whose load failure would be silent in a running app: each is
42
+ * reached only on a specific user action, so a healthy dashboard says nothing
43
+ * about them. `telegram/bot.js` is the exact module the v2.2.10 prune broke.
44
+ */
45
+ const CRITICAL_MODULES = [
46
+ 'dist/src/telegram/bot.js',
47
+ 'dist/server.js',
48
+ 'dist/src/manager/server.js',
49
+ ];
50
+
51
+ /**
52
+ * Exit code 3 = "nothing to check". The caller must NOT report this as a pass:
53
+ * a gate that says "imports verified" when it imported nothing is the same
54
+ * dishonest-green this script exists to eliminate.
55
+ */
56
+ const EXIT_SKIPPED = 3;
57
+
58
+ if (!existsSync(serverRoot)) {
59
+ // Keying this on CI made the gate demand a bundle that the node-tests
60
+ // workflow never builds, so every PR failed here. Require a real check
61
+ // only where the caller actually produced (or explicitly named) the tree.
62
+ if (explicitServerRoot || process.env['JAW_GATE_REQUIRE_SIDECAR'] === '1') {
63
+ console.error(`❌ sidecar not bundled at ${serverRoot} but the caller required a real smoke test`);
64
+ process.exit(1);
65
+ }
66
+ console.log(`ℹ sidecar not bundled at ${serverRoot} — skipping smoke (run scripts/bundle-sidecar.sh first)`);
67
+ process.exit(EXIT_SKIPPED);
68
+ }
69
+
70
+ const failures = [];
71
+ let checked = 0;
72
+ const seenCauses = new Set();
73
+
74
+ // Redirect the probe's home to a throwaway directory. Importing these modules
75
+ // regenerates AGENTS.md and touches the DB; a gate must not mutate the
76
+ // developer's real ~/.cli-jaw just by checking that the bundle loads.
77
+ const probeHome = mkdtempSync(join(tmpdir(), 'jaw-smoke-home-'));
78
+
79
+ for (const relative of CRITICAL_MODULES) {
80
+ const target = join(serverRoot, relative);
81
+ if (!existsSync(target)) {
82
+ failures.push(`${relative}: not present in the bundle`);
83
+ continue;
84
+ }
85
+ checked += 1;
86
+ // Import in a CHILD process, never in-process. These modules are not inert:
87
+ // dist/server.js binds ports, opens the DB, starts timers and writes user
88
+ // state. Importing them here would hang the build forever and mutate the
89
+ // developer's ~/.cli-jaw. We only need to know that resolution + top-level
90
+ // evaluation get far enough to not throw, so a bounded child that we kill
91
+ // is both sufficient and safe.
92
+ const child = spawnSync(
93
+ process.execPath,
94
+ ['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(target).href)}); process.exit(0);`],
95
+ {
96
+ encoding: 'utf8',
97
+ timeout: 30_000,
98
+ killSignal: 'SIGKILL',
99
+ env: {
100
+ ...process.env,
101
+ JAW_SMOKE_PROBE: '1',
102
+ CLI_JAW_HOME: probeHome,
103
+ // Keep the probe off any port the developer or CI is using.
104
+ PORT: '0',
105
+ DASHBOARD_PORT: '0',
106
+ },
107
+ },
108
+ );
109
+
110
+ // A module that boots a server never exits on its own; the timeout kill is
111
+ // the SUCCESS signal there, because it means the import itself resolved.
112
+ const timedOut = child.error && child.error.code === 'ETIMEDOUT';
113
+ const importFailed = !timedOut
114
+ && child.status !== 0
115
+ && /ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM|Cannot find package|Cannot find module/.test(child.stderr || '');
116
+
117
+ if (importFailed) {
118
+ // Prefer the human-readable "Cannot find package 'x'" line over the
119
+ // internal `throw new ERR_MODULE_NOT_FOUND(...)` frame, which names no
120
+ // package and is useless in a build log.
121
+ const stderrLines = (child.stderr || '').split('\n');
122
+ const line = stderrLines.find(l => /Cannot find (package|module)/.test(l))
123
+ || stderrLines.find(l => /ERR_[A-Z_]+/.test(l))
124
+ || `exit ${child.status}`;
125
+ const cause = line.trim();
126
+ if (!seenCauses.has(cause)) {
127
+ seenCauses.add(cause);
128
+ failures.push(`${relative}: ${cause}`);
129
+ } else {
130
+ failures.push(`${relative}: (same cause as above)`);
131
+ }
132
+ continue;
133
+ }
134
+ console.log(` loaded: ${relative}${timedOut ? ' (kept running — import resolved)' : ''}`);
135
+ }
136
+
137
+ try {
138
+ rmSync(probeHome, { recursive: true, force: true });
139
+ } catch {
140
+ // best effort
141
+ }
142
+
143
+ if (failures.length > 0) {
144
+ console.error('❌ sidecar smoke failed — the bundle is missing something it needs at runtime:');
145
+ for (const line of failures) console.error(` - ${line}`);
146
+ console.error('\nThis is the v2.2.10 class: the app would start and look healthy, then fail');
147
+ console.error('on first use of the affected surface. Check the prune list in scripts/bundle-sidecar.sh.');
148
+ process.exit(1);
149
+ }
150
+
151
+ console.log(`✅ sidecar smoke ok (${checked} critical modules imported from ${serverRoot})`);