cli-jaw 2.2.9 → 2.2.11

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 (225) hide show
  1. package/README.ja.md +15 -0
  2. package/README.ko.md +15 -0
  3. package/README.md +19 -4
  4. package/dist/bin/cli-jaw.js +5 -1
  5. package/dist/bin/cli-jaw.js.map +1 -1
  6. package/dist/bin/commands/doctor.js +66 -1
  7. package/dist/bin/commands/doctor.js.map +1 -1
  8. package/dist/bin/commands/init.js +107 -8
  9. package/dist/bin/commands/init.js.map +1 -1
  10. package/dist/bin/commands/slack.js +190 -0
  11. package/dist/bin/commands/slack.js.map +1 -0
  12. package/dist/lib/upload.js +5 -1
  13. package/dist/lib/upload.js.map +1 -1
  14. package/dist/server.js +1 -0
  15. package/dist/server.js.map +1 -1
  16. package/dist/src/agent/alert-escalation.js +1 -1
  17. package/dist/src/agent/alert-escalation.js.map +1 -1
  18. package/dist/src/agent/codex-app-catalog.js +57 -0
  19. package/dist/src/agent/codex-app-catalog.js.map +1 -0
  20. package/dist/src/agent/codex-app-client.js +89 -7
  21. package/dist/src/agent/codex-app-client.js.map +1 -1
  22. package/dist/src/agent/pi-rpc-verdict.js +22 -0
  23. package/dist/src/agent/pi-rpc-verdict.js.map +1 -0
  24. package/dist/src/agent/pi-runtime.js +240 -0
  25. package/dist/src/agent/pi-runtime.js.map +1 -1
  26. package/dist/src/agent/runtime-pool.js +445 -0
  27. package/dist/src/agent/runtime-pool.js.map +1 -0
  28. package/dist/src/agent/spawn.js +395 -240
  29. package/dist/src/agent/spawn.js.map +1 -1
  30. package/dist/src/cli/command-context.js +5 -2
  31. package/dist/src/cli/command-context.js.map +1 -1
  32. package/dist/src/cli/commands.js +43 -39
  33. package/dist/src/cli/commands.js.map +1 -1
  34. package/dist/src/cli/handlers-runtime.js +15 -7
  35. package/dist/src/cli/handlers-runtime.js.map +1 -1
  36. package/dist/src/cli/handlers-search.js +1 -1
  37. package/dist/src/cli/handlers-search.js.map +1 -1
  38. package/dist/src/cli/handlers-skill-invoke.js +1 -1
  39. package/dist/src/cli/handlers-skill-invoke.js.map +1 -1
  40. package/dist/src/cli/handlers-workflows.js +1 -1
  41. package/dist/src/cli/handlers-workflows.js.map +1 -1
  42. package/dist/src/cli/handlers.js +1 -1
  43. package/dist/src/cli/handlers.js.map +1 -1
  44. package/dist/src/cli/opencodex-models.js +40 -7
  45. package/dist/src/cli/opencodex-models.js.map +1 -1
  46. package/dist/src/cli/registry-live.js +6 -5
  47. package/dist/src/cli/registry-live.js.map +1 -1
  48. package/dist/src/command-contract/catalog.js +4 -0
  49. package/dist/src/command-contract/catalog.js.map +1 -1
  50. package/dist/src/core/config.js +69 -4
  51. package/dist/src/core/config.js.map +1 -1
  52. package/dist/src/core/runtime-settings.js +17 -0
  53. package/dist/src/core/runtime-settings.js.map +1 -1
  54. package/dist/src/core/settings-merge.js +1 -1
  55. package/dist/src/core/settings-merge.js.map +1 -1
  56. package/dist/src/discord/bot.js +29 -14
  57. package/dist/src/discord/bot.js.map +1 -1
  58. package/dist/src/discord/commands.js +5 -4
  59. package/dist/src/discord/commands.js.map +1 -1
  60. package/dist/src/discord/discord-file.js +8 -2
  61. package/dist/src/discord/discord-file.js.map +1 -1
  62. package/dist/src/discord/forwarder.js +17 -19
  63. package/dist/src/discord/forwarder.js.map +1 -1
  64. package/dist/src/discord/send-only-client.js +9 -1
  65. package/dist/src/discord/send-only-client.js.map +1 -1
  66. package/dist/src/manager/dashboard-service.js +38 -1
  67. package/dist/src/manager/dashboard-service.js.map +1 -1
  68. package/dist/src/manager/telegram-hub/hub-bot.js +47 -29
  69. package/dist/src/manager/telegram-hub/hub-bot.js.map +1 -1
  70. package/dist/src/messaging/channel-health.js +32 -0
  71. package/dist/src/messaging/channel-health.js.map +1 -1
  72. package/dist/src/messaging/chunk.js +376 -0
  73. package/dist/src/messaging/chunk.js.map +1 -0
  74. package/dist/src/messaging/dedupe.js +104 -0
  75. package/dist/src/messaging/dedupe.js.map +1 -0
  76. package/dist/src/messaging/fold.js +210 -0
  77. package/dist/src/messaging/fold.js.map +1 -0
  78. package/dist/src/messaging/redact.js +577 -0
  79. package/dist/src/messaging/redact.js.map +1 -0
  80. package/dist/src/messaging/retry.js +76 -0
  81. package/dist/src/messaging/retry.js.map +1 -0
  82. package/dist/src/messaging/runtime.js +6 -3
  83. package/dist/src/messaging/runtime.js.map +1 -1
  84. package/dist/src/messaging/send.js +45 -15
  85. package/dist/src/messaging/send.js.map +1 -1
  86. package/dist/src/messaging/slack-target.js +50 -0
  87. package/dist/src/messaging/slack-target.js.map +1 -0
  88. package/dist/src/messaging/types.js.map +1 -1
  89. package/dist/src/orchestrator/pipeline.js +4 -4
  90. package/dist/src/orchestrator/pipeline.js.map +1 -1
  91. package/dist/src/routes/command.js +11 -7
  92. package/dist/src/routes/command.js.map +1 -1
  93. package/dist/src/routes/messaging.js +28 -12
  94. package/dist/src/routes/messaging.js.map +1 -1
  95. package/dist/src/routes/settings.js +22 -6
  96. package/dist/src/routes/settings.js.map +1 -1
  97. package/dist/src/slack/api.js +116 -0
  98. package/dist/src/slack/api.js.map +1 -0
  99. package/dist/src/slack/bot.js +262 -0
  100. package/dist/src/slack/bot.js.map +1 -0
  101. package/dist/src/slack/commands.js +98 -0
  102. package/dist/src/slack/commands.js.map +1 -0
  103. package/dist/src/slack/events.js +116 -0
  104. package/dist/src/slack/events.js.map +1 -0
  105. package/dist/src/slack/format.js +58 -0
  106. package/dist/src/slack/format.js.map +1 -0
  107. package/dist/src/slack/forwarder.js +57 -0
  108. package/dist/src/slack/forwarder.js.map +1 -0
  109. package/dist/src/slack/manifest.js +84 -0
  110. package/dist/src/slack/manifest.js.map +1 -0
  111. package/dist/src/slack/register.js +13 -0
  112. package/dist/src/slack/register.js.map +1 -0
  113. package/dist/src/slack/send-handler.js +41 -0
  114. package/dist/src/slack/send-handler.js.map +1 -0
  115. package/dist/src/slack/send-only-client.js +46 -0
  116. package/dist/src/slack/send-only-client.js.map +1 -0
  117. package/dist/src/slack/slack-file.js +72 -0
  118. package/dist/src/slack/slack-file.js.map +1 -0
  119. package/dist/src/slack/socket.js +341 -0
  120. package/dist/src/slack/socket.js.map +1 -0
  121. package/dist/src/telegram/bot.js +117 -37
  122. package/dist/src/telegram/bot.js.map +1 -1
  123. package/dist/src/telegram/elicitation-buttons.js +7 -2
  124. package/dist/src/telegram/elicitation-buttons.js.map +1 -1
  125. package/dist/src/telegram/forwarder.js +15 -7
  126. package/dist/src/telegram/forwarder.js.map +1 -1
  127. package/dist/src/telegram/rich-message.js +209 -24
  128. package/dist/src/telegram/rich-message.js.map +1 -1
  129. package/dist/src/telegram/telegram-file.js +35 -4
  130. package/dist/src/telegram/telegram-file.js.map +1 -1
  131. package/dist/src/telegram/voice.js +4 -3
  132. package/dist/src/telegram/voice.js.map +1 -1
  133. package/package.json +7 -1
  134. package/public/assets/providers/slack.svg +1 -0
  135. package/public/dist/assets/ChannelsDiscord-D9FLnZso.js +1 -0
  136. package/public/dist/assets/ChannelsSlack-ybvupgdK.js +1 -0
  137. package/public/dist/assets/ChannelsTelegram-BOCOPrBc.js +1 -0
  138. package/public/dist/assets/HealthBadge-megTlF4n.js +1 -0
  139. package/public/dist/assets/TransportStatusChips-CjEySMis.js +1 -0
  140. package/public/dist/assets/app-BGIAy4he.js +55 -0
  141. package/public/dist/assets/{bgtask-badge-CdBqBy1L.js → bgtask-badge-DXca8E7c.js} +1 -1
  142. package/public/dist/assets/bgtask-badge-GRSiNILQ.js +1 -0
  143. package/public/dist/assets/{employees-C8qufeJl.js → employees-BL4qMxJj.js} +1 -1
  144. package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +1 -0
  145. package/public/dist/assets/{iframe-renderer-CZ67aeTO.js → iframe-renderer-DUI0LYBy.js} +2 -2
  146. package/public/dist/assets/{manager-CLXT60M5.js → manager-B-gpf-3j.js} +3 -3
  147. package/public/dist/assets/{memory-lWramLVw.js → memory-B_Y0L0KD.js} +1 -1
  148. package/public/dist/assets/memory-CX6YahTc.js +1 -0
  149. package/public/dist/assets/message-history-Det7u76S.js +1 -0
  150. package/public/dist/assets/{message-history-2NUN3UxM.js → message-history-Dl8dJzez.js} +1 -1
  151. package/public/dist/assets/{provider-icons-stsNmQKx.js → provider-icons-VDfahiUp.js} +3 -2
  152. package/public/dist/assets/{render-CsF-ojLe.js → render-BI1aqKoc.js} +1 -1
  153. package/public/dist/assets/{settings-BMLowRCh.js → settings-B0Sj4sUH.js} +1 -1
  154. package/public/dist/assets/settings-C7NutP6X.js +1 -0
  155. package/public/dist/assets/settings-core-CyX-px6_.js +100 -0
  156. package/public/dist/assets/settings-core-DVpFmrhY.js +1 -0
  157. package/public/dist/assets/{sidebar-gKjYLv49.js → sidebar-DOR9fTgN.js} +3 -3
  158. package/public/dist/assets/skills-BWhKreQ2.js +1 -0
  159. package/public/dist/assets/{skills-CKTbomdm.js → skills-wt0fkV9Y.js} +1 -1
  160. package/public/dist/assets/slash-commands-BZbqTg3v.js +20 -0
  161. package/public/dist/assets/slash-commands-Di4NDA4w.js +1 -0
  162. package/public/dist/assets/{trace-drawer-C3G4mmB1.js → trace-drawer-kMYzaVFW.js} +1 -1
  163. package/public/dist/assets/ui-BghexWrB.js +1 -0
  164. package/public/dist/assets/{ui-CVVC_flX.js → ui-D4D5HUfo.js} +1 -1
  165. package/public/dist/index.html +62 -1
  166. package/public/dist/manager/index.html +1 -1
  167. package/public/index.html +61 -0
  168. package/public/js/features/command-info.ts +1 -0
  169. package/public/js/features/help-content.ts +8 -0
  170. package/public/js/features/settings-channel.ts +5 -1
  171. package/public/js/features/settings-core.ts +2 -0
  172. package/public/js/features/settings-slack.ts +74 -0
  173. package/public/js/features/settings-types.ts +5 -1
  174. package/public/js/features/settings.ts +1 -0
  175. package/public/js/features/transport-status-row.ts +15 -3
  176. package/public/js/main.ts +19 -0
  177. package/public/js/provider-icons.ts +4 -1
  178. package/public/js/ws.ts +1 -1
  179. package/public/locales/en.json +21 -0
  180. package/public/locales/ja.json +21 -0
  181. package/public/locales/ko.json +21 -0
  182. package/public/locales/zh.json +21 -0
  183. package/public/manager/src/settings/SettingsShell.tsx +1 -0
  184. package/public/manager/src/settings/SettingsSidebar.tsx +1 -0
  185. package/public/manager/src/settings/pages/ChannelsSlack.tsx +323 -0
  186. package/public/manager/src/settings/pages/components/ActiveChannelToggle.tsx +2 -1
  187. package/public/manager/src/settings/pages/components/TransportStatusChips.tsx +13 -4
  188. package/public/manager/src/settings/types.ts +1 -0
  189. package/scripts/bundle-sidecar.sh +13 -4
  190. package/scripts/check-redaction-sinks.mjs +276 -0
  191. package/scripts/check-sidecar-prune-safety.mjs +167 -0
  192. package/scripts/pi-rpc-probe.mts +361 -0
  193. package/scripts/pick-gyp-python.sh +61 -0
  194. package/scripts/release-gates.mjs +97 -0
  195. package/scripts/release-preview.sh +8 -1
  196. package/scripts/release.sh +13 -2
  197. package/scripts/sync-electron-version.cjs +93 -0
  198. package/public/dist/assets/ChannelsDiscord-Bt1_rtNC.js +0 -1
  199. package/public/dist/assets/ChannelsTelegram-BLV0m1oq.js +0 -1
  200. package/public/dist/assets/TransportStatusChips-Bld0yXi7.js +0 -1
  201. package/public/dist/assets/app-iSJhsP20.js +0 -55
  202. package/public/dist/assets/bgtask-badge-CSkSsoB6.js +0 -1
  203. package/public/dist/assets/iframe-renderer-DXdo0YXN.js +0 -1
  204. package/public/dist/assets/memory-DeKUlD2M.js +0 -1
  205. package/public/dist/assets/message-history-CWTQ2woA.js +0 -1
  206. package/public/dist/assets/settings-CDjs7hTT.js +0 -1
  207. package/public/dist/assets/settings-core-Cs_oq-b3.js +0 -99
  208. package/public/dist/assets/settings-core-D-TOh1a6.js +0 -1
  209. package/public/dist/assets/skills-GuIfNASs.js +0 -1
  210. package/public/dist/assets/slash-commands-BtZa093n.js +0 -20
  211. package/public/dist/assets/slash-commands-DGJKfpMT.js +0 -1
  212. package/public/dist/assets/ui-DJg5Q_NJ.js +0 -1
  213. /package/public/dist/assets/{DashboardMeta-B7CVqXcU.js → DashboardMeta-DennaaA_.js} +0 -0
  214. /package/public/dist/assets/{Display-BF-rACK6.js → Display-hGuSn_W5.js} +0 -0
  215. /package/public/dist/assets/{Employees-D3hnciP0.js → Employees-B5YXk5ma.js} +0 -0
  216. /package/public/dist/assets/{Heartbeat-D4Pn5O3O.js → Heartbeat-vDGhPfct.js} +0 -0
  217. /package/public/dist/assets/{Mcp-SfytKWGX.js → Mcp-BHWnJ6SP.js} +0 -0
  218. /package/public/dist/assets/{Memory-DNxBOQ7N.js → Memory-B_UZ-iCA.js} +0 -0
  219. /package/public/dist/assets/{ModelProvider-CXwtlG7l.js → ModelProvider-CgUvs6pE.js} +0 -0
  220. /package/public/dist/assets/{Network-DISUhige.js → Network-AWpSkALD.js} +0 -0
  221. /package/public/dist/assets/{Profile-B8yBO3Ju.js → Profile-DnIE2ios.js} +0 -0
  222. /package/public/dist/assets/{Prompts-5lNQ5qOm.js → Prompts-B9aKLall.js} +0 -0
  223. /package/public/dist/assets/{SpeechKeys-BepaIHBa.js → SpeechKeys-K4Be_o7z.js} +0 -0
  224. /package/public/dist/assets/{TelegramHub--DNjRAwW.js → TelegramHub-CFwSkzHP.js} +0 -0
  225. /package/public/dist/assets/{TerminalPanel-DQovYfwz.js → TerminalPanel-CdlFZHAK.js} +0 -0
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env -S npx tsx
2
+
3
+ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { StringDecoder } from 'node:string_decoder';
8
+ import {
9
+ DEFAULT_PI_PROFILE,
10
+ DEFAULT_PI_SETTINGS,
11
+ ensurePiRuntimeConfig,
12
+ parsePiRpcRecord,
13
+ resolvePiCommand,
14
+ type PiCommand,
15
+ } from '../src/agent/pi-runtime.js';
16
+ import { JAW_HOME } from '../src/core/config.js';
17
+ import {
18
+ classifySecondPromptOutcome,
19
+ type PiRpcVerdict,
20
+ type PiRpcVerdictRecord,
21
+ } from '../src/agent/pi-rpc-verdict.js';
22
+
23
+ const GET_STATE_ID = 1;
24
+ const FIRST_PROMPT_ID = 2;
25
+ const SECOND_PROMPT_ID = 3;
26
+ const THIRD_PROMPT_ID = 4;
27
+ const ABORT_ID = 5;
28
+ const FIRST_TIMEOUT_MS = 60_000;
29
+ const SECOND_TIMEOUT_MS = 30_000;
30
+ const ABORT_START_TIMEOUT_MS = 30_000;
31
+ const ABORT_TERMINAL_TIMEOUT_MS = 15_000;
32
+
33
+ type RawRecord = Record<string, unknown>;
34
+ type ObservedRecord = { raw: RawRecord; normalized: PiRpcVerdictRecord };
35
+ type WaitResult = { matched: boolean; closed: boolean; timedOut: boolean };
36
+ type AttemptResult = {
37
+ verdict: PiRpcVerdict;
38
+ abortEffective: boolean;
39
+ firstPromptSucceeded: boolean;
40
+ selfExited: boolean;
41
+ secondTimedOut: boolean;
42
+ recordCount: number;
43
+ };
44
+
45
+ function normalizeRecord(raw: RawRecord): PiRpcVerdictRecord {
46
+ const parsed = parsePiRpcRecord(raw);
47
+ const normalized: PiRpcVerdictRecord = {};
48
+ if (typeof raw['id'] === 'number') normalized.id = raw['id'];
49
+ if (typeof raw['type'] === 'string') normalized.type = raw['type'];
50
+ if (raw['type'] === 'response') {
51
+ if (typeof raw['command'] === 'string') normalized.command = raw['command'];
52
+ if (typeof raw['success'] === 'boolean') normalized.success = raw['success'];
53
+ }
54
+ const error = raw['error'];
55
+ if (error && typeof error === 'object') {
56
+ const message = (error as Record<string, unknown>)['message'];
57
+ normalized.error = typeof message === 'string' ? { message } : {};
58
+ }
59
+ if (parsed.done !== undefined) normalized.done = parsed.done;
60
+ if (parsed.text !== undefined) normalized.text = parsed.text;
61
+ if (raw['type'] === 'agent_end') {
62
+ // user echo 추출 — 모델이 text 파트를 생략하는 비결정성에 강건한 턴 상관 증거
63
+ const messages = raw['messages'];
64
+ if (Array.isArray(messages)) {
65
+ normalized.userEcho = messages
66
+ .filter((entry) => !!entry && typeof entry === 'object' && (entry as Record<string, unknown>)['role'] === 'user')
67
+ .map((entry) => extractProbeText((entry as Record<string, unknown>)['content']))
68
+ .join('');
69
+ }
70
+ }
71
+ return normalized;
72
+ }
73
+
74
+ function extractProbeText(value: unknown): string {
75
+ if (typeof value === 'string') return value;
76
+ if (Array.isArray(value)) return value.map(extractProbeText).join('');
77
+ if (!value || typeof value !== 'object') return '';
78
+ const obj = value as Record<string, unknown>;
79
+ if (obj['type'] === 'thinking') return '';
80
+ return extractProbeText(obj['text']) || extractProbeText(obj['content']) || '';
81
+ }
82
+
83
+ function isSuccessfulStateResponse(record: ObservedRecord): boolean {
84
+ return record.normalized.id === GET_STATE_ID
85
+ && record.raw['type'] === 'response'
86
+ && record.raw['command'] === 'get_state'
87
+ && record.raw['success'] === true;
88
+ }
89
+
90
+ function isGenerationEvent(record: ObservedRecord): boolean {
91
+ const type = record.raw['type'];
92
+ if (type === 'agent_end') return false;
93
+ if (record.normalized.text) return true;
94
+ return typeof type === 'string' && type !== 'response';
95
+ }
96
+
97
+ function isAbortAccepted(record: ObservedRecord): boolean {
98
+ return record.normalized.id === ABORT_ID
99
+ && record.raw['type'] === 'response'
100
+ && record.raw['command'] === 'abort'
101
+ && record.raw['success'] === true;
102
+ }
103
+
104
+ function isTerminalRecord(record: ObservedRecord): boolean {
105
+ if (record.normalized.done) return true;
106
+ const data = record.raw['data'];
107
+ if (!data || typeof data !== 'object') return false;
108
+ const state = data as Record<string, unknown>;
109
+ if (state['running'] === false || state['isRunning'] === false) return true;
110
+ return typeof state['state'] === 'string' && !['running', 'active', 'generating'].includes(state['state'].toLowerCase());
111
+ }
112
+
113
+ class RpcProbeProcess {
114
+ readonly records: ObservedRecord[] = [];
115
+ readonly child: ChildProcessWithoutNullStreams;
116
+ private readonly listeners = new Set<() => void>();
117
+ private buffer = '';
118
+ private readonly decoder = new StringDecoder('utf8');
119
+ private closed = false;
120
+ private closeCode: number | null = null;
121
+
122
+ constructor(command: PiCommand, runtimeDir: string) {
123
+ const args = [
124
+ ...command.baseArgs,
125
+ '--mode', 'rpc',
126
+ '--no-context-files',
127
+ '--provider', DEFAULT_PI_PROFILE.id,
128
+ '--model', DEFAULT_PI_PROFILE.model,
129
+ '--api-key', DEFAULT_PI_PROFILE.apiKey || 'dummy',
130
+ ];
131
+ console.log(`[probe:spawn] ${JSON.stringify([command.command, ...args])}`);
132
+ this.child = spawn(command.command, args, {
133
+ cwd: process.cwd(),
134
+ env: { ...process.env, PI_CODING_AGENT_DIR: runtimeDir },
135
+ stdio: ['pipe', 'pipe', 'pipe'],
136
+ ...(process.platform === 'win32' && !command.command.toLowerCase().endsWith('.exe') ? { shell: true } : {}),
137
+ });
138
+ this.child.stdout.on('data', (chunk: Buffer) => this.consumeStdout(chunk));
139
+ this.child.stderr.on('data', (chunk: Buffer) => process.stderr.write(`[probe:stderr] ${chunk.toString()}`));
140
+ this.child.on('error', (error) => {
141
+ console.error(`[probe:error] ${error.message}`);
142
+ this.notify();
143
+ });
144
+ this.child.on('close', (code) => {
145
+ this.buffer += this.decoder.end();
146
+ if (this.buffer.trim()) this.consumeLine(this.buffer.trim());
147
+ this.buffer = '';
148
+ this.closed = true;
149
+ this.closeCode = code;
150
+ console.log(`[probe:close] code=${String(code)}`);
151
+ this.notify();
152
+ });
153
+ }
154
+
155
+ get didClose(): boolean { return this.closed; }
156
+ get exitedNormally(): boolean { return this.closed && this.closeCode === 0; }
157
+
158
+ send(id: number, type: string, fields: RawRecord = {}): Promise<void> {
159
+ const line = JSON.stringify({ id, type, ...fields });
160
+ console.log(`[probe:send] ${line}`);
161
+ return new Promise((resolve, reject) => {
162
+ if (!this.child.stdin.writable) {
163
+ reject(new Error('pi rpc stdin is not writable'));
164
+ return;
165
+ }
166
+ this.child.stdin.write(`${line}\n`, (error) => error ? reject(error) : resolve());
167
+ });
168
+ }
169
+
170
+ async waitFrom(start: number, predicate: (records: ObservedRecord[]) => boolean, timeoutMs: number): Promise<WaitResult> {
171
+ if (predicate(this.records.slice(start))) return { matched: true, closed: this.closed, timedOut: false };
172
+ if (this.closed) return { matched: false, closed: true, timedOut: false };
173
+ return new Promise((resolve) => {
174
+ const finish = (result: WaitResult) => {
175
+ clearTimeout(timer);
176
+ this.listeners.delete(check);
177
+ resolve(result);
178
+ };
179
+ const check = () => {
180
+ if (predicate(this.records.slice(start))) finish({ matched: true, closed: this.closed, timedOut: false });
181
+ else if (this.closed) finish({ matched: false, closed: true, timedOut: false });
182
+ };
183
+ const timer = setTimeout(() => finish({ matched: false, closed: this.closed, timedOut: true }), timeoutMs);
184
+ this.listeners.add(check);
185
+ });
186
+ }
187
+
188
+ async stop(): Promise<void> {
189
+ try { this.child.stdin.end(); } catch { /* already closed */ }
190
+ if (this.closed) return;
191
+ if (!this.child.killed) this.child.kill('SIGTERM');
192
+ const stopped = await this.waitFrom(this.records.length, () => false, 2_000);
193
+ if (!stopped.closed) {
194
+ this.child.kill('SIGKILL');
195
+ await this.waitFrom(this.records.length, () => false, 1_000);
196
+ }
197
+ }
198
+
199
+ private consumeStdout(chunk: Buffer): void {
200
+ this.buffer += this.decoder.write(chunk);
201
+ const lines = this.buffer.split('\n');
202
+ this.buffer = lines.pop() ?? '';
203
+ for (const line of lines) if (line.trim()) this.consumeLine(line.trim());
204
+ }
205
+
206
+ private consumeLine(line: string): void {
207
+ process.stdout.write(`${line}\n`);
208
+ try {
209
+ const raw = JSON.parse(line) as unknown;
210
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
211
+ this.records.push({ raw: raw as RawRecord, normalized: normalizeRecord(raw as RawRecord) });
212
+ this.notify();
213
+ } catch {
214
+ console.error(`[probe:parse-error] ${line.slice(0, 200)}`);
215
+ }
216
+ }
217
+
218
+ private notify(): void {
219
+ for (const listener of [...this.listeners]) listener();
220
+ }
221
+ }
222
+
223
+ async function probeAbort(rpc: RpcProbeProcess): Promise<boolean> {
224
+ if (rpc.didClose) return false;
225
+ const thirdStart = rpc.records.length;
226
+ try {
227
+ await rpc.send(THIRD_PROMPT_ID, 'prompt', { message: 'Write a very long story, at least 2000 words.' });
228
+ } catch (error) {
229
+ console.error(`[probe:abort] third prompt write failed: ${(error as Error).message}`);
230
+ return false;
231
+ }
232
+ const started = await rpc.waitFrom(thirdStart, (records) => records.some(isGenerationEvent), ABORT_START_TIMEOUT_MS);
233
+ if (!started.matched || rpc.didClose) return false;
234
+
235
+ const abortStart = rpc.records.length;
236
+ try {
237
+ await rpc.send(ABORT_ID, 'abort');
238
+ } catch (error) {
239
+ console.error(`[probe:abort] abort write failed: ${(error as Error).message}`);
240
+ return false;
241
+ }
242
+ const terminal = await rpc.waitFrom(abortStart, (records) => {
243
+ const accepted = records.some(isAbortAccepted);
244
+ return accepted && records.some(isTerminalRecord);
245
+ }, ABORT_TERMINAL_TIMEOUT_MS);
246
+ return terminal.matched;
247
+ }
248
+
249
+ async function runAttempt(command: PiCommand, attempt: number): Promise<AttemptResult> {
250
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `cli-jaw-pi-rpc-probe-${attempt}-`));
251
+ const runtimeDir = ensurePiRuntimeConfig(DEFAULT_PI_SETTINGS, DEFAULT_PI_PROFILE.id, '', tempRoot);
252
+ const rpc = new RpcProbeProcess(command, runtimeDir);
253
+ let firstPromptSucceeded = false;
254
+ let selfExited = false;
255
+ let secondTimedOut = false;
256
+ let abortEffective = false;
257
+ let secondRecords: ObservedRecord[] = [];
258
+ try {
259
+ const stateStart = rpc.records.length;
260
+ await rpc.send(GET_STATE_ID, 'get_state');
261
+ const state = await rpc.waitFrom(stateStart, (records) => records.some(isSuccessfulStateResponse), 15_000);
262
+ if (!state.matched) {
263
+ return { verdict: 'inconclusive', abortEffective, firstPromptSucceeded, selfExited, secondTimedOut: state.timedOut, recordCount: rpc.records.length };
264
+ }
265
+
266
+ const firstStart = rpc.records.length;
267
+ await rpc.send(FIRST_PROMPT_ID, 'prompt', { message: 'Reply with the single token: FIRST' });
268
+ const first = await rpc.waitFrom(firstStart, (records) => records.some((record) => record.normalized.done === true), FIRST_TIMEOUT_MS);
269
+ firstPromptSucceeded = first.matched;
270
+
271
+ const secondStart = rpc.records.length;
272
+ if (firstPromptSucceeded) {
273
+ try {
274
+ await rpc.send(SECOND_PROMPT_ID, 'prompt', { message: 'Reply with the single token: SECOND' });
275
+ } catch (error) {
276
+ console.error(`[probe:second] write failed: ${(error as Error).message}`);
277
+ selfExited = rpc.exitedNormally;
278
+ }
279
+ if (!selfExited) {
280
+ const second = await rpc.waitFrom(secondStart, (records) => records.some((record) =>
281
+ record.normalized.done === true
282
+ || (record.normalized.id === SECOND_PROMPT_ID && record.normalized.error !== undefined)
283
+ ), SECOND_TIMEOUT_MS);
284
+ secondTimedOut = second.timedOut;
285
+ secondRecords = rpc.records.slice(secondStart);
286
+ if (second.closed && rpc.exitedNormally && !second.matched) selfExited = true;
287
+ if (!secondTimedOut && !rpc.didClose) abortEffective = await probeAbort(rpc);
288
+ }
289
+ }
290
+
291
+ const verdict = classifySecondPromptOutcome({
292
+ records: secondRecords.map((record) => record.normalized),
293
+ secondPromptId: SECOND_PROMPT_ID,
294
+ timedOut: secondTimedOut,
295
+ selfExited,
296
+ firstPromptSucceeded,
297
+ });
298
+ if (process.env['PI_RPC_PROBE_DEBUG']) {
299
+ console.error(`[probe:debug] classifier input: ${JSON.stringify({
300
+ firstPromptSucceeded, selfExited, secondTimedOut,
301
+ doneRecords: secondRecords.filter((r) => r.normalized.done).map((r) => r.normalized),
302
+ secondRecordCount: secondRecords.length,
303
+ })}`);
304
+ }
305
+ return { verdict, abortEffective, firstPromptSucceeded, selfExited, secondTimedOut, recordCount: rpc.records.length };
306
+ } catch (error) {
307
+ console.error(`[probe:attempt-${attempt}] ${(error as Error).stack || String(error)}`);
308
+ return { verdict: 'inconclusive', abortEffective, firstPromptSucceeded, selfExited, secondTimedOut, recordCount: rpc.records.length };
309
+ } finally {
310
+ await rpc.stop();
311
+ fs.rmSync(tempRoot, { recursive: true, force: true });
312
+ }
313
+ }
314
+
315
+ function resolveCommandIdentity(command: PiCommand): string {
316
+ const result = spawnSync(command.command, [...command.baseArgs, '--version'], {
317
+ encoding: 'utf8',
318
+ env: process.env,
319
+ timeout: 15_000,
320
+ });
321
+ const version = `${result.stdout || ''}\n${result.stderr || ''}`.trim() || `exit:${String(result.status)}`;
322
+ return JSON.stringify({ source: command.source, command: command.command, baseArgs: command.baseArgs, version });
323
+ }
324
+
325
+ function writeCapability(commandId: string, result: AttemptResult, attempts: AttemptResult[]): void {
326
+ const targetDir = path.join(JAW_HOME, 'pi');
327
+ const target = path.join(targetDir, 'rpc-capabilities.json');
328
+ const temp = `${target}.${process.pid}.tmp`;
329
+ const evidence = attempts.map((attempt, index) =>
330
+ `attempt=${index + 1},verdict=${attempt.verdict},first=${attempt.firstPromptSucceeded},selfExited=${attempt.selfExited},secondTimedOut=${attempt.secondTimedOut},abort=${attempt.abortEffective},records=${attempt.recordCount}`
331
+ ).join('; ');
332
+ fs.mkdirSync(targetDir, { recursive: true });
333
+ fs.writeFileSync(temp, `${JSON.stringify({
334
+ schemaVersion: 1,
335
+ commandId,
336
+ probedAt: new Date().toISOString(),
337
+ profileId: DEFAULT_PI_PROFILE.id,
338
+ abortEffective: result.abortEffective,
339
+ evidence,
340
+ }, null, 2)}\n`, 'utf8');
341
+ fs.renameSync(temp, target);
342
+ console.log(`[probe:capabilities] ${target}`);
343
+ }
344
+
345
+ async function main(): Promise<void> {
346
+ const command = resolvePiCommand();
347
+ const commandId = resolveCommandIdentity(command);
348
+ const attempts: AttemptResult[] = [];
349
+ attempts.push(await runAttempt(command, 1));
350
+ if (attempts[0]?.verdict === 'inconclusive') attempts.push(await runAttempt(command, 2));
351
+ const result = attempts.at(-1);
352
+ if (!result) throw new Error('pi rpc probe produced no result');
353
+ writeCapability(commandId, result, attempts);
354
+ console.log(`[probe:verdict] ${result.verdict} abortEffective=${result.abortEffective}`);
355
+ process.exitCode = result.verdict === 'supported' ? 0 : result.verdict === 'proven-unsupported' ? 1 : 2;
356
+ }
357
+
358
+ void main().catch((error: unknown) => {
359
+ console.error(`[probe:fatal] ${(error as Error).stack || String(error)}`);
360
+ process.exitCode = 2;
361
+ });
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Print a python3 that node-gyp can actually run under.
3
+ #
4
+ # node-gyp's bundled gyp still does `from distutils.version import StrictVersion`,
5
+ # and distutils was removed in Python 3.12. On a machine whose default python3 is
6
+ # Homebrew 3.12+ the native rebuild dies with:
7
+ #
8
+ # ModuleNotFoundError: No module named 'distutils'
9
+ # ⨯ node-gyp failed to rebuild '.../node_modules/node-pty'
10
+ #
11
+ # Observed on macOS 27 with Homebrew Python 3.14.6 while building the desktop app
12
+ # on a second machine. macOS still ships /usr/bin/python3 (3.9.6), which has
13
+ # distutils, so a working interpreter is usually present -- it just is not the
14
+ # one on PATH.
15
+ #
16
+ # CI already knew: .github/workflows/desktop-release.yml pins actions/setup-python
17
+ # to 3.11 with exactly this reasoning in a comment. The local build path never got
18
+ # the same treatment, so `npm run electron:dist:mac` on a developer machine was the
19
+ # one route into node-gyp with no defense.
20
+ #
21
+ # Candidates are TESTED, not assumed: a hardcoded path is the same bet that broke
22
+ # here, one Python release later. An explicit $PYTHON still wins, because the
23
+ # caller may know something this script does not.
24
+ set -euo pipefail
25
+
26
+ has_distutils() {
27
+ [ -x "$1" ] || return 1
28
+ "$1" -c 'import distutils' >/dev/null 2>&1
29
+ }
30
+
31
+ if [ -n "${PYTHON:-}" ]; then
32
+ printf '%s\n' "$PYTHON"
33
+ exit 0
34
+ fi
35
+
36
+ for candidate in \
37
+ /usr/bin/python3 \
38
+ "$(command -v python3.11 2>/dev/null || true)" \
39
+ "$(command -v python3.10 2>/dev/null || true)" \
40
+ "$(command -v python3.9 2>/dev/null || true)" \
41
+ "$(command -v python3 2>/dev/null || true)"
42
+ do
43
+ [ -n "$candidate" ] || continue
44
+ if has_distutils "$candidate"; then
45
+ printf '%s\n' "$candidate"
46
+ exit 0
47
+ fi
48
+ done
49
+
50
+ # Nothing usable. Fall back to whatever python3 exists so the caller fails with
51
+ # node-gyp's own message rather than an empty variable, and say why.
52
+ fallback="$(command -v python3 2>/dev/null || true)"
53
+ if [ -n "$fallback" ]; then
54
+ echo "[gyp-python] no python3 with distutils found; falling back to $fallback" >&2
55
+ echo "[gyp-python] node-gyp will likely fail. Install python@3.11 or set PYTHON=<path>." >&2
56
+ printf '%s\n' "$fallback"
57
+ exit 0
58
+ fi
59
+
60
+ echo "[gyp-python] no python3 on PATH at all" >&2
61
+ exit 1
@@ -67,6 +67,10 @@ const GATES = {
67
67
  'tests/unit/browser-web-ai-source-audit.test.ts',
68
68
  'tests/unit/browser-web-ai-cli-contract.test.ts',
69
69
  'tests/unit/release-gates.test.ts',
70
+ // Guards the release/build scripts themselves, so it belongs in
71
+ // the suite gate:all actually runs rather than npm test only.
72
+ 'tests/unit/electron-version-sync.test.ts',
73
+ 'tests/unit/gyp-python-pick.test.ts',
70
74
  ].filter((p) => fs.existsSync(path.join(repoRoot, p)));
71
75
  if (targets.length === 0) {
72
76
  return { ok: false, detail: 'no Phase 22 mirror tests found' };
@@ -394,6 +398,99 @@ const GATES = {
394
398
  return { ok: true, detail: 'live any/debt/allow counts within frozen baseline' };
395
399
  },
396
400
  },
401
+ 'redaction-sinks': {
402
+ description: 'channel replies, sends and loggers route through a credential masker',
403
+ check() {
404
+ const r = run('node', ['scripts/check-redaction-sinks.mjs'], { timeout: 60_000 });
405
+ if (r.status !== 0) {
406
+ return { ok: false, detail: `unmasked channel sink:\n${(r.stdout || r.stderr || '').slice(-1500)}` };
407
+ }
408
+ return { ok: true, detail: 'no unmasked sink in telegram/discord/slack/telegram-hub' };
409
+ },
410
+ },
411
+ 'electron-version': {
412
+ description: 'electron/package.json version matches the root package.json',
413
+ check() {
414
+ const r = run('node', ['scripts/sync-electron-version.cjs', '--check'], { timeout: 60_000 });
415
+ if (r.status !== 0) {
416
+ return { ok: false, detail: (r.stdout || r.stderr || '').trim().slice(-500) };
417
+ }
418
+ return { ok: true, detail: 'desktop artifacts will carry the release version' };
419
+ },
420
+ },
421
+ 'sidecar-prune-safety': {
422
+ description: 'the sidecar prune list never deletes a package the server imports',
423
+ check() {
424
+ const r = run('node', ['scripts/check-sidecar-prune-safety.mjs'], { timeout: 60_000 });
425
+ if (r.status !== 0) {
426
+ return { ok: false, detail: (r.stdout || r.stderr || '').trim().slice(-800) };
427
+ }
428
+ return { ok: true, detail: 'packaged sidecar keeps every runtime dependency' };
429
+ },
430
+ },
431
+ 'gate-docs': {
432
+ description: 'structure/INDEX.md documents exactly the gates that exist, and each is npm-addressable',
433
+ check() {
434
+ // structure/INDEX.md hardcodes the gate count and enumerates every
435
+ // name. Adding the 16th gate made that row wrong, and nothing
436
+ // noticed: check-docs.mts counts only routes and endpoints, and
437
+ // check-doc-drift.sh never looks at gates. Fixing the row by hand
438
+ // cleared one instance; this closes the class.
439
+ //
440
+ // This gate lives inside release-gates.mjs rather than in its own
441
+ // script because the module calls main() unconditionally at the
442
+ // bottom, so importing GATES from outside would run every gate.
443
+ const names = Object.keys(GATES);
444
+ const doc = readFile('structure/INDEX.md');
445
+ const row = doc.split('\n').find((line) =>
446
+ line.includes('named gates') && line.includes('runs all'));
447
+ if (!row) {
448
+ return { ok: false, detail: 'the release-gates row is gone from structure/INDEX.md' };
449
+ }
450
+
451
+ const problems = [];
452
+
453
+ const claimed = row.match(/runs all (\d+) named gates/);
454
+ if (!claimed) {
455
+ problems.push('the row no longer states a gate count');
456
+ } else if (Number(claimed[1]) !== names.length) {
457
+ problems.push(`count says ${claimed[1]}, GATES has ${names.length}`);
458
+ }
459
+
460
+ // Read ONLY the parenthesised list, not the whole row. The row also
461
+ // carries `scripts/release-gates.mjs`, `package.json`, `gate:all`
462
+ // and `gate:<name>` in prose; scanning the row and filtering those
463
+ // out by shape both accuses innocent prose and lets a phantom like
464
+ // `gate:retired-name` through, because a filter keyed on the token
465
+ // shape cannot tell a stale gate from an ordinary mention.
466
+ const list = row.match(/named gates \(([^)]*)\)/);
467
+ if (!list) {
468
+ problems.push('the parenthesised gate list is gone from the row');
469
+ } else {
470
+ const documented = [...list[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]);
471
+ const missing = names.filter((name) => !documented.includes(name));
472
+ const stale = documented.filter((name) => !names.includes(name));
473
+ if (missing.length > 0) problems.push(`undocumented: ${missing.join(', ')}`);
474
+ if (stale.length > 0) problems.push(`documented but gone: ${stale.join(', ')}`);
475
+ }
476
+
477
+ // The same row promises `gate:<name>` addressability. A gate added
478
+ // without its npm script makes that sentence false while gate:all
479
+ // stays green -- the hand-maintained invariant this gate exists to
480
+ // stop being hand-maintained.
481
+ const pkg = JSON.parse(readFile('package.json'));
482
+ const unaddressable = names.filter((name) =>
483
+ pkg.scripts?.[`gate:${name}`] !== `node scripts/release-gates.mjs ${name}`);
484
+ if (unaddressable.length > 0) {
485
+ problems.push(`no npm script: ${unaddressable.map((n) => `gate:${n}`).join(', ')}`);
486
+ }
487
+
488
+ if (problems.length > 0) {
489
+ return { ok: false, detail: `${problems.join('\n')}\nFix structure/INDEX.md / package.json` };
490
+ }
491
+ return { ok: true, detail: `${names.length} gates documented and addressable` };
492
+ },
493
+ },
397
494
  };
398
495
 
399
496
  function printResult(name, result) {
@@ -148,6 +148,13 @@ npm version "$PREVIEW_VERSION" --no-git-tag-version
148
148
  VERSION=$(node -p "require('./package.json').version")
149
149
  echo "📌 package.json version: $VERSION"
150
150
 
151
+ # Same reason as release.sh: the committed Electron version names the desktop
152
+ # artifacts. This also has to run BEFORE gate:all below -- the electron-version
153
+ # gate compares the two manifests, and the bump above has just moved the root
154
+ # one, so skipping this would fail the preview release outright.
155
+ echo "🖥️ Syncing Electron version to $VERSION..."
156
+ node scripts/sync-electron-version.cjs
157
+
151
158
  echo "🔎 Type checking..."
152
159
  pnpm exec tsc --noEmit
153
160
 
@@ -169,7 +176,7 @@ npm pack --dry-run >/dev/null
169
176
 
170
177
  # ─── Commit + Push ────────────────────────────────────
171
178
  echo "📝 Creating local commit..."
172
- git add package.json package-lock.json
179
+ git add package.json package-lock.json electron/package.json electron/package-lock.json
173
180
  git commit -m "[agent] chore: preview v$VERSION" --allow-empty
174
181
 
175
182
  echo "⬆️ Pushing preview branch..."
@@ -129,6 +129,12 @@ fi
129
129
  VERSION=$(node -p "require('./package.json').version")
130
130
  echo "📌 New version: $VERSION"
131
131
 
132
+ # Desktop artifacts are named from the version COMMITTED in
133
+ # electron/package.json, and desktop-release.yml builds from a tag checkout --
134
+ # so this has to happen here, before the release commit, not at build time.
135
+ echo "🖥️ Syncing Electron version to $VERSION..."
136
+ node scripts/sync-electron-version.cjs
137
+
132
138
  # ─── Collect changelog ─────────────────────────────────
133
139
  if [ -n "$PREV_TAG" ]; then
134
140
  CHANGELOG=$(git log "$PREV_TAG"..HEAD --pretty=format:"- %s" --no-merges | head -50)
@@ -155,10 +161,15 @@ fi
155
161
 
156
162
  # ─── Commit + Tag + Push ──────────────────────────────
157
163
  echo "🏷️ Creating git tag v$VERSION..."
158
- git add package.json package-lock.json
164
+ git add package.json package-lock.json electron/package.json electron/package-lock.json
159
165
  git commit -m "[agent] chore: release v$VERSION" --allow-empty
160
166
  git tag "v$VERSION"
161
- git push origin main
167
+ # HEAD:main, not main. `git push origin main` pushes the LOCAL main branch,
168
+ # which is not necessarily the tree just built, committed and tagged: releases
169
+ # are cut from whatever branch is checked out, and a local main left behind at
170
+ # an older release would be published instead. Caught before a real release
171
+ # with local main at v2.2.7 while the release commit sat on dev.
172
+ git push origin HEAD:main
162
173
  git push origin "v$VERSION"
163
174
 
164
175
  # ─── GitHub Release with changelog ─────────────────────
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Keep electron/package.json's version equal to the root package.json's.
4
+ *
5
+ * Without this the two drifted freely: the root reached 2.2.7 while the
6
+ * Electron shell sat at 0.1.0 since its first commit, so every desktop release
7
+ * produced identically-named artifacts (cli-jaw-0.1.0-arm64.dmg) and the file
8
+ * you downloaded told you nothing about which release it came from.
9
+ *
10
+ * Why the version has to be COMMITTED rather than injected at build time:
11
+ * electron-builder's default artifact name is `${productName}-${version}-...`
12
+ * resolved from the packaged app's own package.json, and
13
+ * .github/workflows/desktop-release.yml builds from a release-tag checkout. A
14
+ * build-time flag would work on a developer machine and silently do nothing in
15
+ * the workflow that actually publishes the assets.
16
+ *
17
+ * One implementation serves both the writer and the gate on purpose. A gate
18
+ * that computed the expected value separately could pass while the release
19
+ * wrote something else.
20
+ */
21
+ const { readFileSync } = require('node:fs');
22
+ const { join } = require('node:path');
23
+ const { spawnSync } = require('node:child_process');
24
+
25
+ const repoRoot = join(__dirname, '..');
26
+ const electronDir = join(repoRoot, 'electron');
27
+
28
+ /** Version field of the package.json in `dir`. */
29
+ function readVersion(dir) {
30
+ const file = join(dir, 'package.json');
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(file, 'utf8');
34
+ } catch (error) {
35
+ throw new Error(`cannot read ${file}: ${error.message}`);
36
+ }
37
+ try {
38
+ return JSON.parse(raw).version;
39
+ } catch (error) {
40
+ throw new Error(`${file} is not valid JSON: ${error.message}`);
41
+ }
42
+ }
43
+
44
+ function main() {
45
+ let rootVersion;
46
+ let electronVersion;
47
+ try {
48
+ rootVersion = readVersion(repoRoot);
49
+ electronVersion = readVersion(electronDir);
50
+ } catch (error) {
51
+ // Fails closed either way -- an unreadable manifest already exited
52
+ // non-zero via the uncaught throw. What this adds is a first line the
53
+ // reader can act on instead of a Node stack trace.
54
+ console.error(`ERROR: ${error.message}`);
55
+ return 1;
56
+ }
57
+ const check = process.argv.includes('--check');
58
+
59
+ if (electronVersion === rootVersion) {
60
+ console.log(`[electron-version] OK ${rootVersion}`);
61
+ return 0;
62
+ }
63
+
64
+ if (check) {
65
+ // Name both values. "versions differ" leaves the reader guessing which
66
+ // side is authoritative.
67
+ console.error(
68
+ `ERROR: electron/package.json version ${electronVersion} != root package.json ${rootVersion}`);
69
+ console.error('Run: npm run sync:electron-version');
70
+ return 1;
71
+ }
72
+
73
+ // Delegate to npm instead of editing JSON by hand: `npm version` updates
74
+ // package.json and the root entries of package-lock.json together. Writing
75
+ // both files directly means reimplementing the lockfile's shape, and
76
+ // getting it subtly wrong surfaces later as unexplained churn in someone
77
+ // else's dependency diff.
78
+ const result = spawnSync(
79
+ 'npm',
80
+ ['version', rootVersion, '--no-git-tag-version', '--allow-same-version'],
81
+ { cwd: electronDir, encoding: 'utf8', timeout: 60_000 },
82
+ );
83
+ if (result.status !== 0) {
84
+ const detail = (result.stderr || result.stdout || '').trim();
85
+ console.error(`ERROR: npm version failed in electron/: ${detail}`);
86
+ return 1;
87
+ }
88
+
89
+ console.log(`[electron-version] synced ${electronVersion} -> ${rootVersion}`);
90
+ return 0;
91
+ }
92
+
93
+ process.exit(main());