@michelj/context-guard 0.6.3 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michelj/context-guard",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Next-generation collaboration layer for humans and coding agents on Codex, Cursor, and Claude.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,8 +56,8 @@
56
56
  "node": ">=18"
57
57
  },
58
58
  "devDependencies": {
59
- "@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.1/michelj-context-guard-core-1.1.1.tgz",
60
- "@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.1/michelj-context-guard-workbench-1.1.1.tgz",
59
+ "@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-core-1.1.2.tgz",
60
+ "@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-workbench-1.1.2.tgz",
61
61
  "marked": "15.0.12",
62
62
  "playwright": "1.62.1"
63
63
  }
@@ -117,6 +117,8 @@ export class WorkbenchSync {
117
117
  this.urlPinned = Boolean(requestedSession) && !this.config?.root?.startsWith('cloud:');
118
118
  this.panel = document.createElement('details'); this.panel.id = 'cg-sync'; this.panel.className = 'set-block sync-settings';
119
119
  this.panel.innerHTML = '<summary>同步与恢复</summary><p id="cg-sync-status"></p><span id="cg-sync-version" hidden></span><div class="sync-actions"><button id="cg-sync-initialize" hidden>将当前图设为真实地图</button><button id="cg-sync-retry">重试</button><button id="cg-sync-export">导出草稿/旧缓存</button><button id="cg-sync-import">导入并比较</button><button id="cg-sync-reload">保留草稿后读取磁盘</button></div><label>Agent 会话<select id="cg-sync-session"></select></label><input id="cg-sync-file" type="file" accept="application/json" hidden>';
120
+ this.cloudBindingNotice = document.createElement('p'); this.cloudBindingNotice.hidden = true;
121
+ this.cloudBindingNotice.setAttribute('role', 'status'); this.panel.querySelector('#cg-sync-status').after(this.cloudBindingNotice);
120
122
  this.repairButton = document.createElement('button'); this.repairButton.id = 'cg-sync-repair'; this.repairButton.hidden = true;
121
123
  this.panel.querySelector('.sync-actions').append(this.repairButton);
122
124
  if (this.config?.interfaceCapabilities?.deviceLogin && !this.config.root?.startsWith('cloud:')) {
@@ -764,12 +766,19 @@ export class WorkbenchSync {
764
766
  this.a.setAccess(active && !unavailableMeta ? this.grants?.[this.activeSession]?.nodes || [] : [], this.activeSession, active, this.activeSession === ALL_SESSIONS, this.project?.main || null);
765
767
  }
766
768
  renderCloudStatus(status) {
769
+ const bindingLabel = status?.configured && status.status === 'conflict'
770
+ ? status.reason === 'session-bound-elsewhere' ? '当前会话绑定旧设备,请新建会话'
771
+ : status.reason === 'binding-conflict' ? '会话绑定冲突,请新建会话' : ''
772
+ : '';
773
+ if (this.cloudBindingNotice) {
774
+ this.cloudBindingNotice.hidden = !bindingLabel; this.cloudBindingNotice.textContent = bindingLabel;
775
+ }
767
776
  if (!this.cloudIndicator) return;
768
777
  if (!status?.configured) { this.cloudIndicator.hidden = true; return; }
769
778
  const value = status.status === 'synced' ? 'synced' : ['conflict', 'error'].includes(status.status) ? status.status : 'syncing';
770
779
  this.cloudIndicator.hidden = false; this.cloudIndicator.className = `cloud-sync-status ${value}`;
771
- const label = value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败';
772
- this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = label;
780
+ const label = bindingLabel || (value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败');
781
+ this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = bindingLabel ? `${label};旧记录与队列保持不变` : label;
773
782
  }
774
783
  async refreshCloudStatus() {
775
784
  if (this.config?.root?.startsWith('cloud:')) { this.renderCloudStatus({ configured: true, status: this.pendingSession ? 'syncing' : 'synced' }); return; }
@@ -61,6 +61,13 @@ Network failure leaves current queues on disk. Reconnect retries with backoff;
61
61
  conflicts require explicit reconciliation. Do not manufacture a new request ID,
62
62
  erase private state or report success merely because a connection is alive.
63
63
 
64
+ A confirmed Session-binding rejection stops automatic binding retries. When a
65
+ Session belongs to another device, create a new actual conversation in the host
66
+ and connect using that host-issued Session ID. Do not take over the old Session,
67
+ alias/change its ID, discard its failed receipt or move its tasks/queues to the
68
+ new conversation. Older binding-conflict receipts cannot prove which device owns
69
+ the Session; they also require a new host conversation, not blind retries.
70
+
64
71
  The private Session service uses authorized `/v1/projects/:project/sessions/`
65
72
  reads, changes/events and map writes. Session generations and server authorization
66
73
  remain enforced. Credentials never belong in Maps, logs or generated HTML.
@@ -465,7 +465,8 @@ export class ProtocolStore extends EventEmitter {
465
465
  previous.sessionId = payload.sessionId;
466
466
  return { session: { id: payload.sessionId, generation: previous.generation }, bindingVersion: previous.version };
467
467
  }
468
- if (previous && (!options.allowMigration || previous.deviceId !== p.deviceId || payload.expectedBindingVersion !== previous.version)) fail('CONFLICT', 'Migration requires the owning device and current binding version', { currentVersion: previous.version });
468
+ if (previous && previous.deviceId !== p.deviceId) fail('CONFLICT', 'Session is already bound to another device', { reason: 'session-bound-elsewhere' });
469
+ if (previous && (!options.allowMigration || payload.expectedBindingVersion !== previous.version)) fail('CONFLICT', 'Migration requires the owning device and current binding version', { currentVersion: previous.version });
469
470
  if (!previous && payload.expectedBindingVersion) fail('CONFLICT', 'Binding does not exist', { currentVersion: '' });
470
471
  const binding = { sessionId: payload.sessionId, deviceId: p.deviceId, agentId: payload.agentId, worktreeId: payload.worktreeId, generation: (previous?.generation || 0) + 1, version: randomUUID() };
471
472
  for (const creation of Object.values(state.sessionCreations || {})) {
@@ -10,7 +10,11 @@ async function request(origin, route, input, fetcher, timeoutMs = 15000) {
10
10
  try {
11
11
  response = await fetcher(new URL(route, origin), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
12
12
  headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) });
13
- } catch { fail('UNAVAILABLE', 'Cloud authorization is unavailable; retry the same connection command'); }
13
+ } catch (error) {
14
+ const beforeConnection = ['ENOTFOUND', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.cause?.code || error?.code);
15
+ fail('UNAVAILABLE', route.endsWith('/poll') && !beforeConnection ? 'Authorization claim outcome is unknown; explicitly retry browser authorization' : 'Cloud authorization is unavailable; retry the same connection command',
16
+ { retryableConnection: route.endsWith('/start') || beforeConnection });
17
+ }
14
18
  if ([404, 405, 426].includes(response.status)) fail('UPGRADE_REQUIRED', 'Cloud does not support browser authorization; upgrade Cloud or use the explicit private-input login');
15
19
  if (!response.headers.get('content-type')?.includes('application/json')) fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
16
20
  let result;
@@ -19,7 +23,8 @@ async function request(origin, route, input, fetcher, timeoutMs = 15000) {
19
23
  for await (const chunk of response.body) { size += chunk.length; if (size > 16384) fail('TOO_LARGE', 'Authorization reply is too large'); chunks.push(chunk); }
20
24
  result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
21
25
  } catch (error) { if (error.code === 'TOO_LARGE') throw error; fail('UNAVAILABLE', 'Cloud authorization reply was interrupted'); }
22
- if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed');
26
+ if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed',
27
+ { retryableConnection: [502, 503, 504].includes(response.status) && result?.error?.code === 'UNAVAILABLE' && result.error.retryable === true });
23
28
  if (!result.data || typeof result.data !== 'object') fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
24
29
  return { data: result.data, credential: response.headers.get('x-context-guard-credential') };
25
30
  }
@@ -32,15 +37,22 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
32
37
  const file = path.join(device.directory, 'browser-login.json');
33
38
  let deadline = clock() + 600000, grant;
34
39
  const checkDeadline = () => {
35
- if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command');
40
+ if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command', { reason: 'authorization-wait-expired' });
36
41
  };
37
42
  const authorize = async (route, input) => {
38
- checkDeadline();
39
- try {
40
- const result = await request(device.origin, route, input, fetcher, wait ? Math.max(1, Math.min(15000, Math.ceil(deadline - clock()))) : 15000);
43
+ for (let attempt = 0; attempt < 3; attempt++) {
41
44
  checkDeadline();
42
- return result;
43
- } catch (error) { checkDeadline(); throw error; }
45
+ try {
46
+ const result = await request(device.origin, route, input, fetcher, wait ? Math.max(1, Math.min(15000, Math.ceil(deadline - clock()))) : 15000);
47
+ checkDeadline();
48
+ return result;
49
+ } catch (error) {
50
+ checkDeadline();
51
+ if (attempt === 2 || error.code !== 'UNAVAILABLE' || error.details?.retryableConnection !== true) throw error;
52
+ await sleep(wait ? Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())) : 250 * 2 ** attempt);
53
+ checkDeadline();
54
+ }
55
+ }
44
56
  };
45
57
  let first = true;
46
58
  for (;;) {
@@ -85,7 +97,7 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
85
97
  let result;
86
98
  try { result = await authorize('/api/auth/device/poll', { deviceCode: pending.deviceCode }); }
87
99
  catch (error) {
88
- if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code)) await atomicWrite(file, encode({ status: error.code }));
100
+ if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code) && error.details?.reason !== 'authorization-wait-expired') await atomicWrite(file, encode({ status: error.code }));
89
101
  throw error;
90
102
  }
91
103
  if (result.data.status === 'pending') return safe;
@@ -292,6 +292,68 @@ export class ClaudeRuntime {
292
292
  }
293
293
  }
294
294
 
295
+ // Monitor an already-created native process. The worker calls this immediately
296
+ // after spawn; it does not wait for a model-specific readiness handshake.
297
+ export async function runClaudeTurn(child, { config, sessionId, message, logFile, onStarted, onFinished }) {
298
+ const exit = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
299
+ exit.catch(() => {}); // A spawn failure may precede opening the private log.
300
+ let buffer = '', bytes = 0, result = null, initialized = false, stopReason = null;
301
+ const decoder = new StringDecoder('utf8');
302
+ const output = await fs.open(logFile, 'a', 0o600);
303
+ let writes = Promise.resolve();
304
+ const idleMs = config.timeoutMs || 600000;
305
+ let idleTimer, killTimer;
306
+ const terminate = (reason = 'CLAUDE_TIMEOUT_OR_INTERRUPTED') => {
307
+ if (stopReason) return;
308
+ stopReason = reason; child.kill('SIGTERM');
309
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
310
+ };
311
+ const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
312
+ active();
313
+ // Activity keeps the idle timer alive, but cannot extend a native turn
314
+ // indefinitely. A stopped turn stays recoverable in the same Session.
315
+ const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
316
+ const interrupted = () => terminate();
317
+ const stdinError = () => {};
318
+ const receive = chunk => {
319
+ bytes += chunk.length;
320
+ if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
321
+ active();
322
+ writes = writes.then(() => output.write(chunk));
323
+ buffer += decoder.write(chunk);
324
+ for (;;) {
325
+ const index = buffer.indexOf('\n'); if (index < 0) break;
326
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
327
+ try {
328
+ const event = JSON.parse(line);
329
+ if (event.session_id && event.session_id !== sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
330
+ if (event.type === 'system' && event.subtype === 'init') initialized = true;
331
+ if (event.type === 'result') result = event;
332
+ } catch { /* Native diagnostic text is not a protocol receipt. */ }
333
+ }
334
+ };
335
+ process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
336
+ child.stdout.on('data', receive);
337
+ child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
338
+ child.stdin.on('error', stdinError);
339
+ try {
340
+ child.stdin.end(message);
341
+ await onStarted?.(); // Same point as the worker's original running update.
342
+ const code = await exit;
343
+ await writes; await output.sync();
344
+ const outcome = { state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
345
+ error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null), initialized };
346
+ await onFinished?.(outcome); // Preserve persistence before timer/log cleanup.
347
+ return outcome;
348
+ } finally {
349
+ clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer);
350
+ process.removeListener('SIGTERM', interrupted); process.removeListener('SIGINT', interrupted);
351
+ child.stdout.removeListener('data', receive); child.stderr.removeListener('data', active);
352
+ child.stdin.removeListener('error', stdinError);
353
+ await output.close();
354
+ }
355
+ }
356
+
295
357
  async function runWorker(file, jobFile) {
296
358
  const session = await readJSON(file), config = session.config;
297
359
  const job = await readJSON(jobFile);
@@ -303,66 +365,24 @@ async function runWorker(file, jobFile) {
303
365
  // Written before spawning Claude. A crash after this point is uncertain, not
304
366
  // permission to submit the same prompt again.
305
367
  await update({ workerPid: process.pid, state: 'dispatching' });
306
- let child, stopReason = null;
307
368
  try {
308
369
  const credentials = await readJSON(config.environmentFile);
309
370
  if (Object.keys(credentials).some(key => !providerKeys.has(key)) || Object.values(credentials).some(value => typeof value !== 'string')) fail('INVALID_ENVIRONMENT', 'Only provider settings are accepted in the private environment file');
310
371
  const env = await claudeEnvironment(config, credentials);
311
- child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
372
+ const child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
312
373
  cwd: config.root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
313
374
  });
314
- const exit = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
315
- exit.catch(() => {}); // A spawn failure may precede opening the private log.
316
- let buffer = '', bytes = 0, result = null, initialized = false;
317
- const decoder = new StringDecoder('utf8');
318
- const output = await fs.open(jobFile + '.jsonl', 'a', 0o600);
319
- let writes = Promise.resolve();
320
- const idleMs = config.timeoutMs || 600000;
321
- let idleTimer, killTimer;
322
- const terminate = (reason = 'CLAUDE_TIMEOUT_OR_INTERRUPTED') => {
323
- if (stopReason) return;
324
- stopReason = reason; child.kill('SIGTERM');
325
- killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
326
- };
327
- const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
328
- active();
329
- // Activity keeps the idle timer alive, but cannot extend a native turn
330
- // indefinitely. A stopped turn stays recoverable in the same Session.
331
- const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
332
- const interrupted = () => terminate();
333
- process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
334
- child.stdout.on('data', chunk => {
335
- bytes += chunk.length;
336
- if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
337
- active();
338
- writes = writes.then(() => output.write(chunk));
339
- buffer += decoder.write(chunk);
340
- for (;;) {
341
- const index = buffer.indexOf('\n'); if (index < 0) break;
342
- const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
343
- try {
344
- const event = JSON.parse(line);
345
- if (event.session_id && event.session_id !== session.sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
346
- if (event.type === 'system' && event.subtype === 'init') initialized = true;
347
- if (event.type === 'result') result = event;
348
- } catch { /* Native diagnostic text is not a protocol receipt. */ }
349
- }
375
+ await runClaudeTurn(child, { config, sessionId: session.sessionId, message: job.message,
376
+ logFile: jobFile + '.jsonl', onStarted: () => update({ state: 'running', childPid: child.pid }),
377
+ onFinished: async ({ initialized, ...outcome }) => {
378
+ await update(outcome);
379
+ await withFileLock(file + '.lock', async () => {
380
+ const latest = await readJSON(file);
381
+ await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
382
+ ...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
383
+ });
384
+ },
350
385
  });
351
- child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
352
- child.stdin.on('error', () => {});
353
- child.stdin.end(job.message);
354
- try {
355
- await update({ state: 'running', childPid: child.pid });
356
- const code = await exit;
357
- await writes; await output.sync();
358
- await update({ state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
359
- error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null) });
360
- await withFileLock(file + '.lock', async () => {
361
- const latest = await readJSON(file);
362
- await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
363
- ...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
364
- });
365
- } finally { clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer); await output.close(); }
366
386
  } catch (error) { await update({ state: 'interrupted', error: String(error.code || 'CLAUDE_LAUNCH_FAILED').slice(0, 100) }); }
367
387
  }
368
388
 
@@ -0,0 +1,5 @@
1
+ export function canRetryWorkbenchListen(error, port, attempt, platform = process.platform) {
2
+ if (port === 0 || attempt >= 20) return false;
3
+ return error?.code === 'EADDRINUSE' || platform === 'win32' && error?.code === 'EACCES'
4
+ && error.syscall === 'listen' && error.address === '127.0.0.1' && error.port === port + attempt;
5
+ }
@@ -6,6 +6,7 @@ import { randomBytes } from 'node:crypto';
6
6
  import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
7
7
  import { RouteStore } from './portless-routes.mjs';
8
8
  import { compatibleRuntime } from './runtime.mjs';
9
+ import { canRetryWorkbenchListen } from './listen.mjs';
9
10
  import { DeviceHeartbeat } from './device-heartbeat.mjs';
10
11
 
11
12
  const secret = () => randomBytes(32).toString('base64url');
@@ -81,8 +82,16 @@ export async function startNamedProxy({ dir, port = 1355 } = {}) {
81
82
  server.on('upgrade', (_req, socket) => socket.destroy()); // Workbench uses SSE, not WebSockets.
82
83
  server.requestTimeout = 15000; server.headersTimeout = 10000;
83
84
  for (let attempt = 0; ; attempt++) {
84
- try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port ? port + attempt : 0, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); }); break; }
85
- catch (e) { server.removeAllListeners('error'); if (e.code !== 'EADDRINUSE' || !port || attempt >= 20) throw e; }
85
+ try {
86
+ await new Promise((resolve, reject) => {
87
+ const onListening = () => { server.off('error', onError); resolve(); };
88
+ const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
89
+ server.once('error', onError);
90
+ try { server.listen(port ? port + attempt : 0, '127.0.0.1', onListening); }
91
+ catch (error) { onError(error); }
92
+ });
93
+ break;
94
+ } catch (error) { if (!canRetryWorkbenchListen(error, port, attempt)) throw error; }
86
95
  }
87
96
  base = `http://127.0.0.1:${server.address().port}`;
88
97
  const state = { version: 1, runtimeSchema: PROXY_RUNTIME_SCHEMA, instance, pid: process.pid, base, adminToken };
@@ -89,14 +89,31 @@ async function physicalGitDirectories(root) {
89
89
  return { topLevel, commonDir };
90
90
  }
91
91
 
92
+ async function commitIds(root, mainRef) {
93
+ if (!mainRef) return [await git(root, ['rev-parse', 'HEAD'], { optional: true }), ''];
94
+ try {
95
+ const output = await git(root, ['rev-parse', '--revs-only', '--end-of-options', 'HEAD', `${mainRef}^{commit}`]);
96
+ const records = output.split(/\r?\n/);
97
+ if (records.length === 2 && records.every(value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value))
98
+ && records[0].length === records[1].length) return records;
99
+ } catch (error) {
100
+ if (!optionalGitMiss(error)) throw error;
101
+ }
102
+ // Missing refs, older options and ambiguous output retain the independent
103
+ // fresh reads; process/system failures must not silently enter this fallback.
104
+ return Promise.all([
105
+ git(root, ['rev-parse', 'HEAD'], { optional: true }),
106
+ git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }),
107
+ ]);
108
+ }
109
+
92
110
  async function worktreeMetadata(root, main = null, physicalGitDir) {
93
111
  const mainBranch = main?.branch || '';
94
112
  const mainRef = main?.ref || '';
95
- const [head, currentBranch, rawGitDir, mainSha] = await Promise.all([
96
- git(root, ['rev-parse', 'HEAD'], { optional: true }),
113
+ const [[head, mainSha], currentBranch, rawGitDir] = await Promise.all([
114
+ commitIds(root, mainRef),
97
115
  git(root, ['branch', '--show-current'], { optional: true }),
98
116
  physicalGitDir === undefined ? git(root, ['rev-parse', '--path-format=absolute', '--git-dir'], { optional: true }) : physicalGitDir,
99
- mainRef ? git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }) : '',
100
117
  ]);
101
118
  const gitDir = rawGitDir ? await fs.realpath(rawGitDir).catch(() => path.resolve(root, rawGitDir)) : '';
102
119
  return { branch: currentBranch, head, gitDir, mainBranch, mainRef, mainSha };
@@ -9,6 +9,13 @@ import { sendMessage, ProjectMessagePump } from './protocol-client.mjs';
9
9
 
10
10
  const readTypes = new Set(['sync.heartbeat', 'sync.read', 'workbench.read', 'object.read', 'blob.get']);
11
11
  const messageScope = message => message.session?.id || message.payload.sessionId || 'project';
12
+ function rejectedBindingReason(record, sessionId) {
13
+ if (record?.state !== 'rejected' || record.message?.type !== 'session.bind' || record.message.payload?.sessionId !== sessionId || record.error?.code !== 'CONFLICT') return '';
14
+ if (record.error.details?.reason === 'session-bound-elsewhere') return 'session-bound-elsewhere';
15
+ // This older rejection also covered same-device migration/version conflicts.
16
+ if (record.error.message === 'Migration requires the owning device and current binding version' && typeof record.error.details?.currentVersion === 'string') return 'binding-conflict';
17
+ return '';
18
+ }
12
19
  // A single project host owns this connection. Agents never receive its credential.
13
20
  export class DeviceConnection {
14
21
  constructor({ directory, origin, transport = sendMessage, allowLoopback = false }) {
@@ -180,9 +187,19 @@ export class DeviceConnection {
180
187
  const file = path.join(this.directory, 'bindings', `${hash(message.payload.sessionId)}.json`);
181
188
  return withFileLock(`${file}.lock`, async () => {
182
189
  const versions = await readJSON(file, {});
183
- const remote = await this.send(message, wire => ({ ...wire, payload: { ...wire.payload,
190
+ let remote;
191
+ try { remote = await this.send(message, wire => ({ ...wire, payload: { ...wire.payload,
184
192
  expectedBindingVersion: versions[wire.payload.expectedBindingVersion] || '',
185
- } }));
193
+ } })); }
194
+ catch (error) {
195
+ const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(message.id)}.json`), null);
196
+ if (rejectedBindingReason(receipt, localBinding.session.id)) {
197
+ versions.rejections ||= {};
198
+ versions.rejections[localBinding.bindingVersion] = message.id;
199
+ await atomicWrite(file, encode(versions));
200
+ }
201
+ throw error;
202
+ }
186
203
  if (remote.session.id !== localBinding.session.id || remote.session.generation !== localBinding.session.generation) {
187
204
  fail('STALE_SESSION', 'Cloud and local binding generations differ');
188
205
  }
@@ -192,16 +209,27 @@ export class DeviceConnection {
192
209
  });
193
210
  }
194
211
  async ensureBinding(binding) {
195
- const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
196
- if (versions[binding.version]) return;
212
+ const status = await this.bindingStatus(binding);
213
+ if (status.status === 'ready') return;
214
+ if (status.status === 'conflict') fail('CONFLICT', 'Session binding requires a new host Session', { reason: status.reason });
197
215
  await this.bind({ v: 2, id: `bind:${hash(binding.version)}`, type: 'session.bind', payload: {
198
216
  sessionId: binding.sessionId, worktreeId: binding.worktreeId, agentId: binding.agentId,
199
217
  expectedBindingVersion: binding.version,
200
218
  } }, { session: { id: binding.sessionId, generation: binding.generation }, bindingVersion: binding.version });
201
219
  }
202
- async bindingReady(binding) {
220
+ async bindingStatus(binding) {
221
+ if (!binding) return { status: 'pending' };
203
222
  const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
204
- if (versions[binding.version]) return true;
223
+ if (versions[binding.version]) return { status: 'ready' };
224
+ const requestId = versions.rejections?.[binding.version] || `bind:${hash(binding.version)}`;
225
+ const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(requestId)}.json`), null);
226
+ const reason = rejectedBindingReason(receipt, binding.sessionId);
227
+ return reason ? { status: 'conflict', code: 'CONFLICT', reason } : { status: 'pending' };
228
+ }
229
+ async bindingReady(binding) {
230
+ const status = await this.bindingStatus(binding);
231
+ if (status.status === 'ready') return true;
232
+ if (status.status === 'conflict') return false;
205
233
  this.enrolling ||= new Map();
206
234
  if (!this.enrolling.has(binding.sessionId) && this.enrolling.size < 4) {
207
235
  const job = this.ensureBinding(binding).catch(error => { this.lastError = error.code || 'UNAVAILABLE'; })
@@ -1,6 +1,8 @@
1
1
  import { fail } from '../shared/protocol.mjs';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { performance } from 'node:perf_hooks';
5
+ import { pause } from '../shared/io.mjs';
4
6
 
5
7
  const execFileAsync = promisify(execFile);
6
8
 
@@ -20,15 +22,35 @@ export async function lookupRepository(slug, {
20
22
  fetcher = fetch,
21
23
  token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
22
24
  tokenProvider = keychainToken,
25
+ clock = () => performance.now(),
26
+ sleep = pause,
23
27
  } = {}) {
24
28
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug) || slug.split('/').some(part => ['.', '..'].includes(part))) fail('INVALID_ARGUMENT', 'Invalid GitHub repository');
25
29
  let url = new URL(`https://api.github.com/repos/${slug}`);
26
- let authorization = token || '', keychainChecked = !!authorization;
30
+ const deadline = clock() + 40000;
31
+ const checkDeadline = () => { if (clock() >= deadline) fail('UNAVAILABLE', 'GitHub repository identity verification timed out'); };
32
+ const authorization = token || await tokenProvider() || '';
33
+ checkDeadline();
34
+ const request = async target => {
35
+ for (let attempt = 0; attempt < 3; attempt++) {
36
+ checkDeadline();
37
+ let response;
38
+ try {
39
+ response = await fetcher(target, { redirect: 'manual', signal: AbortSignal.timeout(Math.max(1, Math.min(10000, Math.ceil(deadline - clock())))),
40
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } });
41
+ } catch {
42
+ checkDeadline();
43
+ if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository identity could not be verified');
44
+ }
45
+ checkDeadline();
46
+ if (response && ![502, 503, 504].includes(response.status)) return response;
47
+ await response?.body?.cancel();
48
+ if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository is temporarily unavailable');
49
+ await sleep(Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())));
50
+ }
51
+ };
27
52
  for (let attempt = 0; attempt < 4; attempt++) {
28
- let response;
29
- try { response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(10000),
30
- headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } }); }
31
- catch { fail('UNAVAILABLE', 'GitHub repository identity could not be verified'); }
53
+ const response = await request(url);
32
54
  if ([301, 302, 307, 308].includes(response.status)) {
33
55
  const location = response.headers.get('location'); await response.body?.cancel();
34
56
  if (!location) fail('UNAVAILABLE', 'GitHub redirect is missing');
@@ -38,11 +60,6 @@ export async function lookupRepository(slug, {
38
60
  }
39
61
  if (!response.ok) {
40
62
  await response.body?.cancel();
41
- if (!authorization && !keychainChecked && [401, 403, 404].includes(response.status)) {
42
- keychainChecked = true;
43
- authorization = await tokenProvider();
44
- if (authorization) { attempt -= 1; continue; }
45
- }
46
63
  fail(response.status === 404 ? 'NOT_FOUND' : [401, 403].includes(response.status) ? 'FORBIDDEN' : 'UNAVAILABLE', 'GitHub repository is unavailable to this backend');
47
64
  }
48
65
  let value;
@@ -51,6 +68,7 @@ export async function lookupRepository(slug, {
51
68
  for await (const chunk of response.body) { size += chunk.length; if (size > 1024 * 1024) fail('TOO_LARGE', 'GitHub response exceeds limit'); chunks.push(chunk); }
52
69
  value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
53
70
  } catch { fail('UNAVAILABLE', 'GitHub returned an invalid repository identity'); }
71
+ checkDeadline();
54
72
  if (!Number.isSafeInteger(value.id) || value.id <= 0 || typeof value.full_name !== 'string') fail('UNAVAILABLE', 'GitHub returned an invalid repository identity');
55
73
  return { repositoryId: String(value.id), slug: value.full_name };
56
74
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import { constants as fsConstants } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { isDeepStrictEqual } from 'node:util';
5
6
  import { atomicWrite, encode, readJSON, withFileLock } from '../shared/io.mjs';
6
7
  import { MapError } from '../shared/map-model.mjs';
7
8
 
@@ -100,6 +101,11 @@ export async function rememberProject(project, { dir = globalWorkbenchDirectory(
100
101
  } : previous?.runtime || null,
101
102
  updatedAt: new Date().toISOString(),
102
103
  };
104
+ if (previous) {
105
+ const { updatedAt: _previousUpdatedAt, ...previousContent } = previous;
106
+ const { updatedAt: _nextUpdatedAt, ...nextContent } = record;
107
+ if (isDeepStrictEqual(previousContent, nextContent)) return previous;
108
+ }
103
109
  const next = {
104
110
  version: 1,
105
111
  projects: [...registry.projects.filter(item => item.projectId !== project.projectId), record]
@@ -15,6 +15,7 @@ import { memoryRequest, sessionMemoryDir, memoryConfigPath } from './memory.mjs'
15
15
  import { MemorySyncCoordinator } from './sync-coordinator.mjs';
16
16
  import { inspectRetiredSync } from './sync.mjs';
17
17
  import { runtimeIdentity } from './runtime.mjs';
18
+ import { canRetryWorkbenchListen } from './listen.mjs';
18
19
  import { Attachments } from './attachments.mjs';
19
20
  import { ProtocolStore } from '../shared/protocol-store.mjs';
20
21
  import { ProtocolBlobs, serveBlob } from '../shared/protocol-blobs.mjs';
@@ -144,6 +145,8 @@ export async function health(state) {
144
145
  return result?.ok ? result.value : null;
145
146
  }
146
147
  export { loopbackJSON };
148
+ export { canRetryWorkbenchListen };
149
+
147
150
  export async function startServer({ root, port = 8877, host = '127.0.0.1', fault, messageQueue = queueCodexMessage, repositoryLookup = lookupRepository } = {}) {
148
151
  if (!['127.0.0.1', 'localhost'].includes(host)) throw new MapError('INVALID_HOST', 'Workbench only listens on loopback');
149
152
  const directory = await defaultDirectoryAvailability();
@@ -476,6 +479,14 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
476
479
  const connection = await projectDevice();
477
480
  const coordinator = new MemorySyncCoordinator({ project: sessionProject, sessionId, store: target, directory: syncDirectory,
478
481
  managed: !!connection && await connection.supports('private-map-heads'),
482
+ request: async (...args) => {
483
+ if (connection && await connection.connected()) {
484
+ const binding = await protocolStore.registeredBinding(backendPrincipal, sessionId);
485
+ const status = await connection.bindingStatus(binding);
486
+ if (status.status === 'conflict') throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.reason });
487
+ }
488
+ return memoryRequest(...args);
489
+ },
479
490
  display: async () => {
480
491
  const identity = (await access.sessionRegistry()).find(item => item.id === sessionId);
481
492
  return identity?.name ? { name: identity.name, platform: identity.platform } : null;
@@ -555,6 +566,13 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
555
566
  return refreshing;
556
567
  }
557
568
  async function cloudSyncStatus(viewId = 'main') {
569
+ if (viewId.startsWith('session:')) {
570
+ const connection = await projectDevice();
571
+ if (connection && await connection.connected()) {
572
+ const status = await connection.bindingStatus(await protocolStore.registeredBinding(backendPrincipal, viewId.slice(8)));
573
+ if (status.status === 'conflict') return { ...(syncCoordinators.get(viewId)?.snapshot() || {}), configured: true, status: 'conflict', error: status.code, reason: status.reason };
574
+ }
575
+ }
558
576
  if (syncCoordinators.has(viewId)) return syncCoordinators.get(viewId).snapshot();
559
577
  return { configured: false, status: 'disabled', cursor: 0, receivedCursor: 0, conflict: null, serviceAlive: false };
560
578
  }
@@ -593,7 +611,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
593
611
  else await connection.bind(bindMessage, protocolBinding);
594
612
  cloudBinding = { status: 'ready' };
595
613
  }
596
- catch (error) { cloudBinding = { status: 'pending', code: error.code || 'UNAVAILABLE' }; }
614
+ catch (error) {
615
+ const status = await connection.bindingStatus({ sessionId: prepared.sessionId, version: protocolBinding.bindingVersion });
616
+ cloudBinding = status.status === 'conflict' ? status : { status: 'pending', code: error.code || 'UNAVAILABLE' };
617
+ if (cloudBinding.status === 'conflict') {
618
+ const directory = path.join(sessionMemoryDir(prepared.sessionProject, prepared.sessionId), 'remote-sync');
619
+ const stateFile = path.join(directory, 'state.json');
620
+ await atomicWrite(stateFile, encode({ ...await readJSON(stateFile, {}), configured: true,
621
+ status: 'conflict', error: 'CONFLICT', reason: cloudBinding.reason,
622
+ pending: await readJSON(path.join(directory, 'outbox.json'), null) ? 1 : 0 }));
623
+ }
624
+ }
597
625
  }
598
626
  const lazyCloudMap = connection && await connection.supports('private-map-heads');
599
627
  const sessionFiles = project.kind === 'git' && !lazyCloudMap && !stores.has(`session:${prepared.sessionId}`) ? await ensureSessionMap(prepared.sessionProject, prepared.sessionId) : null;
@@ -965,8 +993,8 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
965
993
  if (route.startsWith('/api/')) {
966
994
  const actor = auth(req, url);
967
995
  const viewId = viewFor(actor, url);
968
- const activeStore = await storeFor(viewId);
969
996
  if (route === '/api/cloud-sync' && req.method === 'GET') { isHuman(actor); return send(res, 200, await cloudSyncStatus(viewId)); }
997
+ const activeStore = await storeFor(viewId);
970
998
  if (route === '/api/events' && req.method === 'GET') {
971
999
  isHuman(actor); const id = url.searchParams.get('clientId');
972
1000
  if (!id || id.length > 100) throw new MapError('INVALID_CLIENT', 'Invalid clientId');
@@ -1108,8 +1136,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
1108
1136
  });
1109
1137
  server.requestTimeout = 10000; server.headersTimeout = 10000;
1110
1138
  for (let attempt = 0; ; attempt++) {
1111
- try { await new Promise((resolve, reject) => { const onError = e => reject(e); server.once('error', onError); server.listen(port === 0 ? 0 : port + attempt, '127.0.0.1', () => { server.off('error', onError); resolve(); }); }); break; }
1112
- catch (e) { if (e.code !== 'EADDRINUSE' || attempt >= 20 || port === 0) throw e; }
1139
+ try {
1140
+ await new Promise((resolve, reject) => {
1141
+ const onListening = () => { server.off('error', onError); resolve(); };
1142
+ const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
1143
+ server.once('error', onError);
1144
+ try { server.listen(port === 0 ? 0 : port + attempt, '127.0.0.1', onListening); }
1145
+ catch (error) { onError(error); }
1146
+ });
1147
+ break;
1148
+ }
1149
+ catch (e) { if (!canRetryWorkbenchListen(e, port, attempt)) throw e; }
1113
1150
  }
1114
1151
  base = `http://127.0.0.1:${server.address().port}`;
1115
1152
  const state = { ...runtimeIdentity(), root, projectId: project.projectId, worktreeRoot: project.worktreeRoot, worktreeId: project.worktreeId, sharedDir: project.sharedDir, pid: process.pid, instance, url: base + '/prototype/workbench.html', adminToken };
@@ -126,6 +126,13 @@ export class MemorySyncCoordinator extends EventEmitter {
126
126
  this.update(fields);
127
127
  await atomicWrite(this.stateFile, encode(this.status));
128
128
  }
129
+ async preserveBindingConflict(error) {
130
+ const reason = error?.details?.reason;
131
+ if (error?.code !== 'CONFLICT' || !['session-bound-elsewhere', 'binding-conflict'].includes(reason)) return false;
132
+ await this.persist({ status: 'conflict', error: 'CONFLICT', reason,
133
+ conflict: { code: 'CONFLICT', reason }, pending: await readJSON(this.outboxFile, null) ? 1 : 0 });
134
+ return true;
135
+ }
129
136
 
130
137
  async start() {
131
138
  await fs.mkdir(this.directory, { recursive: true });
@@ -272,6 +279,7 @@ export class MemorySyncCoordinator extends EventEmitter {
272
279
  await this.persist({ status: equalDocument(acknowledged, this.store.doc) ? 'synced' : 'syncing', pending: 0, lastSyncedAt: result.persistedAt || new Date().toISOString() });
273
280
  if (!equalDocument(acknowledged, this.store.doc)) await this.queueLocal();
274
281
  } catch (error) {
282
+ if (await this.preserveBindingConflict(error)) return;
275
283
  if (error.code === 'SESSION_REOPEN_REQUIRED') {
276
284
  await this.createSessionGeneration();
277
285
  return;
@@ -451,6 +459,7 @@ export class MemorySyncCoordinator extends EventEmitter {
451
459
  this.retryDelay = this.retryMin;
452
460
  } catch (error) {
453
461
  clearTimeout(this.streamTimer);
462
+ if (await this.preserveBindingConflict(error)) break;
454
463
  if (this.closed || this.managed) break;
455
464
  if (this.abort?.signal.reason?.code === 'EVENT_STREAM_TIMEOUT') error = this.abort.signal.reason;
456
465
  await this.schedule(async () => {
@@ -497,6 +506,7 @@ export class MemorySyncCoordinator extends EventEmitter {
497
506
  if (this.status.pending) await this.flush();
498
507
  else if (['offline', 'error', 'connecting'].includes(this.status.status)) await this.persist({ status: 'synced', error: null });
499
508
  }).catch(async error => {
509
+ if (await this.preserveBindingConflict(error)) throw error;
500
510
  if (!this.status.conflict) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || 'MEMORY_UNAVAILABLE' });
501
511
  throw error;
502
512
  });
@@ -77,6 +77,7 @@ export async function sessionSync(root, sessionId, action) {
77
77
  const project = await resolveProject(root);
78
78
  if (!(await bindingStatus(project, sessionId)).session.bound) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind the actual Session before synchronization', 409);
79
79
  if (!status.configured) throw new MapError('MEMORY_NOT_CONFIGURED', 'Connect this project using workbench connect', 503);
80
+ if (status.state?.status === 'conflict' && ['session-bound-elsewhere', 'binding-conflict'].includes(status.state.reason)) throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.state.reason });
80
81
  if (['prepare', 'pull', 'checkpoint'].includes(action)) return prepareMemory(project, sessionId);
81
82
  if (action === 'finish') {
82
83
  const receipt = await synchronizeMemory(root, sessionId);