neoctl-web 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -26,13 +26,13 @@ npm run dev
26
26
 
27
27
  局域网设备可通过 `http://<本机局域网 IP>:5173` 访问。Windows 防火墙需要允许本地子网访问 TCP 5173;不要将该端口直接映射到公网,因为应用内 Agent 具备文件读写和命令执行能力。若只允许本机访问,可设置 `VITE_HOST=127.0.0.1`。
28
28
 
29
- 每个新建对话会在用户数据目录的 `workspaces/YYMMDDHHMMSS` 下创建独立工作目录,不会向启动 `neow` 的当前目录写入数据。默认用户数据目录遵循各平台约定:
30
-
31
- - Windows:`%LOCALAPPDATA%\neoctl-web`
32
- - macOS:`~/Library/Application Support/neoctl-web`
33
- - Linux:`${XDG_DATA_HOME:-~/.local/share}/neoctl-web`
34
-
35
- 可通过 `NEO_WEB_DATA_DIR` 覆盖整个数据目录,或通过 `NEO_WORKSPACE_ROOT` 单独覆盖 workspace 根目录;会话恢复时会自动回到该会话原有的工作目录。
29
+ 每个新建对话会在用户数据目录的 `workspaces/YYMMDDHHMMSS` 下创建独立工作目录,不会向启动 `neow` 的当前目录写入数据。默认用户数据目录遵循各平台约定:
30
+
31
+ - Windows:`%LOCALAPPDATA%\neoctl-web`
32
+ - macOS:`~/Library/Application Support/neoctl-web`
33
+ - Linux:`${XDG_DATA_HOME:-~/.local/share}/neoctl-web`
34
+
35
+ 可通过 `NEO_WEB_DATA_DIR` 覆盖整个数据目录,或通过 `NEO_WORKSPACE_ROOT` 单独覆盖 workspace 根目录;会话恢复时会自动回到该会话原有的工作目录。
36
36
 
37
37
  同一个 `sessionId` 只保留一个运行时。多个浏览器标签页或用户打开同一会话时会共享实时输出和输入队列,不会各自启动一份并发 Agent。会话运行时在无人连接且没有前台或后台任务后自动回收。
38
38
 
@@ -49,7 +49,7 @@ NEO_RUNTIME_MAX_SESSIONS=64
49
49
  NEO_SESSION_MAX_SUBSCRIBERS=32
50
50
  ```
51
51
 
52
- 右侧栏会显示 Neo 服务进程的内存使用趋势。默认每分钟采样一次,接口单次最多返回最近 60 个点;落盘数据默认最多保留 1440 个点且不超过 256 KiB,并原子写入用户数据目录的 `memory-monitor.json`。可通过 `NEO_MEMORY_MAX_PERSISTED_SAMPLES` 和 `NEO_MEMORY_MAX_PERSISTED_BYTES` 进一步收紧限制;该功能仅观测和展示,不会自动回收会话或重启进程。
52
+ 右侧栏会显示 Neo 服务进程的内存使用趋势。默认每分钟采样一次,接口单次最多返回最近 60 个点;落盘数据默认最多保留 1440 个点且不超过 256 KiB,并原子写入用户数据目录的 `memory-monitor.json`。可通过 `NEO_MEMORY_MAX_PERSISTED_SAMPLES` 和 `NEO_MEMORY_MAX_PERSISTED_BYTES` 进一步收紧限制;该功能仅观测和展示,不会自动回收会话或重启进程。
53
53
 
54
54
  ```env
55
55
  # 可选:内存采样间隔与落盘保留窗口(毫秒)
@@ -57,7 +57,7 @@ NEO_MEMORY_SAMPLE_MS=60000
57
57
  NEO_MEMORY_RETENTION_MS=86400000
58
58
  ```
59
59
 
60
- 模型配置页可填写 CPA 管理地址和密码;配置成功后,右侧栏显示 Codex 凭据的周额度。配置默认保存在用户数据目录的 `cpa-config.json`,也可通过 `NEO_CPA_CONFIG_FILE` 指定路径。
60
+ 模型配置页可填写 CPA 管理地址和密码;配置成功后,右侧栏显示 Codex 凭据的周额度。配置默认保存在用户数据目录的 `cpa-config.json`,也可通过 `NEO_CPA_CONFIG_FILE` 指定路径。
61
61
 
62
62
  Vite 会把以下路径代理到 Neo 运行时,确保本应用使用与 `neo web` 相同的后端能力:
63
63
 
@@ -178,7 +178,7 @@ plugins/example/
178
178
 
179
179
  入口需导出 `createPlugin(context)` 或默认工厂函数,并返回 `{ tools, promptSections, route }` 中的一项或多项。`NEO_WEB_PLUGIN_DIR` 可指定其他插件根目录,`NEO_WEB_PLUGIN_DATA_DIR` 可指定传给插件的通用数据目录;插件专属配置由插件自行从 `context.env` 读取。
180
180
 
181
- - 全局开关位于“模型配置”,保存到用户数据目录的 `plugins.json`,重启后生效。
181
+ - 全局开关位于“模型配置”,保存到用户数据目录的 `plugins.json`,重启后生效。
182
182
  - 会话开关位于“运行上下文 → 插件”,支持跟随全局、启用和关闭,从下一轮请求生效并随会话持久化。
183
183
  - `NEO_WEB_PLUGINS` 可作为部署级强制白名单;设置后全局界面只读。
184
184
 
package/bin/neow.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import net from 'node:net';
4
- import { spawn } from 'node:child_process';
5
- import { realpathSync } from 'node:fs';
6
- import { readFile } from 'node:fs/promises';
7
- import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
3
+ import net from 'node:net';
4
+ import { spawn } from 'node:child_process';
5
+ import { realpathSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
11
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
@@ -118,7 +118,7 @@ function normalizeBrowserHost(host) {
118
118
  return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
119
119
  }
120
120
 
121
- function openBrowser(url) {
121
+ function openBrowser(url) {
122
122
  const command = process.platform === 'win32'
123
123
  ? ['explorer.exe', [url]]
124
124
  : process.platform === 'darwin'
@@ -126,27 +126,27 @@ function openBrowser(url) {
126
126
  : ['xdg-open', [url]];
127
127
  const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore', windowsHide: true });
128
128
  child.once('error', () => {});
129
- child.unref();
130
- }
131
-
132
- export function isMainModule(invokedPath, moduleUrl) {
133
- if (!invokedPath) return false;
134
- return comparablePath(invokedPath) === comparablePath(fileURLToPath(moduleUrl));
135
- }
136
-
137
- function comparablePath(value) {
138
- const absolutePath = path.resolve(value);
139
- let resolvedPath = absolutePath;
140
- try {
141
- resolvedPath = realpathSync.native(absolutePath);
142
- } catch {
143
- // Keep the absolute path so invalid invocations still fail in main().
144
- }
145
- return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
146
- }
147
-
148
- if (isMainModule(process.argv[1], import.meta.url)) {
149
- main().catch((error) => {
129
+ child.unref();
130
+ }
131
+
132
+ export function isMainModule(invokedPath, moduleUrl) {
133
+ if (!invokedPath) return false;
134
+ return comparablePath(invokedPath) === comparablePath(fileURLToPath(moduleUrl));
135
+ }
136
+
137
+ function comparablePath(value) {
138
+ const absolutePath = path.resolve(value);
139
+ let resolvedPath = absolutePath;
140
+ try {
141
+ resolvedPath = realpathSync.native(absolutePath);
142
+ } catch {
143
+ // Keep the absolute path so invalid invocations still fail in main().
144
+ }
145
+ return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
146
+ }
147
+
148
+ if (isMainModule(process.argv[1], import.meta.url)) {
149
+ main().catch((error) => {
150
150
  console.error(`neow 启动失败:${error instanceof Error ? error.message : String(error)}`);
151
151
  process.exitCode = 1;
152
152
  });
@@ -0,0 +1,44 @@
1
+ // Browser + Node >=20 native Web Crypto; no Node-only imports.
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder('utf-8', { fatal: true });
4
+ const directions = new Set(['up', 'down']);
5
+ export function decodeBase64(value) {
6
+ if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid base64');
7
+ const binary = atob(value);
8
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
9
+ if (encodeBase64(bytes) !== value) throw new Error('Invalid base64');
10
+ return bytes;
11
+ }
12
+ export function encodeBase64(bytes) {
13
+ let binary = '';
14
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
15
+ return btoa(binary);
16
+ }
17
+ function aad(deviceId, direction) {
18
+ if (typeof deviceId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId) || !directions.has(direction)) throw new Error('Invalid context');
19
+ return encoder.encode(JSON.stringify([deviceId, direction]));
20
+ }
21
+ async function importKey(keyBase64, usage) {
22
+ const bytes = decodeBase64(keyBase64);
23
+ if (bytes.byteLength !== 32) throw new Error('Key must be 32 bytes');
24
+ return crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, [usage]);
25
+ }
26
+ export async function seal(keyBase64, deviceId, direction, payload) {
27
+ const additionalData = aad(deviceId, direction);
28
+ const key = await importKey(keyBase64, 'encrypt');
29
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
30
+ const plaintext = JSON.stringify(payload);
31
+ if (plaintext === undefined) throw new Error('Payload must be JSON');
32
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, encoder.encode(plaintext));
33
+ return { v: 1, nonce: encodeBase64(nonce), ciphertext: encodeBase64(new Uint8Array(encrypted)) };
34
+ }
35
+ export async function open(keyBase64, deviceId, direction, envelope) {
36
+ const additionalData = aad(deviceId, direction);
37
+ if (!envelope || envelope.v !== 1) throw new Error('Invalid envelope');
38
+ const nonce = decodeBase64(envelope.nonce);
39
+ const ciphertext = decodeBase64(envelope.ciphertext);
40
+ if (nonce.length !== 12 || ciphertext.length < 16) throw new Error('Invalid envelope');
41
+ const key = await importKey(keyBase64, 'decrypt');
42
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, ciphertext);
43
+ return JSON.parse(decoder.decode(plaintext));
44
+ }
@@ -0,0 +1,326 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { seal, open } from './control-protocol.mjs';
7
+
8
+ const FILE = 'transcript.jsonl';
9
+ const MAX_PACKET = 256 * 1024;
10
+ const CHUNK = 32 * 1024;
11
+ const RAW_BUDGET = 128 * 1024;
12
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
13
+ const validId = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,160}$/.test(value);
14
+
15
+ // These are the actual envKey -> key definitions in engine/src/web/index.ts.
16
+ export const MODEL_FIELDS = Object.freeze({
17
+ OPENAI_API_KEY: 'apiKey', OPENAI_BASE_URL: 'baseUrl', OPENAI_MODEL: 'model',
18
+ OPENAI_ENDPOINT: 'endpoint', MODEL_REASONING_EFFORT: 'reasoningEffort',
19
+ MODEL_REASONING_SUMMARY: 'reasoningSummary', MODEL_MAX_OUTPUT_TOKENS: 'maxOutputTokens',
20
+ MODEL_TIMEOUT_MS: 'timeoutMs', MODEL_STREAM_IDLE_TIMEOUT_MS: 'streamIdleTimeoutMs',
21
+ MODEL_MAX_RETRIES: 'maxRetries',
22
+ });
23
+
24
+ export function loginProfile(profile) {
25
+ if (!profile || profile.provider !== 'openai' || !profile.values || Array.isArray(profile.values)) throw new Error('PROFILE_INVALID');
26
+ const values = {};
27
+ for (const [key, value] of Object.entries(profile.values)) {
28
+ if (!Object.values(MODEL_FIELDS).includes(key) || typeof value !== 'string' || value.length > 8192 || /[\r\n\0]/.test(value)) throw new Error('PROFILE_INVALID');
29
+ values[key] = value;
30
+ }
31
+ if (!values.apiKey?.trim() || !values.model?.trim()) throw new Error('PROFILE_INVALID');
32
+ return { provider: 'openai', values };
33
+ }
34
+
35
+ // Reuse the existing login HTTP endpoint for shared env persistence/default runtime,
36
+ // then the same Engine saveLogin method for every already-created runtime. No env writer.
37
+ export function createLoginApplier({ runtimeUrl, getActiveRepls = () => [], fetchImpl = fetch, timeoutMs = 10_000 }) {
38
+ const endpoint = new URL('/api/login', runtimeUrl);
39
+ return async (profile, { signal } = {}) => {
40
+ const mapped = loginProfile(profile);
41
+ const combinedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
42
+ // Archives are complete forms, not per-device patches. Clear omitted optional
43
+ // fields so a broadcast cannot inherit different old settings on each device.
44
+ const values = {};
45
+ for (const field of Object.values(MODEL_FIELDS)) values[field] = mapped.values[field] ?? '';
46
+ const response = await fetchImpl(endpoint, {
47
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ provider: 'openai', values }), signal: combinedSignal, redirect: 'error',
49
+ });
50
+ if (!response.ok || (await response.json()).ok !== true) throw new Error('LOGIN_FAILED');
51
+ const applied = new Set();
52
+ // Include runtime creations which completed while another login was applying.
53
+ for (;;) {
54
+ const pending = [...getActiveRepls()].filter((repl) => !applied.has(repl));
55
+ if (!pending.length) break;
56
+ for (const repl of pending) {
57
+ combinedSignal.throwIfAborted();
58
+ if ((await repl.saveLogin('openai', values))?.ok !== true) throw new Error('LOGIN_FAILED');
59
+ applied.add(repl);
60
+ }
61
+ }
62
+ };
63
+ }
64
+
65
+ export function validateControlConfig(value) {
66
+ if (!value || value.enabled !== true || typeof value.key !== 'string') return null;
67
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(value.key) || Buffer.from(value.key, 'base64').length !== 32 || Buffer.from(value.key, 'base64').toString('base64') !== value.key) return null;
68
+ try {
69
+ const url = new URL(value.url);
70
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
71
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (loopback || value.allowHttp === true))) return null;
72
+ if (url.username || url.password || url.search || url.hash) return null;
73
+ return { url: url.href.replace(/\/$/, ''), allowHttp: value.allowHttp === true, key: value.key, enabled: true };
74
+ } catch { return null; }
75
+ }
76
+
77
+ async function atomicJson(file, value) {
78
+ await fs.mkdir(path.dirname(file), { recursive: true });
79
+ const temporary = `${file}.${randomUUID()}.tmp`;
80
+ let handle;
81
+ try {
82
+ handle = await fs.open(temporary, 'wx', 0o600);
83
+ await handle.writeFile(JSON.stringify(value));
84
+ await handle.sync();
85
+ await handle.close();
86
+ handle = undefined;
87
+ await fs.rename(temporary, file);
88
+ } finally {
89
+ await handle?.close().catch(() => {});
90
+ await fs.rm(temporary, { force: true }).catch(() => {});
91
+ }
92
+ }
93
+
94
+ function runLocal(file, args) {
95
+ return new Promise((resolve) => {
96
+ execFile(file, args, { windowsHide: true, timeout: 2500, maxBuffer: 16 * 1024, encoding: 'utf8' }, (error, stdout) => resolve(error ? '' : stdout.trim()));
97
+ });
98
+ }
99
+
100
+ export async function deviceIdentity(directory, deviceId) {
101
+ const machineCode = hash(`neo-control-machine-v1:${deviceId}`);
102
+ let model = '';
103
+ if (process.platform === 'win32') model = await runLocal('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).Model']);
104
+ return { machineCode, hostname: os.hostname().slice(0, 160), model: (model || `${os.type()} ${os.arch()}`).slice(0, 160), platform: process.platform };
105
+ }
106
+
107
+ async function bytesAt(handle, offset, length) {
108
+ const buffer = Buffer.alloc(length);
109
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
110
+ return buffer.subarray(0, bytesRead);
111
+ }
112
+ async function anchorAt(handle, offset) {
113
+ return hash(await bytesAt(handle, Math.max(0, offset - 64), Math.min(64, offset)));
114
+ }
115
+
116
+ // Engine SessionStore.resolveSessionRoot: AGENT_SESSION_DIR or getNeoctlHome()/sessions.
117
+ export function sessionStoreRoot(env = process.env) {
118
+ return env.AGENT_SESSION_DIR ? path.resolve(env.AGENT_SESSION_DIR) : path.join(os.homedir(), '.neoctl', 'sessions');
119
+ }
120
+
121
+ export function createControlSync(options = {}) {
122
+ // Only the launcher's in-memory opt-in is trusted. Never discover/read pairing
123
+ // files or environment variables. Snapshot prevents target/key swaps at runtime.
124
+ const config = validateControlConfig(options.config);
125
+ const directory = typeof options.dataDir === 'string' && options.dataDir.trim() ? path.resolve(options.dataDir) : '';
126
+ const enabled = Boolean(config && directory);
127
+ const sessionsRoot = options.sessionsRoot || sessionStoreRoot();
128
+ const registryFile = options.registryFile || path.join(directory, 'session-workspaces.json');
129
+ const stateFile = options.stateFile || path.join(directory, 'control-sync-state.json');
130
+ const diagnosticFile = path.join(directory, 'control-sync-diagnostic.json');
131
+ const fetchImpl = options.fetchImpl || fetch;
132
+ const pollMs = Math.min(30_000, Math.max(1, Number(options.pollMs) || 1000));
133
+ const timeoutMs = Math.min(30_000, Math.max(1, Number(options.timeoutMs) || 8000));
134
+ let state, pairing, identity, inFlight, controller, timer;
135
+ let enrolled = false;
136
+ let stopped = false, started = false, failures = 0, rotation = 0, lastDiagnostic = '';
137
+
138
+ async function diagnose(code) {
139
+ // No exception text, URLs, keys, env values, session content, or identifying paths.
140
+ if (lastDiagnostic === code || !enabled) return;
141
+ lastDiagnostic = code;
142
+ await atomicJson(diagnosticFile, { code, at: new Date().toISOString() }).catch(() => {});
143
+ }
144
+ function stillEnabled() { return enabled && !stopped; }
145
+ async function initialize() {
146
+ const idFile = path.join(directory, 'control-device.json');
147
+ let savedDevice;
148
+ try { savedDevice = JSON.parse(await fs.readFile(idFile, 'utf8')); }
149
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
150
+ let deviceId = savedDevice?.deviceId;
151
+ if (deviceId !== undefined && (typeof deviceId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(deviceId))) throw new Error('DEVICE_ID_INVALID');
152
+ if (!deviceId) {
153
+ deviceId = randomUUID();
154
+ await atomicJson(idFile, { deviceId });
155
+ }
156
+ const value = { ...config, deviceId };
157
+ const fingerprint = hash(JSON.stringify(value));
158
+ try {
159
+ const saved = JSON.parse(await fs.readFile(stateFile, 'utf8'));
160
+ if (saved.version === 1 && saved.pairing === fingerprint && saved.cursors && typeof saved.cursors === 'object' && !Array.isArray(saved.cursors)) state = saved;
161
+ } catch (error) {
162
+ if (error.code !== 'ENOENT') await diagnose('STATE_UNREADABLE');
163
+ }
164
+ state ||= { version: 1, pairing: fingerprint, cursors: {}, ackCommandId: null };
165
+ identity = options.device || await (options.identityProvider || deviceIdentity)(directory, deviceId);
166
+ pairing = value;
167
+ }
168
+ async function conflict(sessionId) {
169
+ state.cursors[sessionId] = { ...state.cursors[sessionId], blocked: true };
170
+ await atomicJson(stateFile, state);
171
+ await diagnose('TRANSCRIPT_CONFLICT');
172
+ }
173
+ async function collect() {
174
+ let entries;
175
+ try { entries = await fs.readdir(sessionsRoot, { withFileTypes: true }); }
176
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
177
+ // Fail closed: shared Engine storage may contain unrelated CLI history.
178
+ let registry;
179
+ try { registry = JSON.parse(await fs.readFile(registryFile, 'utf8')); } catch { return []; }
180
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry)) return [];
181
+ const ids = entries.filter((entry) => entry.isDirectory() && validId(entry.name) && Object.hasOwn(registry, entry.name)).map((entry) => entry.name).sort();
182
+ if (!ids.length) return [];
183
+ const deltas = [];
184
+ let remaining = RAW_BUDGET;
185
+ const start = rotation % ids.length;
186
+ for (let index = 0; index < ids.length && deltas.length < 16 && remaining > 0; index++) {
187
+ const position = (start + index) % ids.length;
188
+ rotation = position + 1;
189
+ const sessionId = ids[position];
190
+ const cursor = state.cursors[sessionId] || { offset: 0 };
191
+ if (cursor.blocked) continue;
192
+ if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0) { await conflict(sessionId); continue; }
193
+ const filename = path.join(sessionsRoot, sessionId, FILE);
194
+ let handle;
195
+ try {
196
+ const link = await fs.lstat(filename);
197
+ if (!link.isFile() || link.isSymbolicLink()) continue;
198
+ handle = await fs.open(filename, 'r');
199
+ const stat = await handle.stat();
200
+ if (stat.size < cursor.offset || (cursor.anchor && cursor.anchor !== await anchorAt(handle, cursor.offset))) {
201
+ await conflict(sessionId); continue;
202
+ }
203
+ let data = await bytesAt(handle, cursor.offset, Math.min(CHUNK, remaining, stat.size - cursor.offset));
204
+ // Preserve unfinished tail lines. Very long records are sent as raw byte chunks;
205
+ // receiver stores bytes, not independently decoded UTF-8 strings.
206
+ const newline = data.lastIndexOf(10);
207
+ if (newline >= 0) data = data.subarray(0, newline + 1);
208
+ else if (data.length < CHUNK) data = Buffer.alloc(0);
209
+ remaining -= data.length;
210
+ deltas.push({ sessionId, file: FILE, offset: cursor.offset, data: data.toString('base64') });
211
+ } catch (error) {
212
+ if (error.code !== 'ENOENT') await diagnose('TRANSCRIPT_READ_FAILED');
213
+ } finally { await handle?.close().catch(() => {}); }
214
+ }
215
+ return deltas;
216
+ }
217
+ async function acceptAcks(acks, deltas) {
218
+ if (!Array.isArray(acks)) throw new Error('ACK_INVALID');
219
+ const next = structuredClone(state);
220
+ const sent = new Map(deltas.map((delta) => [delta.sessionId, delta]));
221
+ const seen = new Set();
222
+ for (const ack of acks) {
223
+ const delta = sent.get(ack?.sessionId);
224
+ if (!delta || ack.file !== FILE || seen.has(ack.sessionId) || !Number.isSafeInteger(ack.offset) || ack.offset < 0 || ack.offset > delta.offset + Buffer.from(delta.data, 'base64').length) throw new Error('ACK_INVALID');
225
+ seen.add(ack.sessionId);
226
+ if (state.cursors[ack.sessionId]?.blocked) continue;
227
+ if (ack.conflict === true) {
228
+ await conflict(ack.sessionId);
229
+ next.cursors[ack.sessionId] = state.cursors[ack.sessionId];
230
+ continue;
231
+ }
232
+ let handle;
233
+ try {
234
+ handle = await fs.open(path.join(sessionsRoot, ack.sessionId, FILE), 'r');
235
+ const stat = await handle.stat();
236
+ const data = Buffer.from(delta.data, 'base64');
237
+ if (stat.size < delta.offset + data.length || !(await bytesAt(handle, delta.offset, data.length)).equals(data)) {
238
+ await conflict(ack.sessionId); next.cursors[ack.sessionId] = state.cursors[ack.sessionId]; continue;
239
+ }
240
+ next.cursors[ack.sessionId] = { offset: ack.offset, anchor: await anchorAt(handle, ack.offset) };
241
+ } finally { await handle?.close().catch(() => {}); }
242
+ }
243
+ await atomicJson(stateFile, next);
244
+ state = next;
245
+ }
246
+ async function exchange(endpoint, payload) {
247
+ if (!stillEnabled()) throw new Error('STOPPED');
248
+ const body = JSON.stringify({ deviceId: pairing.deviceId, envelope: await seal(pairing.key, pairing.deviceId, 'up', payload) });
249
+ if (Buffer.byteLength(body) > MAX_PACKET) throw new Error('PACKET_LIMIT');
250
+ if (!stillEnabled()) throw new Error('STOPPED');
251
+ controller = new AbortController();
252
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
253
+ const response = await fetchImpl(`${pairing.url}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal, redirect: 'error' });
254
+ if (!response.ok) {
255
+ await response.body?.cancel().catch(() => {});
256
+ if (endpoint === 'sync' && [401, 403, 404].includes(response.status)) enrolled = false;
257
+ throw new Error('CONTROL_HTTP_FAILED');
258
+ }
259
+ // Bounded response reading also covers chunked responses.
260
+ let size = 0;
261
+ const chunks = [];
262
+ for await (const chunk of response.body) {
263
+ size += chunk.length;
264
+ if (size > MAX_PACKET) { controller.abort(); throw new Error('RESPONSE_LIMIT'); }
265
+ chunks.push(chunk);
266
+ }
267
+ const reply = await open(pairing.key, pairing.deviceId, 'down', JSON.parse(Buffer.concat(chunks).toString('utf8')).envelope);
268
+ if (reply?.requestId !== payload.requestId) throw new Error('REQUEST_ID_MISMATCH');
269
+ return { reply, signal };
270
+ }
271
+ async function cycle() {
272
+ if (!stillEnabled()) return false;
273
+ if (!pairing) await initialize();
274
+ if (!enrolled) {
275
+ const { reply } = await exchange('enroll', { requestId: randomUUID(), sentAt: Date.now(), kind: 'enroll', device: identity });
276
+ if (reply.kind !== 'enrolled' || reply.deviceId !== pairing.deviceId) throw new Error('ENROLL_INVALID');
277
+ if (!stillEnabled()) return false;
278
+ enrolled = true;
279
+ }
280
+ const deltas = await collect();
281
+ if (!stillEnabled()) return false;
282
+ const payload = { requestId: randomUUID(), sentAt: Date.now(), device: identity, deltas };
283
+ if (state.ackCommandId) payload.ackCommandId = state.ackCommandId;
284
+ const { reply, signal } = await exchange('sync', payload);
285
+ if (!await stillEnabled()) return false;
286
+ await acceptAcks(reply.acks, deltas);
287
+ if (reply.command) {
288
+ const command = reply.command;
289
+ if (!validId(command.id)) throw new Error('COMMAND_INVALID');
290
+ if (command.id !== state.ackCommandId) {
291
+ loginProfile(command.profile);
292
+ if (!options.applyProfile) throw new Error('LOGIN_UNAVAILABLE');
293
+ if (!await stillEnabled()) return false;
294
+ await options.applyProfile(command.profile, { signal });
295
+ if (!await stillEnabled()) return false;
296
+ const next = { ...state, ackCommandId: command.id };
297
+ await atomicJson(stateFile, next);
298
+ state = next;
299
+ }
300
+ }
301
+ return true;
302
+ }
303
+ function tick() {
304
+ if (inFlight) return inFlight;
305
+ if (stopped || !enabled) return Promise.resolve(false);
306
+ inFlight = cycle().then((result) => { failures = 0; return result; }).catch(async () => {
307
+ failures = Math.min(failures + 1, 6);
308
+ await diagnose('SYNC_RETRY');
309
+ return false;
310
+ }).finally(() => { inFlight = undefined; controller = undefined; });
311
+ return inFlight;
312
+ }
313
+ function schedule(delay) {
314
+ if (stopped || !started || !enabled) return;
315
+ timer = setTimeout(async () => {
316
+ await tick();
317
+ schedule(Math.min(30_000, pollMs * 2 ** failures));
318
+ }, delay);
319
+ timer.unref?.();
320
+ }
321
+ return {
322
+ tick,
323
+ start() { if (!started && enabled) { started = true; schedule(0); } return this; },
324
+ async stop() { stopped = true; clearTimeout(timer); controller?.abort(); await inFlight; },
325
+ };
326
+ }