cli-jaw 2.2.11 → 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 (176) hide show
  1. package/dist/bin/commands/doctor.js +20 -0
  2. package/dist/bin/commands/doctor.js.map +1 -1
  3. package/dist/bin/commands/init.js +7 -0
  4. package/dist/bin/commands/init.js.map +1 -1
  5. package/dist/bin/commands/slack.js +47 -5
  6. package/dist/bin/commands/slack.js.map +1 -1
  7. package/dist/bin/commands/tui/slash-command-runner.js +11 -2
  8. package/dist/bin/commands/tui/slash-command-runner.js.map +1 -1
  9. package/dist/server.js +18 -2
  10. package/dist/server.js.map +1 -1
  11. package/dist/src/agent/error-classifier.js +11 -2
  12. package/dist/src/agent/error-classifier.js.map +1 -1
  13. package/dist/src/agent/events/claude.js +7 -1
  14. package/dist/src/agent/events/claude.js.map +1 -1
  15. package/dist/src/agent/events/fulltext-bound.js +32 -0
  16. package/dist/src/agent/events/fulltext-bound.js.map +1 -0
  17. package/dist/src/agent/events/grok.js +7 -1
  18. package/dist/src/agent/events/grok.js.map +1 -1
  19. package/dist/src/agent/events/helpers.js +17 -4
  20. package/dist/src/agent/events/helpers.js.map +1 -1
  21. package/dist/src/agent/lifecycle-handler.js +56 -5
  22. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  23. package/dist/src/agent/pi-runtime.js +30 -1
  24. package/dist/src/agent/pi-runtime.js.map +1 -1
  25. package/dist/src/agent/spawn/exit-drain.js +96 -0
  26. package/dist/src/agent/spawn/exit-drain.js.map +1 -0
  27. package/dist/src/agent/spawn/line-buffer.js +26 -0
  28. package/dist/src/agent/spawn/line-buffer.js.map +1 -0
  29. package/dist/src/agent/spawn/process-kill.js +31 -0
  30. package/dist/src/agent/spawn/process-kill.js.map +1 -1
  31. package/dist/src/agent/spawn.js +80 -23
  32. package/dist/src/agent/spawn.js.map +1 -1
  33. package/dist/src/agent/watchdog.js +21 -5
  34. package/dist/src/agent/watchdog.js.map +1 -1
  35. package/dist/src/cli/commands.js +68 -15
  36. package/dist/src/cli/commands.js.map +1 -1
  37. package/dist/src/cli/opencodex-models.js +67 -14
  38. package/dist/src/cli/opencodex-models.js.map +1 -1
  39. package/dist/src/cli/registry-live.js +55 -2
  40. package/dist/src/cli/registry-live.js.map +1 -1
  41. package/dist/src/cli/registry.js +8 -2
  42. package/dist/src/cli/registry.js.map +1 -1
  43. package/dist/src/core/config.js +26 -1
  44. package/dist/src/core/config.js.map +1 -1
  45. package/dist/src/messaging/channel-health.js +6 -0
  46. package/dist/src/messaging/channel-health.js.map +1 -1
  47. package/dist/src/messaging/channel-validate.js +85 -0
  48. package/dist/src/messaging/channel-validate.js.map +1 -0
  49. package/dist/src/routes/link-preview.js +14 -0
  50. package/dist/src/routes/link-preview.js.map +1 -1
  51. package/dist/src/routes/messaging.js +9 -0
  52. package/dist/src/routes/messaging.js.map +1 -1
  53. package/dist/src/routes/system.js +5 -0
  54. package/dist/src/routes/system.js.map +1 -1
  55. package/dist/src/slack/api.js +32 -3
  56. package/dist/src/slack/api.js.map +1 -1
  57. package/dist/src/slack/bot.js +37 -1
  58. package/dist/src/slack/bot.js.map +1 -1
  59. package/dist/src/slack/events.js +23 -0
  60. package/dist/src/slack/events.js.map +1 -1
  61. package/dist/src/slack/hot-notify.js +33 -0
  62. package/dist/src/slack/hot-notify.js.map +1 -0
  63. package/dist/src/slack/manifest.js +2 -1
  64. package/dist/src/slack/manifest.js.map +1 -1
  65. package/dist/src/slack/progress.js +98 -0
  66. package/dist/src/slack/progress.js.map +1 -0
  67. package/dist/src/slack/send-only-client.js +3 -2
  68. package/dist/src/slack/send-only-client.js.map +1 -1
  69. package/dist/src/slack/slack-file.js +4 -2
  70. package/dist/src/slack/slack-file.js.map +1 -1
  71. package/package.json +3 -1
  72. package/public/css/sidebar.css +200 -0
  73. package/public/dist/assets/Agent-BjbZVZ9p.js +1 -0
  74. package/public/dist/assets/CodeCanvas-CbfPP7Ad.js +3 -0
  75. package/public/dist/assets/{DocPanel-DtYwv3n3.js → DocPanel-Dz-J_l5M.js} +1 -1
  76. package/public/dist/assets/{Heartbeat-vDGhPfct.js → Heartbeat-DDmZARWw.js} +1 -1
  77. package/public/dist/assets/MarkdownRenderer-CXrilOsG.js +15 -0
  78. package/public/dist/assets/MarkdownRenderer-DZVh_xX2.js +1 -0
  79. package/public/dist/assets/{Memory-B_UZ-iCA.js → Memory-CUbviW7N.js} +1 -1
  80. package/public/dist/assets/{MilkdownWysiwygEditor-BNoLGbFC.js → MilkdownWysiwygEditor-Em7iC6y9.js} +8 -8
  81. package/public/dist/assets/ModelProvider-ngg8xTuB.js +1 -0
  82. package/public/dist/assets/{agent-meta-C8DqYXM6.js → agent-meta-Y3DE6sxh.js} +1 -1
  83. package/public/dist/assets/app-BzD_ZLvt.js +55 -0
  84. package/public/dist/assets/{app-Nu2VPaO5.css → app-DX4e6isX.css} +1 -1
  85. package/public/dist/assets/bgtask-badge-Dqbp-Y4e.js +1 -0
  86. package/public/dist/assets/{bgtask-badge-DXca8E7c.js → bgtask-badge-qMkKOZx0.js} +1 -1
  87. package/public/dist/assets/{react-dom-AWKMTIe7.js → bounded-set-CFlZiEF4.js} +1 -1
  88. package/public/dist/assets/{constants-CSZ011-j.js → constants-Df7-7Rky.js} +1 -1
  89. package/public/dist/assets/{employees-BL4qMxJj.js → employees-BnobWM_2.js} +1 -1
  90. package/public/dist/assets/{iframe-renderer-DUI0LYBy.js → iframe-renderer-C5vRbbl9.js} +2 -2
  91. package/public/dist/assets/iframe-renderer-Df4KIVyx.js +1 -0
  92. package/public/dist/assets/manager-DaN201mN.js +13 -0
  93. package/public/dist/assets/memory-D-bULTHl.js +1 -0
  94. package/public/dist/assets/{memory-B_Y0L0KD.js → memory-DF4kPwwC.js} +1 -1
  95. package/public/dist/assets/{message-history-Dl8dJzez.js → message-history-C4WOisGx.js} +1 -1
  96. package/public/dist/assets/message-history-ZBoza6ew.js +1 -0
  97. package/public/dist/assets/{render-BI1aqKoc.js → render-DYX6GRQ0.js} +1 -1
  98. package/public/dist/assets/{settings-B0Sj4sUH.js → settings-DbJpGoaX.js} +1 -1
  99. package/public/dist/assets/settings-SreihUje.js +1 -0
  100. package/public/dist/assets/settings-core-B1Y5Nb4Z.js +1 -0
  101. package/public/dist/assets/settings-core-DobZWIZ7.js +140 -0
  102. package/public/dist/assets/{sidebar-DOR9fTgN.js → sidebar-Cp22g0Gv.js} +3 -3
  103. package/public/dist/assets/skills-DwPIP08e.js +1 -0
  104. package/public/dist/assets/{skills-wt0fkV9Y.js → skills-IGquen_j.js} +1 -1
  105. package/public/dist/assets/slash-commands-BBVfwcnW.js +1 -0
  106. package/public/dist/assets/{slash-commands-BZbqTg3v.js → slash-commands-ZRDOL99E.js} +1 -1
  107. package/public/dist/assets/{trace-drawer-kMYzaVFW.js → trace-drawer-D8To8fYt.js} +1 -1
  108. package/public/dist/assets/ui-CAicpUZc.js +1 -0
  109. package/public/dist/assets/{ui-D4D5HUfo.js → ui-Hx_mNoDf.js} +1 -1
  110. package/public/dist/assets/{wiki-link-resolver-BfNXN9px.js → wiki-link-resolver-DyFzBTIQ.js} +1 -1
  111. package/public/dist/index.html +90 -47
  112. package/public/dist/manager/index.html +1 -1
  113. package/public/index.html +88 -45
  114. package/public/js/constants.ts +50 -0
  115. package/public/js/features/channel-onboarding-flow.ts +197 -0
  116. package/public/js/features/channel-onboarding.ts +322 -0
  117. package/public/js/features/channel-setup-guide.ts +26 -0
  118. package/public/js/features/channel-setup-rules.ts +32 -0
  119. package/public/js/features/notifications.ts +38 -0
  120. package/public/js/features/settings-channel.ts +4 -0
  121. package/public/js/features/settings-core.ts +73 -9
  122. package/public/js/features/settings-discord.ts +2 -0
  123. package/public/js/features/settings-slack.ts +60 -2
  124. package/public/js/features/settings-telegram.ts +2 -0
  125. package/public/js/features/settings-types.ts +1 -1
  126. package/public/js/features/settings.ts +1 -1
  127. package/public/js/features/transport-status-row.ts +7 -3
  128. package/public/js/main.ts +5 -1
  129. package/public/locales/en.json +65 -3
  130. package/public/locales/ja.json +76 -3
  131. package/public/locales/ko.json +65 -3
  132. package/public/locales/zh.json +76 -3
  133. package/public/manager/src/SidebarRailRouter.tsx +9 -3
  134. package/public/manager/src/browser-panel/use-embedded-target-sync.ts +19 -1
  135. package/public/manager/src/code/CodeCanvas.tsx +14 -2
  136. package/public/manager/src/code/CodeTranscript.tsx +33 -9
  137. package/public/manager/src/code/CodeWorkbench.tsx +1 -1
  138. package/public/manager/src/code/code-event-dedupe.ts +27 -3
  139. package/public/manager/src/code/use-code-transcript-scroll.ts +28 -3
  140. package/public/manager/src/code/use-throttled-markdown.ts +75 -0
  141. package/public/manager/src/code/useCodeTranscriptVirtualRows.ts +19 -0
  142. package/public/manager/src/electron-metrics.tsx +7 -0
  143. package/public/manager/src/jaw-ceo/useJawCeo.ts +6 -1
  144. package/public/manager/src/jaw-ceo/useJawCeoVirtualTimeline.ts +5 -0
  145. package/public/manager/src/lib/bounded-set.ts +15 -0
  146. package/public/manager/src/lib/fnv1a.ts +14 -0
  147. package/public/manager/src/notes/rendering/CodeBlock.tsx +13 -4
  148. package/public/manager/src/notes/rendering/MarkdownRenderer.tsx +14 -2
  149. package/public/manager/src/notes/rendering/highlight-cache.ts +111 -0
  150. package/public/manager/src/notes/useNotesExternalSync.ts +6 -1
  151. package/public/manager/src/settings/pages/Agent.tsx +25 -4
  152. package/public/manager/src/settings/pages/components/PerCliRow.tsx +13 -5
  153. package/public/manager/src/settings/pages/components/agent/agent-meta.ts +98 -0
  154. package/scripts/bundle-sidecar.sh +8 -0
  155. package/scripts/check-electron-no-native.cjs +90 -4
  156. package/scripts/check-native-load.cjs +157 -0
  157. package/scripts/check-sidecar-smoke.mjs +151 -0
  158. package/scripts/ensure-native-modules.cjs +222 -18
  159. package/scripts/release-gates.mjs +29 -0
  160. package/public/dist/assets/Agent-BgkP4EnS.js +0 -1
  161. package/public/dist/assets/CodeCanvas-BZo3dohZ.js +0 -3
  162. package/public/dist/assets/MarkdownRenderer-CHacL2M_.js +0 -15
  163. package/public/dist/assets/MarkdownRenderer-DyrB9FbZ.js +0 -1
  164. package/public/dist/assets/ModelProvider-CgUvs6pE.js +0 -1
  165. package/public/dist/assets/app-BGIAy4he.js +0 -55
  166. package/public/dist/assets/bgtask-badge-GRSiNILQ.js +0 -1
  167. package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +0 -1
  168. package/public/dist/assets/manager-B-gpf-3j.js +0 -13
  169. package/public/dist/assets/memory-CX6YahTc.js +0 -1
  170. package/public/dist/assets/message-history-Det7u76S.js +0 -1
  171. package/public/dist/assets/settings-C7NutP6X.js +0 -1
  172. package/public/dist/assets/settings-core-CyX-px6_.js +0 -100
  173. package/public/dist/assets/settings-core-DVpFmrhY.js +0 -1
  174. package/public/dist/assets/skills-BWhKreQ2.js +0 -1
  175. package/public/dist/assets/slash-commands-Di4NDA4w.js +0 -1
  176. package/public/dist/assets/ui-BghexWrB.js +0 -1
@@ -4,6 +4,7 @@ import { useState } from 'react';
4
4
  import { TextField, SelectField, ToggleField } from '../../fields';
5
5
  import type { SettingsClient } from '../../types';
6
6
  import type { DirtyEntry } from '../../types';
7
+ import { coerceEffortForModel, effortChoicesForModel } from './agent/agent-meta';
7
8
  import type { CliMeta, PerCliEntry } from './agent/agent-meta';
8
9
  import { PiProfileDialog } from './PiProfileDialog';
9
10
  import { piModelOptions, piProfileOptions, type PiSettingsView } from './pi-profile';
@@ -36,9 +37,10 @@ export function PerCliRow({ cli, meta, original, value, setValue, setEntry, clie
36
37
  : hasProviders
37
38
  ? (meta.modelsByProvider?.[provider] ?? meta.models)
38
39
  : meta.models;
39
- const effortOptions = hasProviders
40
- ? (meta.effortsByProvider?.[provider] ?? meta.efforts)
41
- : meta.efforts;
40
+ const providerEfforts = hasProviders ? (meta.effortsByProvider?.[provider] ?? meta.efforts) : undefined;
41
+ // Per-model effort sets from a live opencodex catalog win over the
42
+ // provider/registry lists; the chosen value is forwarded to the wire.
43
+ const effortOptions = effortChoicesForModel(meta, value.model || '', providerEfforts, hasProviders ? provider : undefined);
42
44
 
43
45
  return (
44
46
  <div className="settings-percli-row" data-cli={cli}>
@@ -68,7 +70,9 @@ export function PerCliRow({ cli, meta, original, value, setValue, setEntry, clie
68
70
  const nextModels = meta.modelsByProvider?.[next] ?? [];
69
71
  const nextEfforts = meta.effortsByProvider?.[next] ?? [];
70
72
  const nextModel = nextModels.includes(value.model || '') ? (value.model || '') : (nextModels[0] || '');
71
- const nextEffort = nextEfforts.includes(value.effort || '') ? (value.effort || '') : '';
73
+ // Resolve against the NEW provider+model pair: the same
74
+ // model id can allow different efforts per provider.
75
+ const nextEffort = coerceEffortForModel(meta, nextModel, value.effort ?? '', nextEfforts, next);
72
76
  setValue({ ...value, provider: next, model: nextModel, effort: nextEffort });
73
77
  setEntry(`perCli.${cli}.provider`, entryFor(next, original.provider ?? meta.defaultProvider ?? 'claude'));
74
78
  setEntry(`perCli.${cli}.model`, entryFor(nextModel, original.model ?? ''));
@@ -84,8 +88,12 @@ export function PerCliRow({ cli, meta, original, value, setValue, setEntry, clie
84
88
  value={value.model ?? modelOptions[0] ?? ''}
85
89
  options={modelOptions.map((m) => ({ value: m, label: m }))}
86
90
  onChange={(next) => {
87
- setValue({ ...value, model: next });
91
+ const nextEffort = coerceEffortForModel(meta, next, value.effort ?? '', providerEfforts, hasProviders ? provider : undefined);
92
+ setValue({ ...value, model: next, effort: nextEffort });
88
93
  setEntry(`perCli.${cli}.model`, entryFor(next, original.model ?? ''));
94
+ if (nextEffort !== (value.effort ?? '')) {
95
+ setEntry(`perCli.${cli}.effort`, entryFor(nextEffort, original.effort ?? ''));
96
+ }
89
97
  }}
90
98
  />
91
99
  ) : (
@@ -6,6 +6,22 @@ export type CliMeta = {
6
6
  providers?: ReadonlyArray<string>;
7
7
  modelsByProvider?: Record<string, ReadonlyArray<string>>;
8
8
  effortsByProvider?: Record<string, ReadonlyArray<string>>;
9
+ /**
10
+ * Per-model reasoning-effort sets from a live opencodex catalog. An entry
11
+ * that exists but is EMPTY means the model takes no effort at all, so it
12
+ * must not fall back to `efforts`.
13
+ */
14
+ effortsByModel?: Record<string, ReadonlyArray<string>>;
15
+ defaultEffortByModel?: Record<string, string>;
16
+ /**
17
+ * Provider-scoped per-model efforts, used by runtimes whose model list is
18
+ * split by provider (`ai-e`). A flat map would collide on ids shared across
19
+ * providers — `gpt-5.6-sol` exists under both codex and kiro with different
20
+ * allowed efforts.
21
+ */
22
+ effortsByModelByProvider?: Record<string, Record<string, ReadonlyArray<string>>>;
23
+ defaultEffortByModelByProvider?: Record<string, Record<string, string>>;
24
+ modelSource?: string;
9
25
  effortNote?: string;
10
26
  modelNote?: string;
11
27
  };
@@ -230,6 +246,35 @@ function stringArrayRecord(value: unknown): Record<string, string[]> | undefined
230
246
  return out;
231
247
  }
232
248
 
249
+ function stringRecord(value: unknown): Record<string, string> | undefined {
250
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
251
+ const out: Record<string, string> = {};
252
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
253
+ if (typeof raw === 'string') out[key] = raw;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ function nestedStringArrayRecord(value: unknown): Record<string, Record<string, string[]>> | undefined {
259
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
260
+ const out: Record<string, Record<string, string[]>> = {};
261
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
262
+ const inner = stringArrayRecord(raw);
263
+ if (inner) out[key] = inner;
264
+ }
265
+ return out;
266
+ }
267
+
268
+ function nestedStringRecord(value: unknown): Record<string, Record<string, string>> | undefined {
269
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
270
+ const out: Record<string, Record<string, string>> = {};
271
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
272
+ const inner = stringRecord(raw);
273
+ if (inner) out[key] = inner;
274
+ }
275
+ return out;
276
+ }
277
+
233
278
  export function normalizeCliMetaRegistry(raw: unknown): Record<string, CliMeta> {
234
279
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
235
280
  const out: Record<string, CliMeta> = {};
@@ -238,6 +283,10 @@ export function normalizeCliMetaRegistry(raw: unknown): Record<string, CliMeta>
238
283
  const record = value as Record<string, unknown>;
239
284
  const modelsByProvider = stringArrayRecord(record['modelsByProvider']);
240
285
  const effortsByProvider = stringArrayRecord(record['effortsByProvider']);
286
+ const effortsByModel = stringArrayRecord(record['effortsByModel']);
287
+ const defaultEffortByModel = stringRecord(record['defaultEffortByModel']);
288
+ const effortsByModelByProvider = nestedStringArrayRecord(record['effortsByModelByProvider']);
289
+ const defaultEffortByModelByProvider = nestedStringRecord(record['defaultEffortByModelByProvider']);
241
290
  out[key] = {
242
291
  label: typeof record['label'] === 'string' ? record['label'] : key,
243
292
  models: stringArray(record['models']),
@@ -246,6 +295,11 @@ export function normalizeCliMetaRegistry(raw: unknown): Record<string, CliMeta>
246
295
  ...(record['providers'] ? { providers: stringArray(record['providers']) } : {}),
247
296
  ...(modelsByProvider ? { modelsByProvider } : {}),
248
297
  ...(effortsByProvider ? { effortsByProvider } : {}),
298
+ ...(effortsByModel ? { effortsByModel } : {}),
299
+ ...(defaultEffortByModel ? { defaultEffortByModel } : {}),
300
+ ...(effortsByModelByProvider ? { effortsByModelByProvider } : {}),
301
+ ...(defaultEffortByModelByProvider ? { defaultEffortByModelByProvider } : {}),
302
+ ...(typeof record['modelSource'] === 'string' ? { modelSource: record['modelSource'] } : {}),
249
303
  ...(typeof record['effortNote'] === 'string' ? { effortNote: record['effortNote'] } : {}),
250
304
  ...(typeof record['modelNote'] === 'string' ? { modelNote: record['modelNote'] } : {}),
251
305
  };
@@ -253,6 +307,50 @@ export function normalizeCliMetaRegistry(raw: unknown): Record<string, CliMeta>
253
307
  return out;
254
308
  }
255
309
 
310
+ /**
311
+ * Effort choices for one model.
312
+ *
313
+ * A live opencodex catalog advertises a different effort set per model, and the
314
+ * chosen value reaches the wire as `-c model_reasoning_effort=`, so the
315
+ * per-model set wins. An entry that exists but is EMPTY means the model takes
316
+ * no effort and must not fall back to the provider/registry lists.
317
+ */
318
+ export function effortChoicesForModel(
319
+ meta: CliMeta,
320
+ model: string,
321
+ providerEfforts?: ReadonlyArray<string>,
322
+ provider?: string,
323
+ ): ReadonlyArray<string> {
324
+ const key = (model || '').trim();
325
+ // Provider-scoped map wins for provider-split runtimes. When the map exists
326
+ // but the model is absent (e.g. a Claude model under ai-e/claude), fall back
327
+ // to the provider list rather than inventing an empty "no effort" set.
328
+ if (provider) {
329
+ const scoped = meta.effortsByModelByProvider?.[provider]?.[key];
330
+ if (scoped) return scoped;
331
+ }
332
+ const byModel = meta.effortsByModel?.[key];
333
+ if (byModel) return byModel;
334
+ return providerEfforts ?? meta.efforts;
335
+ }
336
+
337
+ /** Keep a saved effort only when the model still advertises it. */
338
+ export function coerceEffortForModel(
339
+ meta: CliMeta,
340
+ model: string,
341
+ effort: string,
342
+ providerEfforts?: ReadonlyArray<string>,
343
+ provider?: string,
344
+ ): string {
345
+ const choices = effortChoicesForModel(meta, model, providerEfforts, provider);
346
+ if (effort && choices.includes(effort)) return effort;
347
+ const key = (model || '').trim();
348
+ const fallback = (provider ? meta.defaultEffortByModelByProvider?.[provider]?.[key] : undefined)
349
+ ?? meta.defaultEffortByModel?.[key]
350
+ ?? '';
351
+ return choices.includes(fallback) ? fallback : '';
352
+ }
353
+
256
354
  export function metaFor(cli: string, registry?: Record<string, CliMeta> | null): CliMeta {
257
355
  return registry?.[cli] || CLI_META[cli] || { label: cli, models: [], efforts: [] };
258
356
  }
@@ -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})`);