synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -10,7 +10,9 @@
10
10
  // refuse, user allow rules included; routing always asks (one
11
11
  // route card per task is the task's approval); workers get no
12
12
  // computer, no "user-authorized-full" and a registered cwd.
13
- // autonomous — the modes as configured, until autonomousUntil; then ask.
13
+ // autonomous — the modes as configured. On WhatsApp it stays until the owner
14
+ // lowers it in Settings; any other channel only until its
15
+ // autonomousUntil, then ask.
14
16
  // Ask and Autonomous hold only on a Claude brain: its in-process PreToolUse
15
17
  // hook sees every call with its arguments. A Codex or OpenCode brain runs
16
18
  // ordinary commands inside its own sandbox / permissions without asking, so a
@@ -25,7 +27,7 @@
25
27
  //
26
28
  // Computer use (the Assistant's own computer tools) is off unless the owner
27
29
  // turned on its switch at this computer (the policy's `computerUse`), and then
28
- // only as remoteComputerUse says: unasked while Autonomous is active, after
30
+ // only as remoteComputerUse says: unasked while Autonomous is on, after
29
31
  // one yes per turn at Ask, never at Read-only, while paused or on a Codex /
30
32
  // OpenCode brain. That function is the one place that decides; the runtime,
31
33
  // the brain's hook, the desktop gate, the persona and the panel all ask it.
@@ -81,16 +83,17 @@ export class RemotePolicyError extends Error {
81
83
 
82
84
  /**
83
85
  * The level that applies now: null without a policy. Paused reads as
84
- * read-only, an expired (or never armed) autonomous as ask, a turn whose
85
- * prompt carried untrusted content is capped at ask, and a session whose brain
86
- * cannot hold Ask/Autonomous (`provider`, else the policy's brainProvider:
87
- * Codex, OpenCode) is read-only.
86
+ * read-only, a turn whose prompt carried untrusted content is capped at ask,
87
+ * and a session whose brain cannot hold Ask/Autonomous (`provider`, else the
88
+ * policy's brainProvider: Codex, OpenCode) is read-only. Autonomous on
89
+ * WhatsApp has no time limit (it stays until the owner lowers it); on any
90
+ * other channel an expired (or never armed) autonomousUntil reads as ask.
88
91
  */
89
92
  export function effectiveLevel(policy, { untrusted = false, now = Date.now(), provider = null } = {}) {
90
93
  if (!policy) return null;
91
94
  let level = normalizeLevel(policy.level);
92
95
  if (policy.paused === true) level = 'read-only';
93
- if (level === 'autonomous') {
96
+ if (level === 'autonomous' && policy.channel !== 'whatsapp') {
94
97
  const until = toMs(policy.autonomousUntil);
95
98
  if (!(until > now)) level = 'ask';
96
99
  }
@@ -139,7 +142,8 @@ export const COMPUTER_REASONS = Object.freeze({
139
142
  * read_only the Read-only level (or a session nobody registered since a restart)
140
143
  * unsupported, setup computer use is not available, or not set up, on this machine (`desktop`)
141
144
  * ask ask the Ask level: one yes per turn turns it on for that turn
142
- * autonomous_expired Autonomous is configured but its window is closed (or was never armed)
145
+ * autonomous_expired Autonomous is configured but its autonomousUntil passed (or was never set): never
146
+ * on WhatsApp, where Autonomous has no time limit
143
147
  * untrusted Autonomous is active, but this turn carries content the owner did not write
144
148
  * allowed autonomous Autonomous is active: the computer tools run unasked
145
149
  * `desktop`: { supported, ready } of the desktop service (omitted: not checked).
@@ -288,17 +292,20 @@ function defaultDataHome(env = process.env) {
288
292
  if (osPlatform() === 'win32' && env.APPDATA) return resolve(env.APPDATA, 'synabun');
289
293
  return resolve(homedir(), '.synabun');
290
294
  }
291
- /** Every place the WhatsApp Link may keep its session and connector (lib/whatsapp/paths.js), the refused override too. */
295
+ /**
296
+ * Every place the WhatsApp Link may keep its session and connector (lib/whatsapp/paths.js), the refused
297
+ * override too, and the voice-note folder whose programs the WhatsApp host runs (transcriptionDir).
298
+ */
292
299
  function whatsappLocations(dataHome, env, platform) {
293
300
  const out = [];
294
301
  try {
295
302
  const wa = resolveWhatsAppPaths({ dataHome, env, platform });
296
- out.push(wa.waHome, wa.defaultWaHome, wa.runtimeDir, wa.stagingDir);
303
+ out.push(wa.waHome, wa.defaultWaHome, wa.runtimeDir, wa.stagingDir, wa.transcriptionDir);
297
304
  } catch {}
298
305
  const override = typeof env.SYNABUN_WHATSAPP_HOME === 'string' ? env.SYNABUN_WHATSAPP_HOME.trim() : '';
299
306
  if (override) out.push(resolve(override));
300
307
  if (env.LOCALAPPDATA) out.push(resolve(env.LOCALAPPDATA, 'synabun', 'whatsapp'));
301
- out.push(resolve(dataHome, 'whatsapp'), resolve(dataHome, 'runtime', 'whatsapp'));
308
+ out.push(resolve(dataHome, 'whatsapp'), resolve(dataHome, 'runtime', 'whatsapp'), resolve(dataHome, 'runtime', 'whatsapp-transcription'));
302
309
  return [...new Set(out.filter(Boolean))];
303
310
  }
304
311
  const slash = (value) => String(value || '').replace(/\\/g, '/').replace(/\/{2,}/g, '/').toLowerCase();
@@ -359,6 +366,8 @@ const DENIED_PATTERNS = [
359
366
  { re: /\/etc\/cron|\/var\/at\/tabs|\/usr\/lib\/cron|\/var\/spool\/cron/, why: 'crontab' },
360
367
  { re: /\/api\/whatsapp\b/, why: 'the WhatsApp link' },
361
368
  { re: /(?:^|[^a-z0-9_-])runtime\/whatsapp\b/, why: 'the WhatsApp link' },
369
+ // The voice-note folder by name, wherever it sits (a relative path, a moved home): the WhatsApp host runs the programs in it.
370
+ { re: /(?:^|[^a-z0-9_.-])whatsapp-transcription(?=$|[^a-z0-9_-])/, why: 'the WhatsApp link (voice-note programs)' },
362
371
  { re: /(?:^|[^a-z0-9_-])\.?synabun\/whatsapp\b/, why: 'the WhatsApp link' },
363
372
  // The session store by name, wherever it sits (a relative path, a moved home): state.db and its -wal/-shm/-journal.
364
373
  { re: /(?:^|[^a-z0-9_.-])state\.db(?:-(?:wal|shm|journal))?(?=$|[^a-z0-9_.-])/, why: 'the WhatsApp login (state.db)' },
@@ -1,3 +1,4 @@
1
+ import { withPathEnv } from './augmented-path.js';
1
2
  import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk';
2
3
  import { CLAUDE_EFFORT_LEVELS } from './claude-model-catalog.js';
3
4
  import {
@@ -94,7 +95,7 @@ async function generateClaudeTitle(input, deps) {
94
95
  includePartialMessages: false,
95
96
  abortController,
96
97
  ...(executable?.path ? { pathToClaudeCodeExecutable: executable.path } : {}),
97
- env: cleanClaudeEnv(deps.env || process.env),
98
+ env: typeof deps.childEnv === 'function' ? deps.childEnv(cleanClaudeEnv(deps.env || process.env)) : cleanClaudeEnv(deps.env || process.env),
98
99
  canUseTool: async () => ({ behavior: 'deny', message: 'Session title generation cannot use tools.' }),
99
100
  },
100
101
  });
@@ -123,11 +124,11 @@ async function generateCodexTitle(input, deps) {
123
124
  const effort = codexReasoningEffort(input.effort);
124
125
  if (effort) args.push('-c', `model_reasoning_effort="${effort}"`);
125
126
  args.push(titlePrompt(input));
126
- const env = {
127
+ let env = {
127
128
  ...(deps.env || process.env),
128
129
  CODEX_HOME: deps.getCodexAccountHome?.(input.accountId || 'default'),
129
130
  };
130
- if (typeof deps.getAugmentedPath === 'function') env.PATH = deps.getAugmentedPath();
131
+ if (typeof deps.getAugmentedPath === 'function') env = withPathEnv(env, deps.getAugmentedPath());
131
132
  const { stdout } = await deps.execFile(deps.getCodexBin(), args, {
132
133
  cwd: input.cwd || process.cwd(),
133
134
  env,
@@ -0,0 +1,340 @@
1
+ // SynaBun — the facts behind Settings → About (GET /api/system/about)
2
+ //
3
+ // What the About page shows: the app (version, channel, how it was installed, the Git branch of a clone), the
4
+ // runtime it runs on, the links its package.json and README give, and every dependency of the packages the
5
+ // product is made of, each with the version actually installed and its license. Everything is read from files
6
+ // on disk: package.json files, node_modules/<name>/package.json, the vendored bundles' manifest.json, LICENSE,
7
+ // .git/HEAD. No npm, no git process, no network. The answer is built on the first get() and kept for the life of
8
+ // the process: the code that runs does not change until a restart either.
9
+ //
10
+ // Never in it: an environment variable, a token, a setting. A path is the data folder's and nothing else.
11
+
12
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
13
+ import { isAbsolute, join, relative, resolve } from 'node:path';
14
+ import os from 'node:os';
15
+ import { formatSynabunVersion, synabunReleaseChannel } from './synabun-version.js';
16
+
17
+ /**
18
+ * The packages the product is made of, relative to the package root. One that is not on disk (the desktop app's
19
+ * shell is in a Git clone only; inside the desktop app it is packed where the server cannot read it) is left out.
20
+ * `onDemand`: installed later, by SynaBun itself, into `installDirs` (the WhatsApp connector, lib/whatsapp/installer.js).
21
+ */
22
+ export const ABOUT_PACKAGES = Object.freeze([
23
+ Object.freeze({ id: 'neural-interface', dir: 'neural-interface' }),
24
+ Object.freeze({ id: 'mcp-server', dir: 'mcp-server' }),
25
+ Object.freeze({ id: 'desktop', dir: 'packaging/desktop' }),
26
+ Object.freeze({ id: 'whatsapp-connector', dir: 'neural-interface/lib/whatsapp/connector', onDemand: true }),
27
+ ]);
28
+
29
+ /** Front-end libraries bundled into public/vendor/ by scripts/build-vendor.mjs: folder → package and product name. */
30
+ const VENDOR_BUNDLES = Object.freeze({
31
+ three: { label: 'three.js', name: 'three' },
32
+ xterm: { label: 'xterm.js', name: '@xterm/xterm' },
33
+ codemirror: { label: 'CodeMirror' },
34
+ });
35
+
36
+ // The community links README.md gives (package.json has no field for them).
37
+ const COMMUNITY_LINKS = Object.freeze([
38
+ Object.freeze({ id: 'discord', url: 'https://discord.gg/x6yWqE9GZP' }),
39
+ Object.freeze({ id: 'x', url: 'https://x.com/SynabunAI' }),
40
+ ]);
41
+
42
+ const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
43
+ const MAX_TEXT = 200;
44
+
45
+ const text = (value, max = MAX_TEXT) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);
46
+
47
+ function readJson(path) {
48
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
49
+ }
50
+
51
+ function readText(path, max = 256 * 1024) {
52
+ try {
53
+ const body = readFileSync(path, 'utf8');
54
+ return body.length > max ? body.slice(0, max) : body;
55
+ } catch { return ''; }
56
+ }
57
+
58
+ /** A package.json's license, as written: an SPDX expression, the old object, or the old list. */
59
+ export function licenseOf(pkg) {
60
+ if (!pkg || typeof pkg !== 'object') return null;
61
+ const one = (value) => (typeof value === 'string' ? text(value, 64) : value && typeof value === 'object' ? text(value.type, 64) : null);
62
+ if (pkg.license != null) return one(pkg.license);
63
+ if (Array.isArray(pkg.licenses)) {
64
+ const all = pkg.licenses.map(one).filter(Boolean);
65
+ return all.length ? all.join(' OR ').slice(0, 64) : null;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Is `child` the folder `parent` or inside it? */
71
+ function within(child, parent) {
72
+ const rel = relative(parent, child);
73
+ return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel));
74
+ }
75
+
76
+ /**
77
+ * Where Node would find `name` for a package in `dir`: node_modules of `dir` and of each folder above it, up to
78
+ * `root` and never past it, then the extra folders (an on-demand install). The package.json found, or null.
79
+ */
80
+ function installedPackage(name, dir, root, extraDirs = []) {
81
+ if (!NPM_NAME.test(name)) return null;
82
+ const folders = [];
83
+ if (within(dir, root)) {
84
+ for (let at = dir; ; at = resolve(at, '..')) {
85
+ folders.push(at);
86
+ if (at === root || resolve(at, '..') === at) break;
87
+ }
88
+ }
89
+ for (const extra of extraDirs) folders.push(extra);
90
+ for (const folder of folders) {
91
+ const pkg = readJson(join(folder, 'node_modules', ...name.split('/'), 'package.json'));
92
+ if (pkg && typeof pkg.version === 'string') return pkg;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** One dependency: the range package.json declares, the version installed (or null) and its license. */
98
+ function dependencyRow(name, declared, dir, root, extraDirs, extra = {}) {
99
+ const pkg = installedPackage(name, dir, root, extraDirs);
100
+ return {
101
+ name: String(name).slice(0, 214),
102
+ declared: text(String(declared ?? ''), 120) || '',
103
+ version: pkg ? text(pkg.version, 64) : null,
104
+ installed: !!pkg,
105
+ license: licenseOf(pkg),
106
+ ...extra,
107
+ };
108
+ }
109
+
110
+ const byName = (a, b) => a.name.localeCompare(b.name, 'en');
111
+
112
+ /** The dependency groups of one package of the product, or null when its package.json is not on disk. */
113
+ export function readProductPackage(spec, { root, installDirs = [] } = {}) {
114
+ const dir = resolve(root, spec.dir);
115
+ const pkg = readJson(join(dir, 'package.json'));
116
+ if (!pkg || typeof pkg !== 'object') return null;
117
+ const deps = (field) => (pkg[field] && typeof pkg[field] === 'object' && !Array.isArray(pkg[field]) ? Object.entries(pkg[field]) : []);
118
+ const optional = new Set(deps('optionalDependencies').map(([name]) => name));
119
+ const runtime = [
120
+ ...deps('dependencies').filter(([name]) => !optional.has(name)).map(([name, range]) => dependencyRow(name, range, dir, root, installDirs)),
121
+ ...deps('optionalDependencies').map(([name, range]) => dependencyRow(name, range, dir, root, installDirs, { optional: true })),
122
+ ].sort(byName);
123
+ const dev = deps('devDependencies').map(([name, range]) => dependencyRow(name, range, dir, root, installDirs)).sort(byName);
124
+ return {
125
+ id: spec.id,
126
+ name: text(pkg.name, 214) || spec.id,
127
+ version: text(pkg.version, 64),
128
+ onDemand: !!spec.onDemand,
129
+ runtime,
130
+ dev,
131
+ };
132
+ }
133
+
134
+ /** The bundles in public/vendor/: each with the version its manifest records, and the packages it holds. */
135
+ export function readVendored(vendorDir, { licenseFrom } = {}) {
136
+ let folders = [];
137
+ try { folders = readdirSync(vendorDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); } catch { return []; }
138
+ const out = [];
139
+ for (const folder of folders) {
140
+ const manifest = readJson(join(vendorDir, folder, 'manifest.json'));
141
+ const version = text(manifest?.version, 64);
142
+ if (!manifest || !version) continue; // no manifest, no honest version
143
+ const known = VENDOR_BUNDLES[folder] || {};
144
+ let packages;
145
+ if (manifest.packages && typeof manifest.packages === 'object') {
146
+ packages = Object.entries(manifest.packages)
147
+ .filter(([name, v]) => NPM_NAME.test(name) && typeof v === 'string' && v)
148
+ .map(([name, v]) => ({ name, version: v.slice(0, 64) }));
149
+ } else {
150
+ const name = known.name || folder;
151
+ if (!NPM_NAME.test(name)) continue;
152
+ packages = [{ name, version: version.replace(/-[0-9a-f]{8}$/, '') }];
153
+ }
154
+ for (const p of packages) p.license = licenseFrom ? licenseFrom(p.name, p.version) : null;
155
+ packages.sort(byName);
156
+ out.push({ id: folder, label: known.label || folder, version: version.replace(/-[0-9a-f]{8}$/, ''), packages });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** `git+https://github.com/o/r.git` → `https://github.com/o/r`; null for anything but an https address. */
162
+ export function repositoryUrl(repository) {
163
+ const raw = typeof repository === 'string' ? repository : repository?.url;
164
+ if (typeof raw !== 'string') return null;
165
+ const url = raw.trim().replace(/^git\+/, '').replace(/\.git$/, '').replace(/\/+$/, '');
166
+ return /^https:\/\/[^\s/]+\/[^\s]+$/.test(url) ? url : null;
167
+ }
168
+
169
+ const httpsOrNull = (value) => (typeof value === 'string' && /^https:\/\/\S+$/.test(value.trim()) ? value.trim().replace(/\/+$/, '') : null);
170
+
171
+ /**
172
+ * The project's links, from the root package.json: repository, homepage, bugs, name. The website's docs and
173
+ * changelog pages, the npm page and the community links are the addresses README.md gives; the license is the
174
+ * README's ./LICENSE on GitHub's default branch. A link whose source is missing is left out.
175
+ */
176
+ export function aboutLinks(pkg) {
177
+ const repository = repositoryUrl(pkg?.repository);
178
+ const github = repository && /^https:\/\/github\.com\//.test(repository) ? repository : null;
179
+ const website = httpsOrNull(pkg?.homepage);
180
+ const issues = httpsOrNull(typeof pkg?.bugs === 'string' ? pkg.bugs : pkg?.bugs?.url) || (github ? `${github}/issues` : null);
181
+ const name = typeof pkg?.name === 'string' && NPM_NAME.test(pkg.name) ? pkg.name : null;
182
+ const links = [
183
+ { id: 'repository', url: repository },
184
+ { id: 'releases', url: github ? `${github}/releases` : null },
185
+ { id: 'changelog', url: website ? `${website}/changelog` : null },
186
+ { id: 'issues', url: issues },
187
+ { id: 'website', url: website },
188
+ { id: 'docs', url: website ? `${website}/docs` : null },
189
+ { id: 'npm', url: name && !pkg.private ? `https://www.npmjs.com/package/${name}` : null },
190
+ { id: 'license', url: github ? `${github}/blob/main/LICENSE` : null },
191
+ ...COMMUNITY_LINKS,
192
+ ];
193
+ return links.filter((link) => link.url);
194
+ }
195
+
196
+ /** The years and holder of the copyright line, from LICENSE (or NOTICE); the holder is package.json's author when it names one. */
197
+ export function copyrightOf({ license = '', notice = '', author = null } = {}) {
198
+ const lines = `${license}\n${notice}`.split(/\r?\n/);
199
+ for (const line of lines) {
200
+ const m = /^\s*Copyright\s+(?:\(c\)\s*|©\s*)?(\d{4}(?:\s*[-–]\s*\d{4})?)\s+(.+?)\s*$/i.exec(line);
201
+ if (!m || /\[|\]/.test(m[2])) continue; // the Apache template's "[yyyy] [name of copyright owner]"
202
+ return { years: m[1].replace(/\s*[-–]\s*/, '–'), holder: author || text(m[2], 120) };
203
+ }
204
+ return null;
205
+ }
206
+
207
+ const authorName = (author) => text(typeof author === 'string' ? author.replace(/\s*[<(].*$/, '') : author?.name, 120);
208
+
209
+ /** The branch and short commit of the checkout at `root`, read from .git (a worktree's `gitdir:` file too). */
210
+ export function readGitHead(root) {
211
+ let gitDir = join(root, '.git');
212
+ try {
213
+ if (statSync(gitDir).isFile()) {
214
+ const m = /^gitdir:\s*(.+)$/m.exec(readText(gitDir, 4096));
215
+ if (!m) return null;
216
+ gitDir = resolve(root, m[1].trim());
217
+ }
218
+ } catch { return null; }
219
+ const head = readText(join(gitDir, 'HEAD'), 4096).trim();
220
+ if (!head) return null;
221
+ const common = (() => {
222
+ const pointer = readText(join(gitDir, 'commondir'), 4096).trim();
223
+ return pointer ? resolve(gitDir, pointer) : gitDir;
224
+ })();
225
+ const ref = /^ref:\s*(refs\/heads\/([^\s]+))$/.exec(head);
226
+ let commit = null;
227
+ if (ref && !ref[1].split('/').includes('..')) {
228
+ for (const dir of [gitDir, common]) {
229
+ const loose = readText(join(dir, ...ref[1].split('/')), 4096).trim();
230
+ if (/^[0-9a-f]{40,64}$/.test(loose)) { commit = loose; break; }
231
+ }
232
+ if (!commit) {
233
+ const packed = readText(join(common, 'packed-refs'));
234
+ const line = packed.split(/\r?\n/).find((l) => l.endsWith(` ${ref[1]}`));
235
+ if (line && /^[0-9a-f]{40,64} /.test(line)) commit = line.split(' ')[0];
236
+ }
237
+ } else if (/^[0-9a-f]{40,64}$/.test(head)) {
238
+ commit = head;
239
+ }
240
+ const branch = ref ? text(ref[2], 120) : null;
241
+ if (!branch && !commit) return null;
242
+ return { branch, commit: commit ? commit.slice(0, 7) : null };
243
+ }
244
+
245
+ /** The operating system's own name and version: macOS from SystemVersion.plist, Linux from os-release, Windows from os.version(). */
246
+ export function readOsInfo({ platform = process.platform, readFile = readText, osApi = os } = {}) {
247
+ const kernel = `${osApi.type()} ${osApi.release()}`;
248
+ let name = null;
249
+ let version = null;
250
+ if (platform === 'darwin') {
251
+ const plist = readFile('/System/Library/CoreServices/SystemVersion.plist', 64 * 1024);
252
+ const key = (k) => text(new RegExp(`<key>${k}</key>\\s*<string>([^<]+)</string>`).exec(plist)?.[1], 64);
253
+ name = key('ProductName') || 'macOS';
254
+ version = key('ProductVersion');
255
+ } else if (platform === 'linux') {
256
+ const release = readFile('/etc/os-release', 64 * 1024);
257
+ const field = (k) => text(new RegExp(`^${k}=("?)([^"\\n]*)\\1\\s*$`, 'm').exec(release)?.[2], 120);
258
+ name = field('NAME') || 'Linux';
259
+ version = field('VERSION_ID') || field('VERSION');
260
+ } else if (platform === 'win32') {
261
+ name = text(typeof osApi.version === 'function' ? osApi.version() : '', 120) || 'Windows';
262
+ version = text(osApi.release(), 64);
263
+ } else {
264
+ name = text(osApi.type(), 64);
265
+ version = text(osApi.release(), 64);
266
+ }
267
+ return { name, version, kernel: text(kernel, 120), platform, arch: process.arch };
268
+ }
269
+
270
+ /**
271
+ * The About facts of this install, built on the first get() and kept.
272
+ *
273
+ * @param {Object} o
274
+ * @param {string} o.packageRoot the SynaBun package (PACKAGE_ROOT)
275
+ * @param {string} o.dataHome the data folder (shown on the page; it is the owner's, and the route is the owner's)
276
+ * @param {number|string} o.port the port the server listens on
277
+ * @param {'desktop'|'app'|'npm'|'git'} o.install how this copy was installed
278
+ * @param {Object<string, string[]>} [o.installDirs] more folders to resolve a package's dependencies in, by package id
279
+ * @param {string} [o.vendorDir] the vendored bundles (public/vendor of the Neural Interface)
280
+ * @param {() => Object} [o.runtime] the runtime facts (tests); default: this process
281
+ */
282
+ export function createSystemAbout({ packageRoot, dataHome, port, install, installDirs = {}, vendorDir, runtime } = {}) {
283
+ const root = resolve(packageRoot);
284
+ let cached = null;
285
+
286
+ const build = () => {
287
+ const pkg = readJson(join(root, 'package.json')) || {};
288
+ const version = typeof pkg.version === 'string' ? (formatSynabunVersion(pkg.version) || text(pkg.version, 64)) : null;
289
+ const author = authorName(pkg.author);
290
+ const packages = ABOUT_PACKAGES.map((spec) => readProductPackage(spec, { root, installDirs: installDirs[spec.id] || [] })).filter(Boolean);
291
+ const ni = resolve(root, 'neural-interface');
292
+ const vendored = readVendored(vendorDir || join(ni, 'public', 'vendor'), {
293
+ // The bundle was built from the packages in the Neural Interface's node_modules: their license, when they are there.
294
+ licenseFrom: (name) => licenseOf(installedPackage(name, ni, root)),
295
+ });
296
+ const count = (list) => list.length;
297
+ const counts = {
298
+ runtime: packages.reduce((n, p) => n + count(p.runtime), 0),
299
+ dev: packages.reduce((n, p) => n + count(p.dev), 0),
300
+ vendored: vendored.reduce((n, b) => n + count(b.packages), 0),
301
+ };
302
+ counts.total = counts.runtime + counts.dev + counts.vendored;
303
+ const facts = runtime ? runtime() : {
304
+ node: process.versions.node,
305
+ sqlite: process.versions.sqlite || null,
306
+ os: readOsInfo(),
307
+ };
308
+ return {
309
+ app: {
310
+ name: 'SynaBun',
311
+ package: text(pkg.name, 214),
312
+ version,
313
+ channel: version ? synabunReleaseChannel(version) : 'stable',
314
+ description: text(pkg.description),
315
+ license: licenseOf(pkg),
316
+ author,
317
+ copyright: copyrightOf({ license: readText(join(root, 'LICENSE')), notice: readText(join(root, 'NOTICE')), author }),
318
+ install: ['desktop', 'app', 'npm', 'git'].includes(install) ? install : null,
319
+ git: install === 'git' ? readGitHead(root) : null,
320
+ },
321
+ runtime: {
322
+ node: facts.node || null,
323
+ sqlite: facts.sqlite || null,
324
+ os: facts.os || null,
325
+ dataHome: typeof dataHome === 'string' ? dataHome : null,
326
+ port: Number(port) || null,
327
+ },
328
+ links: aboutLinks(pkg),
329
+ dependencies: { packages, vendored, counts },
330
+ builtAt: new Date().toISOString(),
331
+ };
332
+ };
333
+
334
+ return {
335
+ get() {
336
+ if (!cached) cached = build();
337
+ return cached;
338
+ },
339
+ };
340
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * SynaBun — is anything running that a restart would end?
3
+ *
4
+ * The desktop app installs an update only while this says idle
5
+ * (packaging/desktop/src/updater/controller.mjs): a restart ends every agent
6
+ * run, side-panel reply, loop and backup the server holds. server.js hands in
7
+ * one counter per kind, each reading the state the server already keeps
8
+ * (`GET /api/system/activity`); this module only adds them up. Pure, tested in
9
+ * tests/system-activity.test.mjs.
10
+ *
11
+ * A counter that throws counts as `unknown`: when SynaBun cannot tell, it is
12
+ * not idle.
13
+ */
14
+
15
+ /** The kinds an answer may name, in the order the interface lists them. */
16
+ export const ACTIVITY_KINDS = Object.freeze([
17
+ 'assistant', 'claude-panel', 'codex-panel', 'opencode-panel', 'claude-cli', 'loop', 'schedule', 'backup', 'maintenance', 'unknown',
18
+ ]);
19
+
20
+ /** A Claude Code session that took a prompt this recently may still be working on it. */
21
+ export const CLI_RECENT_MS = 10 * 60 * 1000;
22
+
23
+ /**
24
+ * `sources` is `{ kind: () => count }`. Returns `{ idle, busy: [{ kind,
25
+ * count }], checkedAt }`, busy in ACTIVITY_KINDS order, one entry per kind.
26
+ */
27
+ export function collectActivity(sources = {}, { now = () => Date.now(), log = () => {} } = {}) {
28
+ const counts = new Map();
29
+ const add = (kind, count) => counts.set(kind, (counts.get(kind) || 0) + count);
30
+ for (const [name, read] of Object.entries(sources || {})) {
31
+ const kind = ACTIVITY_KINDS.includes(name) ? name : 'unknown';
32
+ let count;
33
+ try {
34
+ count = Number(typeof read === 'function' ? read() : read);
35
+ } catch (error) {
36
+ log(`activity: ${name} could not be read: ${error?.message || error}`);
37
+ add('unknown', 1);
38
+ continue;
39
+ }
40
+ if (!Number.isFinite(count) || count < 0) { add('unknown', 1); continue; }
41
+ if (count >= 1) add(kind, Math.floor(count));
42
+ }
43
+ const busy = ACTIVITY_KINDS.filter(kind => counts.get(kind) > 0).map(kind => ({ kind, count: counts.get(kind) }));
44
+ return { idle: busy.length === 0, busy, checkedAt: new Date(now()).toISOString() };
45
+ }
46
+
47
+ /** How many of `items` satisfy `test`, for a Set, a Map's values, an array or nothing. */
48
+ export function countWhere(items, test = Boolean) {
49
+ if (!items) return 0;
50
+ const values = items instanceof Map ? items.values() : items;
51
+ let count = 0;
52
+ for (const item of values) if (test(item)) count += 1;
53
+ return count;
54
+ }
55
+
56
+ /**
57
+ * Claude Code sessions (a SynaBun terminal or any other) that took a prompt
58
+ * within CLI_RECENT_MS: the hooks report a prompt, never its end, so a recent
59
+ * one counts as working. `sessions` is the server's session registry.
60
+ */
61
+ export function recentCliSessions(sessions, { now = Date.now(), windowMs = CLI_RECENT_MS } = {}) {
62
+ return countWhere(sessions, (entry) => {
63
+ const at = Number(entry?.lastActivity) || 0;
64
+ return at > 0 && now - at >= 0 && now - at < windowMs;
65
+ });
66
+ }
@@ -4,7 +4,8 @@
4
4
  // terminal happened to launch the SynaBun server.
5
5
 
6
6
  import { readFileSync } from 'node:fs';
7
- import { basename } from 'node:path';
7
+ import { basename, win32 } from 'node:path';
8
+ import { pathEnvValue, withPathEnv } from './augmented-path.js';
8
9
 
9
10
  // Identity/session variables that belong to the terminal or IDE the SERVER was
10
11
  // started from. Leaking them makes CLIs believe they run inside VS Code, tmux,
@@ -62,7 +63,9 @@ export function buildTerminalEnv({
62
63
  if (platform !== 'win32' && !env.LANG && !env.LC_ALL && !env.LC_CTYPE) {
63
64
  env.LANG = localeFallback;
64
65
  }
65
- return { ...env, ...(profileEnv || {}), ...(extraEnv || {}) };
66
+ const merged = { ...env, ...(profileEnv || {}), ...(extraEnv || {}) };
67
+ const pathValue = [extraEnv, profileEnv, base].find(layer => Object.keys(layer || {}).some(key => key.toUpperCase() === 'PATH'));
68
+ return withPathEnv(merged, pathEnvValue(pathValue || {}), platform);
66
69
  }
67
70
 
68
71
  export function shellSupportsLoginCommand(shellPath) {
@@ -80,7 +83,12 @@ export function interactiveShellArgs(platform = process.platform) {
80
83
  * (PATH from .zprofile, brew shellenv) before the CLI starts.
81
84
  */
82
85
  export function cliWrapperArgs(shellPath, cmd, platform = process.platform) {
83
- if (platform === 'win32') return ['/k', cmd];
86
+ if (platform === 'win32') {
87
+ const name = win32.basename(shellPath).toLowerCase();
88
+ if (/^(powershell|pwsh)(\.exe)?$/.test(name)) return ['-NoExit', '-Command', cmd];
89
+ if (/^(ba|z|fi)?sh(\.exe)?$/.test(name)) return ['-lc', `${cmd}; exec "${shellPath.replace(/\\/g, '/')}" -l`];
90
+ return ['/d', '/s', '/k', cmd];
91
+ }
84
92
  if (shellSupportsLoginCommand(shellPath)) return ['-lc', `${cmd}; exec $SHELL -l`];
85
93
  return ['-c', `${cmd}; exec $SHELL`];
86
94
  }
@@ -0,0 +1,55 @@
1
+ // User-facing terminals start from today's OS environment, not the runtime's
2
+ // private Node/npm PATH. Shared by every PTY spawn and PATH diagnostics.
3
+ import path from 'node:path';
4
+ import { pathEnvValue, withPathEnv } from './augmented-path.js';
5
+ import { dedupePath, expandWindowsVars, splitPathValue } from './os-path.js';
6
+ import { cmdEscapeArgument, cmdEscapeCommand, spawnArgsFor } from './cli-resolver.js';
7
+
8
+ // synabunDirs: the packaged runtime, always last and never earlier. extraDirs:
9
+ // the global bin of the npm that came with it, added before them when nothing
10
+ // of the user's has put it on PATH already (then it stays where the user has it).
11
+ export function composeTerminalPath({ env = process.env, platform = process.platform, os = {}, launchers = [], synabunDirs = [], extraDirs = [] } = {}) {
12
+ const impl = platform === 'win32' ? path.win32 : path.posix;
13
+ const normalize = dir => platform === 'win32' ? dir.replace(/\//g, '\\').toLowerCase().replace(/[\\/]+$/, '') : dir.replace(/\/+$/, '') || '/';
14
+ const privateDirs = new Set(synabunDirs.filter(Boolean).map(normalize));
15
+ const entries = [];
16
+ const seen = new Set();
17
+ const add = (dir, source) => {
18
+ if (platform === 'win32') dir = expandWindowsVars(dir, env);
19
+ const key = normalize(dir);
20
+ if (!dir || seen.has(key) || (source !== 'SynaBun' && privateDirs.has(key))) return;
21
+ seen.add(key); entries.push({ dir, source });
22
+ };
23
+ for (const dir of os.entries || []) add(dir, os.sources?.find(entry => normalize(entry.dir) === normalize(dir))?.source || 'login-shell');
24
+ for (const found of launchers) if (found.path) add(impl.dirname(found.path), 'CLI folder');
25
+ // An unavailable OS reader falls back to the inherited environment. Never
26
+ // let packaged runtime folders lead user tools, even in this fallback.
27
+ if (!os.entries?.length) for (const dir of splitPathValue(pathEnvValue(env), platform)) add(dir, 'inherited');
28
+ for (const dir of extraDirs) if (dir) add(dir, 'npm global');
29
+ for (const dir of synabunDirs) if (dir) add(dir, 'SynaBun');
30
+ const value = dedupePath(entries.map(entry => entry.dir), platform).join(platform === 'win32' ? ';' : ':');
31
+ return { env: withPathEnv(env, value, platform), entries, value };
32
+ }
33
+
34
+ const posixQuote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
35
+
36
+ export function terminalArgument(value, shell, platform = process.platform) {
37
+ const name = path.win32.basename(shell).replace(/\.exe$/i, '').toLowerCase();
38
+ if (platform !== 'win32' || ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh'].includes(name)) return posixQuote(value);
39
+ if (name === 'powershell' || name === 'pwsh') return `'${String(value).replace(/'/g, "''")}'`;
40
+ return cmdEscapeArgument(value, false);
41
+ }
42
+
43
+ /** Render a resolver-owned absolute launch spec for the selected shell. */
44
+ export function terminalLaunchCommand(found, shell, { platform = process.platform, env = process.env, execPath = process.execPath } = {}) {
45
+ if (!found?.spec) return null;
46
+ const launch = spawnArgsFor(found.spec, [], { env, execPath });
47
+ const words = [launch.command, ...launch.args];
48
+ const name = path.win32.basename(shell).replace(/\.exe$/i, '').toLowerCase();
49
+ if (platform !== 'win32' || ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh'].includes(name)) return words.map(posixQuote).join(' ');
50
+ if (/(?:powershell|pwsh)(?:\.exe)?$/i.test(shell)) return `& ${words.map(word => `'${String(word).replace(/'/g, "''")}'`).join(' ')}`;
51
+ // cmd shims already have their inner escaping from spawnArgsFor. Keep that
52
+ // command intact; native executables use the same escaping as the resolver.
53
+ if (found.spec.kind === 'cmd') return `${cmdEscapeCommand(launch.command)} /d /s /c ${launch.args.at(-1)}`;
54
+ return [cmdEscapeCommand(launch.command), ...launch.args.map(word => cmdEscapeArgument(word, false))].join(' ');
55
+ }
@@ -15,7 +15,17 @@ export const OPEN = 1;
15
15
  export const CLOSING = 2;
16
16
  export const CLOSED = 3;
17
17
 
18
- export function createVirtualSocket({ onSend = () => {}, name = 'virtual' } = {}) {
18
+ // A brain's credential (assistant-runtime.js mintBrainCredential), kept beside the socket object
19
+ // that server code built: a client's WebSocket never has one, and nothing a client sends can set
20
+ // it. The Codex skin handler reads it here (brainCredentialOf), never from a message.
21
+ const credentials = new WeakMap();
22
+
23
+ /** The credential the server attached to this in-process socket, or null (always null for a client's socket). */
24
+ export function brainCredentialOf(socket) {
25
+ return (socket && credentials.get(socket)) || null;
26
+ }
27
+
28
+ export function createVirtualSocket({ onSend = () => {}, name = 'virtual', credential = null } = {}) {
19
29
  const emitter = new EventEmitter();
20
30
  emitter.setMaxListeners(50);
21
31
  const socket = {
@@ -56,5 +66,6 @@ export function createVirtualSocket({ onSend = () => {}, name = 'virtual' } = {}
56
66
  emit: (...args) => emitter.emit(...args),
57
67
  listenerCount: (type) => emitter.listenerCount(type),
58
68
  };
69
+ if (typeof credential === 'string' && credential) credentials.set(socket, credential);
59
70
  return socket;
60
71
  }