synabun 2.2.0 → 2.2.1

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/mcp-server/package-lock.json +2 -2
  4. package/mcp-server/package.json +1 -1
  5. package/neural-interface/i18n/en.json +3 -0
  6. package/neural-interface/i18n/pt-BR.json +3 -0
  7. package/neural-interface/lib/assistant-brains/codex.js +6 -1
  8. package/neural-interface/lib/assistant-dispatch.js +4 -1
  9. package/neural-interface/lib/mcp-external-servers.js +27 -3
  10. package/neural-interface/lib/native-loop-providers.js +3 -2
  11. package/neural-interface/lib/opencode-native-boundary.js +155 -21
  12. package/neural-interface/lib/peer-identity.js +49 -5
  13. package/neural-interface/package-lock.json +2 -2
  14. package/neural-interface/package.json +1 -1
  15. package/neural-interface/public/shared/assistant/asst-api.js +14 -2
  16. package/neural-interface/public/shared/assistant/asst-computer.js +26 -2
  17. package/neural-interface/public/shared/assistant/asst-motion-gate.js +80 -0
  18. package/neural-interface/public/shared/assistant/asst-panel.js +1 -1
  19. package/neural-interface/public/shared/assistant/asst-render.js +62 -7
  20. package/neural-interface/public/shared/assistant/asst-scene-styles.js +55 -23
  21. package/neural-interface/public/shared/assistant/asst-scenes.js +225 -79
  22. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +4 -4
  23. package/neural-interface/public/shared/assistant/asst-style-cache.js +44 -0
  24. package/neural-interface/public/shared/assistant/asst-styles.js +24 -4
  25. package/neural-interface/public/shared/cp/cp-permissions.js +12 -0
  26. package/neural-interface/public/shared/loading-teardown.js +34 -0
  27. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +6 -1
  28. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +1 -0
  29. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +10 -0
  30. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +6 -0
  31. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +88 -72
  32. package/neural-interface/public/shared/storage-retry.js +47 -0
  33. package/neural-interface/public/shared/storage.js +25 -4
  34. package/neural-interface/public/shared/styles.css +8 -1
  35. package/neural-interface/public/shared/synabun-mascot.js +27 -8
  36. package/neural-interface/public/shared/synabun-ticker.js +147 -12
  37. package/neural-interface/public/shared/ui-claude-panel.js +79 -61
  38. package/neural-interface/public/shared/ui-loading.js +5 -4
  39. package/neural-interface/server.js +87 -40
  40. package/npm-shrinkwrap.json +2 -2
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ # SynaBun v.2.2.1
2
+
3
+ 2.2.1 is a fix release: shared browser runs work again with MoreLogin, screenshots reach OpenAI models in the Assistant, and the Assistant no longer keeps the CPU busy while a worker runs.
4
+
5
+ ## ✨ New
6
+
7
+ - **Profiling the app with a debug client** — Developers can set `SYNABUN_ALLOW_DEBUG_CLIENT` to a port when starting SynaBun, so a local browser started with remote debugging on that port can load the app and profile it. It is off unless you set it.
8
+
9
+ ## 🛠 Fixed
10
+
11
+ - **Shared browser runs refused with an unclear error** — With MoreLogin as the browser, tasks that needed the browser could fail with a generic "unverified OpenCode serve" error even when OpenCode had nothing to do with the task. They now run, and a real refusal names its cause.
12
+ - **Screenshots ignored by OpenAI models in the Assistant** — Screenshots attached with the paperclip or sent from the whiteboard now reach Codex and OpenAI models as images.
13
+ - **High CPU while a worker ran** — The Assistant window used most of a CPU core while a worker was running. It now stays near idle.
14
+ - **Settings showed placeholders for ordinary MCP values** — When hiding secrets in MCP server settings, SynaBun also hid ordinary words such as the project name. Now only real credentials are hidden.
15
+ - **File Explorer git menu hidden under the toolbar** — The branch and commit menu now opens above the top-right toolbar instead of underneath it.
16
+
17
+ ---
18
+
1
19
  # SynaBun v.2.2.0
2
20
 
3
21
  2.2.0 is a reliability release: it fixes a slow memory buildup in long-running chat panels, makes closing a minimized OpenCode tab actually stop that conversation, and cleans up terminal processes left behind by a crash.
package/README.md CHANGED
@@ -178,7 +178,7 @@ Everything SynaBun stores is in one folder outside the app: `~/.synabun` on macO
178
178
 
179
179
  ## Links
180
180
 
181
- - [Changelog](./CHANGELOG.md): what is new in 2.2.0 and before
181
+ - [Changelog](./CHANGELOG.md): what is new in 2.2.1 and before
182
182
  - [Security](./SECURITY.md): security model and how to report a vulnerability
183
183
  - [Contributing](./CONTRIBUTING.md): bug reports and feature requests are welcome as [issues](https://github.com/danilokhury/Synabun/issues); pull requests are not accepted
184
184
  - [Website](https://synabun.ai), [docs](https://synabun.ai/docs) and [blog](https://synabun.ai/blog)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-memory-mcp",
9
- "version": "2.2.0",
9
+ "version": "2.2.1",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@huggingface/transformers": "^3.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "private": true,
5
5
  "description": "Private MCP server runtime bundled with SynaBun for persistent memory and agent tooling",
6
6
  "license": "Apache-2.0",
@@ -10759,6 +10759,9 @@
10759
10759
  "resume": "Resume",
10760
10760
  "setupTitle": "Computer use setup",
10761
10761
  "checking": "Checking computer use…",
10762
+ "checkFailed": "Could not check computer use",
10763
+ "checkFailedForbidden": "This window is not allowed to read computer use.",
10764
+ "checkFailedTimeout": "The check did not answer in time.",
10762
10765
  "unsupported": "Computer use needs macOS",
10763
10766
  "unsupportedBody": "SynaBun controls the desktop through a small native helper that only runs on a Mac.",
10764
10767
  "setupStart": "Set up computer use",
@@ -10705,6 +10705,9 @@
10705
10705
  "resume": "Retomar",
10706
10706
  "setupTitle": "Configuração do uso do computador",
10707
10707
  "checking": "Verificando uso do computador…",
10708
+ "checkFailed": "Não foi possível verificar o uso do computador",
10709
+ "checkFailedForbidden": "Esta janela não tem permissão para ler o uso do computador.",
10710
+ "checkFailedTimeout": "A verificação não respondeu a tempo.",
10708
10711
  "unsupported": "O uso do computador precisa de macOS",
10709
10712
  "unsupportedBody": "O SynaBun controla o desktop por um pequeno auxiliar nativo que só funciona no Mac.",
10710
10713
  "setupStart": "Configurar uso do computador",
@@ -181,8 +181,13 @@ export function createCodexBrain({ session, sink, deps = {}, persona = '' } = {}
181
181
  queryPlanMode = planMode;
182
182
  // "<id>[extended]" = the model at its extended context window (the sidepanel's contextMode).
183
183
  const picked = model || brain.model || null;
184
+ // The Assistant composer uses base64 + mediaType; the Codex skin persists
185
+ // data URLs as native localImage inputs. Translate at this protocol boundary.
186
+ const pictures = (Array.isArray(images) ? images : [])
187
+ .filter((image) => image?.base64)
188
+ .map((image) => ({ dataUrl: `data:${image.mediaType || 'image/png'};base64,${image.base64}` }));
184
189
  sendQuery({
185
- prompt: text, images: Array.isArray(images) && images.length ? images : undefined,
190
+ prompt: text, images: pictures.length ? pictures : undefined,
186
191
  cwd: cwd || brain.cwd || deps.PACKAGE_ROOT || undefined, model: picked ? baseModelId(picked) : null, effort: effort || brain.effort || null,
187
192
  contextMode: contextVariantOf(picked) === 'extended' ? 'extended' : undefined,
188
193
  threadId: threadId || null, autoAccept: approvalMode === 'auto' && !planMode && !remoteReadOnly, planMode,
@@ -1842,8 +1842,11 @@ export function createAssistantDispatcher({
1842
1842
  const acq = await acquireLoopBrowserAndTab({ requestedBrowserSessionId: entry.requestedBrowserSessionId, terminalSessionId: entry.runId, logTag: 'assistant:browser' });
1843
1843
  if (acq?.error) {
1844
1844
  entry.state = 'failed'; entry.completionReason = 'browser_unavailable'; entry.finishedAt = iso(now()); entry.notes.push(acq.error);
1845
+ // The native serve boundary's refusal keeps its own code and the serves it names, on the run and the error.
1846
+ const boundary = acq.code === 'NATIVE_SERVE_UNVERIFIED' ? { browserCode: acq.code, offenders: Array.isArray(acq.offenders) ? acq.offenders : [] } : null;
1847
+ if (boundary) entry.browserError = boundary;
1845
1848
  emit(entry, 'failed');
1846
- throw new DispatchError('PROVIDER_UNAVAILABLE', acq.error, { status: 500 });
1849
+ throw new DispatchError('PROVIDER_UNAVAILABLE', acq.error, { status: 500, ...(boundary || {}) });
1847
1850
  }
1848
1851
  browserSessionId = acq.browserSessionId || null;
1849
1852
  browserTabId = acq.browserTabId || null;
@@ -319,14 +319,38 @@ export async function waitForClaudeMcpScope(q, names, { timeoutMs = 30000 } = {}
319
319
  }
320
320
  throw Object.assign(new Error('Claude MCP scope could not be enforced before the first turn'), { code: 'MCP_SCOPE_UNENFORCED' });
321
321
  }
322
+ /** Values shorter than this are never redacted: a short or common word would be erased from ordinary output. */
323
+ export const MIN_MCP_SECRET_LENGTH = 8;
322
324
  export function redactMcpValues(value, values) {
323
- if (typeof value === 'string') { for (const secret of [...new Set(values)].filter(Boolean).sort((a, b) => b.length - a.length)) value = value.split(secret).join('[MCP value]'); return value; }
325
+ if (typeof value === 'string') { for (const secret of [...new Set(values)].filter(item => typeof item === 'string' && item.length >= MIN_MCP_SECRET_LENGTH).sort((a, b) => b.length - a.length)) value = value.split(secret).join('[MCP value]'); return value; }
324
326
  if (Array.isArray(value)) return value.map(item => redactMcpValues(item, values));
325
327
  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactMcpValues(item, values)]));
326
328
  return value;
327
329
  }
328
330
 
329
- export function mcpDefinitionValues(servers) {
331
+ const SECRET_QUERY_NAME = /token|key|secret|auth|pass|pwd|sig|credential|session|code/i;
332
+ /** The secrets a URL carries (userinfo, credential-named query values), never the URL itself. */
333
+ export function urlSecretValues(url) {
334
+ if (typeof url !== 'string' || !url) return [];
335
+ let parsed; try { parsed = new URL(url); } catch { return []; }
336
+ const out = [];
337
+ for (const part of [parsed.username, parsed.password]) if (part) { out.push(part); try { out.push(decodeURIComponent(part)); } catch {} }
338
+ for (const [name, value] of parsed.searchParams) if (value && SECRET_QUERY_NAME.test(name)) { out.push(value); out.push(encodeURIComponent(value)); }
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * The real secrets of MCP server definitions: env / environment and header values, authToken, oauth strings
344
+ * and the secrets inside a URL. Never command, args, cwd, type or a whole URL, and never SynaBun's own entry
345
+ * (its headers name the project, terminal and role: ordinary words). `${VAR}` / `{env:VAR}` references are
346
+ * expanded through `env` when given. Values under MIN_MCP_SECRET_LENGTH are dropped.
347
+ */
348
+ export function mcpSecretValues(servers, { env = null, exclude = ['SynaBun'] } = {}) {
330
349
  const strings = value => typeof value === 'string' ? [value] : Array.isArray(value) ? value.flatMap(strings) : value && typeof value === 'object' ? Object.values(value).flatMap(strings) : [];
331
- return [...new Set(Object.values(servers || {}).flatMap(entry => ['command', 'args', 'cwd', 'env', 'environment', 'headers', 'url', 'oauth', 'headersHelper', 'authToken'].flatMap(field => strings(entry[field]))))].filter(Boolean);
350
+ const expand = value => env ? value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}|\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, a, b) => env[a || b] ?? whole) : value;
351
+ const values = Object.entries(servers || {}).filter(([name]) => !exclude.includes(name)).flatMap(([, entry]) => entry && typeof entry === 'object' ? [
352
+ ...['env', 'environment', 'headers', 'oauth', 'authToken'].flatMap(field => strings(entry[field])).flatMap(value => { const bare = /^(?:Bearer|Basic|Token|Bot)\s+(\S.*)$/i.exec(value); return bare ? [value, bare[1]] : [value]; }),
353
+ ...urlSecretValues(entry.url),
354
+ ] : []).map(expand);
355
+ return [...new Set(values)].filter(value => value.length >= MIN_MCP_SECRET_LENGTH);
332
356
  }
@@ -1,7 +1,7 @@
1
1
  import { codexWorkerReply, CODEX_QUESTIONS, elicitationReply, openCodeAnswers, workerQuestions } from './worker-question.js';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { externalServerInventory, selectExternalServers, codexMcpEnabledOverride, referenceClaudeMap, waitForClaudeMcpScope, redactMcpValues, writePrivateMcpConfig, mcpDefinitionValues } from './mcp-external-servers.js';
4
+ import { externalServerInventory, selectExternalServers, codexMcpEnabledOverride, referenceClaudeMap, waitForClaudeMcpScope, redactMcpValues, writePrivateMcpConfig, mcpSecretValues } from './mcp-external-servers.js';
5
5
  import { resolveStoredTools, runtimeToolsValue, readDefaultTools } from './tool-lists.js';
6
6
  import { waitForSynabunTools, synabunToolsTimeoutText } from './opencode-mcp-ready.js';
7
7
  import { config as memoryConfig, detectProject as memoryProject } from '../../mcp-server/dist/config.js';
@@ -864,7 +864,8 @@ export async function createClaudeNativeLoopAdapter(options = {}) {
864
864
  if (definition.oauth) privateMcpServers[name].oauth = structuredClone(definition.oauth);
865
865
  if (definition.headersHelper) privateMcpServers[name].headersHelper = definition.headersHelper;
866
866
  }
867
- externalValues = [...Object.values(referenced.env), ...mcpDefinitionValues(definitions)];
867
+ // Only the external servers' secrets (expanded through the CLI's env); SynaBun's own headers are not secrets.
868
+ externalValues = mcpSecretValues(definitions, { env });
868
869
  externalScopeNames = Object.keys(definitions);
869
870
  }
870
871
  if (model) queryOptions.model = model;
@@ -15,39 +15,173 @@ export function nativeServeCapability({ version, commandLine = false, passwordFr
15
15
  return !commandLine && passwordFrom === 'stdout' && NATIVE_SERVE_VERSIONS.includes(bare);
16
16
  }
17
17
 
18
+ // Why a serve is not native-safe, in words that name what to change.
19
+ export function nativeServeReason({ version, commandLine = false, passwordFrom } = {}) {
20
+ if (commandLine) return 'command-line';
21
+ if (passwordFrom !== 'stdout') return 'password';
22
+ return nativeServeCapability({ version, commandLine, passwordFrom }) ? null : 'version';
23
+ }
24
+
25
+ const BASE = `Native browser WebSockets require authenticated OpenCode ${NATIVE_SERVE_VERSIONS.join(' or ')} serves with private stdout credentials; wait for startup, stop unverified serves or use the verified executable path.`;
26
+
27
+ const MAX_COMMAND = 200;
28
+ const short = (text, max = MAX_COMMAND) => {
29
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
30
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
31
+ };
32
+ const pidOf = (o) => (Number.isInteger(o?.pid) && o.pid > 0 ? o.pid : null);
33
+
34
+ // The fields of an offender a caller may see (a route's answer, a dispatch error): what names the serve, never its
35
+ // token or the rest of its record. A command line is shortened.
36
+ export function narrowOffenders(offenders = []) {
37
+ return (Array.isArray(offenders) ? offenders : []).map((o) => {
38
+ const out = { reason: String(o?.reason || 'unknown'), pid: pidOf(o), port: Number(o?.port) > 0 ? Number(o.port) : null };
39
+ if (o?.path) out.path = short(o.path, 400);
40
+ if (o?.command) out.command = short(o.command, 400);
41
+ if (o?.version) out.version = String(o.version).trim();
42
+ if (o?.reason === 'record') { out.kind = o.kind || null; out.claimable = !!o.claimable; }
43
+ return out;
44
+ });
45
+ }
46
+
47
+ function describeOffender(o) {
48
+ const pid = pidOf(o);
49
+ const where = [pid ? `pid ${pid}` : null, o.port ? `port ${o.port}` : null].filter(Boolean).join(', ');
50
+ const what = [o.path || null, o.command ? `command ${short(o.command)}` : null, o.version ? `version ${String(o.version).trim()}` : null].filter(Boolean).join(', ');
51
+ const head = `OpenCode serve ${where || '(unknown process)'}${what ? ` (${what})` : ''}`;
52
+ switch (o.reason) {
53
+ case 'version': return `${head}: this OpenCode version is not one whose authentication SynaBun verified (${NATIVE_SERVE_VERSIONS.join(', ')}); install one of them, or stop this serve (close the OpenCode panel${pid ? ` / end pid ${pid}` : ''})`;
54
+ case 'command-line': return `${head}: started from the OpenCode command line set in Settings (its version cannot be read); clear it so SynaBun starts the verified executable, or stop this serve`;
55
+ case 'password': return `${head}: started without a private stdout password`;
56
+ case 'starting': return `${head}: still starting; try again once OpenCode answers`;
57
+ case 'untracked-port': return `${head}: answers on a port SynaBun did not start in this run; stop the process listening on port ${o.port}`;
58
+ case 'record': {
59
+ const kind = o.kind ? `, kind ${o.kind}` : '';
60
+ if (o.claimable) return `${head}: left running by an earlier SynaBun that has ended${kind}; it could not be stopped automatically: end ${pid ? `pid ${pid}` : 'that serve'}`;
61
+ // Not confirmed: the owner may still run, or the OS could not say whether this pid is still that serve (a pid
62
+ // is reused once its process ends). Never ask the user to end a number that may be another program now.
63
+ return `${head}: on record from another SynaBun process, or one whose identity the OS could not confirm${kind}. It is unconfirmed${pid ? ` whether pid ${pid} is still this serve (the number may belong to another program by now)` : ''}: check ${pid ? `that pid ${pid}'s command line is the one shown (ps -o command= -p ${pid}; on Windows, Task Manager → Details → Command line column)` : 'the OpenCode serves that are running'} before ending it, or close the other SynaBun`;
64
+ }
65
+ default: return `${head}: not verified`;
66
+ }
67
+ }
68
+
69
+ export function nativeServeError(offenders = []) {
70
+ const list = offenders.map(describeOffender);
71
+ const error = new Error(list.length ? `${BASE} Blocking: ${list.join('; ')}.` : BASE);
72
+ error.code = 'NATIVE_SERVE_UNVERIFIED';
73
+ error.offenders = narrowOffenders(offenders);
74
+ return error;
75
+ }
76
+
77
+ const nativeContextClosed = () => Object.assign(new Error('The browser closed while SynaBun was checking its OpenCode serves; nothing was attached.'), { code: 'NATIVE_CONTEXT_CLOSED' });
78
+ const nativeAttachAborted = () => Object.assign(new Error('The browser request ended while SynaBun was checking its OpenCode serves; nothing was attached.'), { code: 'NATIVE_ATTACH_ABORTED' });
79
+ const defaultSleep = (ms) => new Promise((done) => { const t = setTimeout(done, ms); t.unref?.(); });
80
+
18
81
  export function createNativeServeBoundary() {
19
82
  const processes = new Map();
83
+ // Every native context that relies on the boundary, attached or still being checked (a reservation): while any is
84
+ // held, assertLaunch refuses an unverified serve. A context is held from before its check until it closes, its
85
+ // browser disconnects or its attachment fails.
20
86
  const contexts = new Map();
21
- const error = () => new Error(`Native browser WebSockets require authenticated OpenCode ${NATIVE_SERVE_VERSIONS.join(' or ')} serves with private stdout credentials; wait for startup, stop unverified serves or use the verified executable path.`);
87
+ function offenders({ ports = [], unverifiedRecords = [] } = {}) {
88
+ const out = [];
89
+ const verified = new Set();
90
+ for (const [proc, e] of processes) {
91
+ const base = { pid: proc.pid || null, port: e.port, path: e.path || null, version: e.version || null };
92
+ if (!e.safe) out.push({ ...base, reason: e.reason || 'version' });
93
+ else if (!e.ready) out.push({ ...base, reason: 'starting' });
94
+ else verified.add(e.port);
95
+ }
96
+ for (const port of ports) {
97
+ const n = Number(port);
98
+ if (verified.has(n) || out.some(o => o.port === n)) continue;
99
+ out.push({ pid: null, port: n, reason: 'untracked-port' });
100
+ }
101
+ // A record has the serve's command line (its identity), not a path; never its token.
102
+ for (const r of unverifiedRecords) out.push({ pid: r?.pid || null, port: r?.port || null, kind: r?.kind || null, command: r?.command || null, claimable: !!r?.claimable, reason: 'record' });
103
+ return out;
104
+ }
105
+ function reserve(context) {
106
+ const held = contexts.get(context);
107
+ if (held) return held;
108
+ const browser = context.browser?.() || null;
109
+ const release = () => {
110
+ if (contexts.get(context) !== release) return;
111
+ contexts.delete(context);
112
+ context.removeListener('close', release);
113
+ browser?.removeListener('disconnected', release);
114
+ };
115
+ contexts.set(context, release);
116
+ context.once('close', release);
117
+ browser?.once('disconnected', release);
118
+ return release;
119
+ }
120
+ function check(state) {
121
+ const blocking = offenders(state);
122
+ if (blocking.length) throw nativeServeError(blocking);
123
+ }
22
124
  return {
23
- assertLaunch(safe) { if (!safe && contexts.size) throw error(); },
24
- track(proc, { port, safe = false }) {
125
+ offenders,
126
+ reserve,
127
+ holds: (context) => contexts.has(context),
128
+ assertLaunch(safe, info = {}) {
129
+ if (!safe && contexts.size) {
130
+ const e = nativeServeError([{ pid: null, port: info.port || null, path: info.path || null, version: info.version || null, reason: info.reason || 'version' }]);
131
+ e.message = `A native browser is attached, so this unverified OpenCode serve is not started. ${e.message}`;
132
+ throw e;
133
+ }
134
+ },
135
+ track(proc, { port, safe = false, version = null, path = null, reason = null }) {
25
136
  if (!proc || proc.exitCode != null || proc.signalCode != null) return;
26
- processes.set(proc, { port: Number(port), safe, ready: false });
137
+ processes.set(proc, { port: Number(port), safe, ready: false, version, path, reason: safe ? null : (reason || 'version') });
27
138
  const release = () => processes.delete(proc);
28
139
  proc.once('exit', release);
29
140
  proc.once('error', () => { if (!proc.pid) release(); });
30
141
  },
31
142
  ready(proc) { const entry = processes.get(proc); if (entry) entry.ready = true; },
32
- protect(context, { ports, unverifiedRecords = [] }) {
143
+ // Synchronous: check now, then hold the context. A context already held is returned as it is.
144
+ protect(context, state) {
33
145
  if (contexts.has(context)) return contexts.get(context);
34
- const verified = new Set();
35
- for (const { port, safe, ready } of processes.values()) {
36
- if (!safe || !ready) throw error();
37
- verified.add(port);
146
+ check(state);
147
+ return reserve(context);
148
+ },
149
+ // The context is held (a pending reservation assertLaunch honours) before anything is waited for; then, bounded,
150
+ // SynaBun repairs what it can: a sweep of the records an ended SynaBun left (started at once, in the background,
151
+ // waited for at most `sweepMs`) and SynaBun's own verified serves still starting (waited for at most `waitMs`).
152
+ // Any other blocker refuses at once. `deadline` (an absolute time) and `signal` end the wait early. On every
153
+ // failure the reservation is released; nothing is cached, so the next attempt reads the live state again.
154
+ async attach(context, { state, sweep = null, waitMs = 20_000, sweepMs = 10_000, deadline = Infinity, signal = null, pollMs = 250, now = Date.now, sleep = defaultSleep } = {}) {
155
+ const release = reserve(context);
156
+ const held = () => contexts.get(context) === release;
157
+ try {
158
+ const start = now();
159
+ const end = Number.isFinite(deadline) ? deadline : Infinity;
160
+ const waitEnd = Math.min(start + Math.max(0, waitMs), end);
161
+ const sweepEnd = Math.min(start + Math.max(0, sweepMs), end);
162
+ let current = state();
163
+ let sweeping = false;
164
+ if (sweep && current.unverifiedRecords?.length) {
165
+ sweeping = true;
166
+ Promise.resolve().then(sweep).catch(() => {}).finally(() => { sweeping = false; });
167
+ }
168
+ for (;;) {
169
+ if (!held()) throw nativeContextClosed();
170
+ if (signal?.aborted) throw nativeAttachAborted();
171
+ const blocking = offenders(current);
172
+ if (!blocking.length) return release;
173
+ const t = now();
174
+ const waitable = (o) => (o.reason === 'starting' && t < waitEnd)
175
+ || (o.reason === 'record' && o.claimable && sweeping && t < sweepEnd);
176
+ if (!blocking.every(waitable)) throw nativeServeError(blocking);
177
+ const until = Math.max(...blocking.map(o => (o.reason === 'starting' ? waitEnd : sweepEnd)));
178
+ await sleep(Math.max(1, Math.min(pollMs, until - t)));
179
+ current = state();
180
+ }
181
+ } catch (error) {
182
+ release();
183
+ throw error;
38
184
  }
39
- if ([...ports].some(port => !verified.has(Number(port))) || unverifiedRecords.length) throw error();
40
- const browser = context.browser();
41
- const release = () => {
42
- if (contexts.get(context) !== release) return;
43
- contexts.delete(context);
44
- context.removeListener('close', release);
45
- browser.removeListener('disconnected', release);
46
- };
47
- contexts.set(context, release);
48
- context.once('close', release);
49
- browser.once('disconnected', release);
50
- return release;
51
185
  },
52
186
  };
53
187
  }
@@ -439,7 +439,7 @@ export function agentProcesses() {
439
439
  * `agentPids`: processes SynaBun started for an agent (registerAgentProcess), refused like an agent's tree
440
440
  * @returns {{ self:boolean, refuse: null | { why:string, kind:string, pid:number, name:string } }}
441
441
  */
442
- export function classifyPeerChain(chain, { selfPid = process.pid, listening = new Set(), synabunMcp = 0, agentPids = AGENT_PROCESSES } = {}) {
442
+ export function classifyPeerChain(chain, { selfPid = process.pid, listening = new Set(), synabunMcp = 0, agentPids = AGENT_PROCESSES, debugClient = null } = {}) {
443
443
  const list = Array.isArray(chain) ? chain : [];
444
444
  if (!list.length) return { self: false, refuse: null };
445
445
  if (list[0].pid === selfPid) return { self: true, refuse: null };
@@ -447,11 +447,17 @@ export function classifyPeerChain(chain, { selfPid = process.pid, listening = ne
447
447
  const refuse = (p, kind, why) => ({ self: false, refuse: { kind, why, pid: p.pid, name: nameOf(p) } });
448
448
  const peerKinds = processKinds(list[0]);
449
449
  const mcp = Number(synabunMcp) > 0 && list[0].pid === Number(synabunMcp);
450
+ let exempted = false;
450
451
  for (let i = 0; i < list.length; i++) {
451
452
  const p = list[i];
452
453
  if (!p || p.pid === selfPid || p.pid <= 1) break; // this server's own ancestry, and launchd, are not the peer's
453
454
  const k = i === 0 ? peerKinds : processKinds(p);
454
- if (k.automation) return refuse(p, 'automation-browser', `a browser started for automation (${k.automation})`);
455
+ // Local profiling (SYNABUN_ALLOW_DEBUG_CLIENT, debugClientPolicy): only the remote-debugging flag is forgiven, and
456
+ // only on the port the policy names; every other rule below still applies to this process and its parents. The
457
+ // process holds its debug port open itself, so the listener rule is not asked of that one process.
458
+ const debugOk = !!k.automation && debugClientExempt(p, k, debugClient);
459
+ if (debugOk) exempted = true;
460
+ if (k.automation && !debugOk) return refuse(p, 'automation-browser', `a browser started for automation (${k.automation})`);
455
461
  if (k.agent) return refuse(p, 'agent', `an AI agent's process tree (${k.agent})`);
456
462
  const startedFor = agentPids?.get?.(p.pid);
457
463
  if (startedFor) return refuse(p, 'agent', `an AI agent's process tree (${startedFor}, started by SynaBun)`);
@@ -464,12 +470,47 @@ export function classifyPeerChain(chain, { selfPid = process.pid, listening = ne
464
470
  // A program that serves TCP itself and calls SynaBun is relaying for whoever reached it (a dev server's proxy,
465
471
  // a small SOCKS / HTTP proxy). The owner's browsers are left out of this one rule (their network process, or a
466
472
  // browser that holds a socket for its own reasons): their automation flags are what is asked of them.
467
- if (i <= 1 && !peerKinds.browser && listening.has(p.pid)) return refuse(p, 'listener', 'a program that also listens for connections (a relay or proxy)');
473
+ if (i <= 1 && !peerKinds.browser && !debugOk && listening.has(p.pid)) return refuse(p, 'listener', 'a program that also listens for connections (a relay or proxy)');
468
474
  }
475
+ if (exempted && !mcp) return { self: false, refuse: null, debugExempt: true };
469
476
  if (mcp) return refuse(list[0], 'synabun-mcp', 'SynaBun\'s own MCP server over HTTP (it calls for whichever MCP client reached it)');
470
477
  return { self: false, refuse: null };
471
478
  }
472
479
 
480
+ // ── Local profiling exemption (off unless the server's own environment asks) ─
481
+ //
482
+ // SYNABUN_ALLOW_DEBUG_CLIENT=1 (any debug port) or =<port> (only --remote-debugging-port=<port>) lets a client process
483
+ // started with --remote-debugging-port hold the owner's authority, so SynaBun's own window can be profiled over CDP.
484
+ // Read from the environment the server was started with only (server.js reads it before DATA_HOME/.env is loaded:
485
+ // never from a file a client can write), loopback peers only (127.0.0.0/8, ::1), and only that flag: a
486
+ // --remote-debugging-pipe, MoreLogin, Playwright WebKit / Firefox automation, agents, relays and every other rule
487
+ // still refuse.
488
+
489
+ /** The policy from an environment object: { enabled, port } (port null = any). Anything but 1/on/true/yes or a port is off. */
490
+ export function debugClientPolicy(env = process.env) {
491
+ const raw = String(env?.SYNABUN_ALLOW_DEBUG_CLIENT ?? '').trim().toLowerCase();
492
+ if (/^(?:1|on|true|yes)$/.test(raw)) return { enabled: true, port: null };
493
+ if (/^[0-9]{4,5}$/.test(raw) && Number(raw) >= 1024 && Number(raw) <= 65535) return { enabled: true, port: Number(raw) };
494
+ return { enabled: false, port: null };
495
+ }
496
+
497
+ /** Whether this process's automation flag is the one the policy forgives. */
498
+ export function debugClientExempt(proc, kinds, policy) {
499
+ if (!policy?.enabled || kinds?.automation !== 'remote debugging') return false;
500
+ const la = lower(proc?.args);
501
+ if (/(?:^|\s)--remote-debugging-pipe(?:[=\s]|$)/.test(la)) return false;
502
+ // The other automation rules are asked as if the flag were not there: MoreLogin, Playwright, Firefox automation.
503
+ if (MORELOGIN_PATH_RE.test(String(proc?.comm ?? '')) || lower(proc?.ucomm).trim().startsWith('morelogin')) return false;
504
+ if (/(?:^|\s)--inspector-pipe(?:\s|$)/.test(la) || /\/pw_run\.sh(?:\s|$)/.test(la) || /(?:^|\s)-juggler(?:-pipe)?(?:\s|$)/.test(la) || /(?:^|\s)--?marionette(?:\s|$)/.test(la)) return false;
505
+ if (/(?:^|\s)--(?:enable-automation|headless)(?:[=\s]|$)/.test(la)) return false;
506
+ const ports = [...la.matchAll(/(?:^|\s)--remote-debugging-port(?:=|\s+)([0-9]+)(?=\s|$)/g)].map((m) => Number(m[1]));
507
+ if (!ports.length) return false;
508
+ return policy.port == null || ports.every((n) => n === policy.port);
509
+ }
510
+
511
+ /** Whether a normalized address is loopback. */
512
+ export const isLoopbackAddress = (addr) => /^127\.\d+\.\d+\.\d+$/.test(String(addr || '')) || String(addr || '') === '::1';
513
+
473
514
  /** The environment part of `ps -E` output for one process, given the same process's line without -E. */
474
515
  export function envPart(withEnv, plain) {
475
516
  const w = String(withEnv ?? '');
@@ -526,8 +567,10 @@ function thisMachineAddresses() {
526
567
  * is told apart from other listening programs (isSynabunMcpServer). None: it is refused as a listener, as any relay.
527
568
  * @param {(path:string) => string} [o.realpath] fs.realpathSync (tests inject one).
528
569
  */
529
- export function createPeerIdentity({ platform = process.platform, selfPid = process.pid, run = defaultRun, localAddresses = null, log = () => {}, now = Date.now, disabled = false, synabunRoots = [], realpath = realpathSync } = {}) {
570
+ export function createPeerIdentity({ platform = process.platform, selfPid = process.pid, run = defaultRun, localAddresses = null, log = () => {}, now = Date.now, disabled = false, synabunRoots = [], realpath = realpathSync, debugClient = null } = {}) {
530
571
  const supported = platform === 'darwin' && !disabled;
572
+ const debugPolicy = debugClient?.enabled ? { enabled: true, port: Number.isInteger(debugClient.port) ? debugClient.port : null } : null;
573
+ if (debugPolicy) { try { log(`[peer-identity] WARNING: SYNABUN_ALLOW_DEBUG_CLIENT is set: a loopback client started with --remote-debugging-port${debugPolicy.port ? `=${debugPolicy.port}` : ''} is taken as the owner (local profiling only; unset it when done)`); } catch { /* logger */ } }
531
574
  if (disabled) { try { log('[peer-identity] off (SYNABUN_PEER_IDENTITY=off): the page secret\'s rules from before apply, with no peer lookup'); } catch { /* logger */ } }
532
575
  const bySocket = new WeakMap();
533
576
  const chains = new Map(); // pid → { at, ucomm, chain }
@@ -679,7 +722,8 @@ export function createPeerIdentity({ platform = process.platform, selfPid = proc
679
722
  if (!chain || !chain.length) return { status: 'gone', reason: 'the client process exited before it was identified', pid: row.pid, ms: now() - started };
680
723
  let synabunMcp = 0;
681
724
  try { synabunMcp = await synabunMcpPid(chain[0], table.listening); } catch { synabunMcp = 0; }
682
- const verdict = classifyPeerChain(chain, { selfPid, listening: table.listening, synabunMcp });
725
+ const verdict = classifyPeerChain(chain, { selfPid, listening: table.listening, synabunMcp, debugClient: isLoopbackAddress(remote) ? debugPolicy : null });
726
+ if (verdict.debugExempt) note(`debug:${row.pid}`, `[peer-identity] WARNING: accepted a debug client as the owner (pid ${row.pid}, ${row.name || chain[0].ucomm}) under SYNABUN_ALLOW_DEBUG_CLIENT`);
683
727
  return { status: 'ok', self: verdict.self, pid: row.pid, name: row.name || chain[0].ucomm, chain, refuse: verdict.refuse, ms: now() - started };
684
728
  }
685
729
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "neural-memory-interface",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "neural-memory-interface",
9
- "version": "2.2.0",
9
+ "version": "2.2.1",
10
10
  "hasInstallScript": true,
11
11
  "license": "Apache-2.0",
12
12
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neural-memory-interface",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "private": true,
5
5
  "description": "Private local SynaBun interface for memory visualization and multi-agent workflows",
6
6
  "license": "Apache-2.0",
@@ -98,8 +98,20 @@ export function escalateAssistantRun(runId, target) {
98
98
 
99
99
  // ── Desktop (computer use) ───────────────────
100
100
 
101
- export function getDesktopStatus() {
102
- return jsonFetch('/api/desktop/status');
101
+ /** How long the status check may take before it counts as failed (the tray must never spin forever). */
102
+ export const DESKTOP_STATUS_TIMEOUT_MS = 10_000;
103
+
104
+ export async function getDesktopStatus({ timeoutMs = DESKTOP_STATUS_TIMEOUT_MS } = {}) {
105
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
106
+ const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs) : null;
107
+ try {
108
+ return await jsonFetch('/api/desktop/status', ctrl ? { signal: ctrl.signal } : {});
109
+ } catch (err) {
110
+ if (ctrl?.signal.aborted) throw Object.assign(new Error('The status check timed out'), { code: 'TIMEOUT', timedOut: true });
111
+ throw err;
112
+ } finally {
113
+ if (timer) clearTimeout(timer);
114
+ }
103
115
  }
104
116
 
105
117
  /** step: 'start'|'recheck'|'install_toolchain'|'request_screen'|'request_accessibility'|'open_settings' (+ { pane }). */
@@ -290,6 +290,14 @@ export function requestDesktopStop(body = { scope: 'all' }) {
290
290
  const ICON_COMPUTER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>';
291
291
  const ICON_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
292
292
  const POLL_MS = 3000;
293
+
294
+ /** What a failed status check was: { kind: 'forbidden' | 'timeout' | 'error', message }. */
295
+ export function computerCheckFailure(err) {
296
+ const message = str(err?.message) || 'The status check failed';
297
+ if (err?.status === 401 || err?.status === 403 || err?.forbidden) return { kind: 'forbidden', message };
298
+ if (err?.timedOut || err?.code === 'TIMEOUT' || err?.name === 'AbortError') return { kind: 'timeout', message };
299
+ return { kind: 'error', message };
300
+ }
293
301
  const THUMB_MIN_INTERVAL_MS = 500; // ≤ 2 live-thumbnail updates per second
294
302
 
295
303
  function el(tag, className, text) {
@@ -411,6 +419,7 @@ export function createComputerPanel(hostEl, hooks = {}) {
411
419
  thumbTimer: null,
412
420
  thumbPending: null,
413
421
  view: '',
422
+ checkFailed: null,
414
423
  };
415
424
 
416
425
  function isEnabled() { try { return !!hooks.isEnabled?.(); } catch { return false; } }
@@ -575,7 +584,17 @@ export function createComputerPanel(hostEl, hooks = {}) {
575
584
  const note = (text) => body.appendChild(el('div', 'asst-computer-tray-note', text));
576
585
  const spinner = () => head.insertBefore(el('span', 'asst-spinner'), head.firstChild);
577
586
 
578
- switch (info.state) {
587
+ const viewState = info.state === 'loading' && state.checkFailed ? 'check_failed' : info.state;
588
+ switch (viewState) {
589
+ case 'check_failed':
590
+ title.textContent = t('assistant.computer.checkFailed', 'Could not check computer use');
591
+ note(state.checkFailed.kind === 'forbidden'
592
+ ? t('assistant.computer.checkFailedForbidden', 'This window is not allowed to read computer use.')
593
+ : state.checkFailed.kind === 'timeout'
594
+ ? t('assistant.computer.checkFailedTimeout', 'The check did not answer in time.')
595
+ : state.checkFailed.message);
596
+ button(t('assistant.computer.checkAgain', 'Check again'), 'secondary', () => { state.checkFailed = null; render(); refresh().catch(() => {}); }, 'recheck');
597
+ break;
579
598
  case 'loading':
580
599
  spinner();
581
600
  title.textContent = t('assistant.computer.checking', 'Checking computer use…');
@@ -670,6 +689,7 @@ export function createComputerPanel(hostEl, hooks = {}) {
670
689
 
671
690
  function applyStatus(status) {
672
691
  if (!status || typeof status !== 'object') return;
692
+ state.checkFailed = null;
673
693
  state.status = status;
674
694
  state.info = computerSetupState(status);
675
695
  if (status.control && typeof status.control === 'object') {
@@ -688,7 +708,9 @@ export function createComputerPanel(hostEl, hooks = {}) {
688
708
  if (!state.destroyed) applyStatus(status);
689
709
  return status;
690
710
  } catch (err) {
691
- if (!state.destroyed) { state.error = ''; render(); }
711
+ // A check that failed is a settled state (Check again), never "Checking…" forever. A status already known
712
+ // stays on screen; only a tray that never got one shows the failure.
713
+ if (!state.destroyed) { state.error = ''; state.checkFailed = computerCheckFailure(err); render(); }
692
714
  throw err;
693
715
  }
694
716
  }
@@ -735,6 +757,8 @@ export function createComputerPanel(hostEl, hooks = {}) {
735
757
  stop,
736
758
  isActive: desktopActive,
737
759
  status: () => state.status,
760
+ /** A status check made elsewhere failed: settle the tray instead of leaving it on "Checking…". */
761
+ checkFailed(err) { if (state.destroyed || state.status) return; state.checkFailed = computerCheckFailure(err); render(); },
738
762
  info: () => state.info,
739
763
  destroy() {
740
764
  state.destroyed = true;