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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/neural-interface/i18n/en.json +3 -0
- package/neural-interface/i18n/pt-BR.json +3 -0
- package/neural-interface/lib/assistant-brains/codex.js +6 -1
- package/neural-interface/lib/assistant-dispatch.js +4 -1
- package/neural-interface/lib/mcp-external-servers.js +27 -3
- package/neural-interface/lib/native-loop-providers.js +3 -2
- package/neural-interface/lib/opencode-native-boundary.js +155 -21
- package/neural-interface/lib/peer-identity.js +49 -5
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/shared/assistant/asst-api.js +14 -2
- package/neural-interface/public/shared/assistant/asst-computer.js +26 -2
- package/neural-interface/public/shared/assistant/asst-motion-gate.js +80 -0
- package/neural-interface/public/shared/assistant/asst-panel.js +1 -1
- package/neural-interface/public/shared/assistant/asst-render.js +62 -7
- package/neural-interface/public/shared/assistant/asst-scene-styles.js +55 -23
- package/neural-interface/public/shared/assistant/asst-scenes.js +225 -79
- package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +4 -4
- package/neural-interface/public/shared/assistant/asst-style-cache.js +44 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +24 -4
- package/neural-interface/public/shared/cp/cp-permissions.js +12 -0
- package/neural-interface/public/shared/loading-teardown.js +34 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +6 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +1 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +10 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +6 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +88 -72
- package/neural-interface/public/shared/storage-retry.js +47 -0
- package/neural-interface/public/shared/storage.js +25 -4
- package/neural-interface/public/shared/styles.css +8 -1
- package/neural-interface/public/shared/synabun-mascot.js +27 -8
- package/neural-interface/public/shared/synabun-ticker.js +147 -12
- package/neural-interface/public/shared/ui-claude-panel.js +79 -61
- package/neural-interface/public/shared/ui-loading.js +5 -4
- package/neural-interface/server.js +87 -40
- package/npm-shrinkwrap.json +2 -2
- 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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.2.1",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@huggingface/transformers": "^3.0.0",
|
package/mcp-server/package.json
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.2.1",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"dependencies": {
|
|
@@ -98,8 +98,20 @@ export function escalateAssistantRun(runId, target) {
|
|
|
98
98
|
|
|
99
99
|
// ── Desktop (computer use) ───────────────────
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|