@ctrl-spc/cs 0.7.15 → 0.7.16

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.
@@ -1,19 +1,20 @@
1
+ import { sessionRenewing } from './supabase.js';
1
2
  import { createServer } from 'node:http';
2
- import { randomUUID, timingSafeEqual, createHash } from 'node:crypto';
3
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
4
  import { existsSync, readFileSync, writeFileSync, openSync, closeSync, mkdirSync } from 'node:fs';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { spawn } from 'node:child_process';
7
8
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
9
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
- import { configDir, getMachineIdentity, lifecycleToken, readLifecycleToken, readMcpToken, readSession, machineHostname } from './config.js';
10
+ import { configDir, getMachineIdentity, lifecycleToken, readLifecycleToken, readMcpToken, readSessionRecord, machineHostname } from './config.js';
10
11
  import { entryPath, ensureAutostart, prepareAutostart, autostartDisabled, autostartEnabled, loadedStartupJob, unloadStoppedStartupJob } from './autostart.js';
11
12
  import { CLI_VERSION } from './package-version.js';
12
13
  import { TOOLS_SERVER_PORT } from './env.js';
13
14
  import { claimLifecycleOperation, publishRuntime, readMigration, readOperation, readRuntime, removeOperation, removeRuntime, updateOperation, updateRuntime, writeMigration } from './daemon-lock.js';
14
15
  import { inspectProcess, processIdentityMatches, osBootIdentity, loopbackListenerPid, terminateOwnedRoot } from './win-shell.js';
15
16
  import { initializeOwnedWork, snapshotOwnedWork, interruptOwnedWork, markOwnedWorkInterrupted, ownedWorkInstanceNonces } from './daemon-processes.js';
16
- import { startPresence, stopPresence, liveClient, suspendPresenceWork, resumePresenceWork, presenceCloudState } from './presence.js';
17
+ import { startPresence, stopPresence, liveClient, suspendPresenceWork, resumePresenceWork, presenceCloudState, observePresenceSession } from './presence.js';
17
18
  import { NotLoggedIn } from './supabase.js';
18
19
  import { launchWindowsService, windowsJobExecutable } from './windows-job.js';
19
20
  const MAX_COMMAND_MS = 30_000;
@@ -42,8 +43,7 @@ async function bounded(promise, deadline) {
42
43
  }
43
44
  }
44
45
  function sessionFingerprint() {
45
- const session = readSession();
46
- return session ? createHash('sha256').update(JSON.stringify(session)).digest('hex') : 'none';
46
+ return readSessionRecord()?.generation ?? 'none';
47
47
  }
48
48
  function pendingExecution(work) {
49
49
  const claims = new Set((work.claimPending ?? []).map((item) => item.id));
@@ -82,6 +82,20 @@ export async function inspectLocalRuntime(deadline = Date.now() + 10_000) {
82
82
  return { record, status: null };
83
83
  } // Verified local owner is alive; readiness remains unknown.
84
84
  }
85
+ /** Acknowledgment means admission is closed, not merely that credentials were deleted. */
86
+ export async function notifySessionChanged() {
87
+ const deadline = Date.now() + 5000;
88
+ if (runtime) {
89
+ await bounded(observePresenceSession(), deadline);
90
+ return;
91
+ }
92
+ const current = await inspectLocalRuntime(deadline);
93
+ if (!current)
94
+ return;
95
+ if (!current.status)
96
+ throw new Error('Sign-in changed locally, but the running service did not confirm it. Check this computer in Companion.');
97
+ await control(current.record, 'session-changed', deadline);
98
+ }
85
99
  async function verifiedLegacyOwner(deadline) {
86
100
  const token = readMcpToken();
87
101
  if (!token)
@@ -188,22 +202,29 @@ let signalController = null;
188
202
  async function runtimeStatus(deadline = Date.now() + 8000) {
189
203
  if (!runtime)
190
204
  throw new Error('This process does not own the local service.');
191
- return { nonce: runtime.record.nonce, pid: process.pid, version: CLI_VERSION, local: runtime.record.state, cloud: presenceCloudState(), work: await snapshotOwnedWork(deadline) };
205
+ return { nonce: runtime.record.nonce, pid: process.pid, version: CLI_VERSION, local: runtime.record.state, cloud: presenceCloudState(), sessionRenewing: sessionRenewing(), work: await snapshotOwnedWork(deadline) };
192
206
  }
193
207
  async function connectCloud() {
194
- if (!runtime || runtime.record.state === 'stopping' || connecting || liveClient())
195
- return;
196
- if (rejectedSession === sessionFingerprint())
208
+ if (!runtime || runtime.record.state === 'stopping' || connecting)
197
209
  return;
198
210
  connecting = (async () => {
199
211
  try {
212
+ await observePresenceSession();
213
+ if (liveClient())
214
+ return;
215
+ if (rejectedSession === sessionFingerprint())
216
+ return;
200
217
  await startPresence();
201
218
  rejectedSession = null;
202
219
  }
203
220
  catch (error) {
204
- if (error instanceof NotLoggedIn)
205
- rejectedSession = sessionFingerprint();
206
- console.warn(error instanceof NotLoggedIn ? 'Cloud sign-in is required. Local service controls remain available.' : 'Cloud connection is unavailable. Local service controls remain available.');
221
+ if (error instanceof NotLoggedIn) {
222
+ try {
223
+ rejectedSession = sessionFingerprint();
224
+ }
225
+ catch { /* Storage failures remain unavailable. */ }
226
+ }
227
+ console.warn(error instanceof NotLoggedIn ? 'Cloud sign-in is required. Local service controls remain available.' : 'Cloud connection or local sign-in storage is unavailable. Local service controls remain available.');
207
228
  }
208
229
  })();
209
230
  try {
@@ -354,6 +375,12 @@ export async function startLocalRuntime({ onStop } = {}) {
354
375
  res.end(JSON.stringify(await runtimeStatus()));
355
376
  return;
356
377
  }
378
+ if (req.url === '/session-changed' && req.method === 'POST') {
379
+ await observePresenceSession();
380
+ res.end(JSON.stringify(await runtimeStatus(Date.now() + 3000)));
381
+ void connectCloud().catch(() => { }); // The resident reconnect loop retains failed startup.
382
+ return;
383
+ }
357
384
  if (req.url !== '/stop' || req.method !== 'POST' || req.headers['content-type'] !== 'application/json') {
358
385
  res.writeHead(405);
359
386
  res.end(JSON.stringify({ error: 'Unsupported local control action.' }));
@@ -13,6 +13,7 @@ const claimWaiters = new Set();
13
13
  const admissionWaiters = new Set();
14
14
  const handles = new Map();
15
15
  const creating = new Set();
16
+ const preparing = new Set();
16
17
  function path() { return join(lifecycleDir(), 'owned-work.json'); }
17
18
  function record(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
18
19
  function identity(value) {
@@ -32,6 +33,8 @@ function reference(value) {
32
33
  return typeof value.runId === 'string' && typeof value.cardId === 'string'
33
34
  && (value.processToken === null || typeof value.processToken === 'string') && typeof value.startedAt === 'string'
34
35
  && (value.resumedAt === null || typeof value.resumedAt === 'string') && ids(value.observedPendingTurnIds)
36
+ && (value.authRecoveryRunId === undefined || value.authRecoveryRunId === null || typeof value.authRecoveryRunId === 'string')
37
+ && (value.harness === undefined || value.harness === null || ['codex', 'claude'].includes(String(value.harness)))
35
38
  && (value.pid === undefined || value.pid === null || Number.isInteger(value.pid) && value.pid > 0);
36
39
  if (value.surface === 'worker')
37
40
  return typeof value.todoId === 'string' && (value.workerId === null || typeof value.workerId === 'string')
@@ -46,7 +49,7 @@ function readStore() {
46
49
  if (!existsSync(path()))
47
50
  return { version: 1, entries: [], legacy: [] };
48
51
  const value = JSON.parse(readFileSync(path(), 'utf8'));
49
- if (!record(value) || (value.legacyBootstrap !== undefined && typeof value.legacyBootstrap !== 'boolean') || (value.rpcClaims !== undefined && (!Array.isArray(value.rpcClaims) || !value.rpcClaims.every((row) => record(row) && typeof row.id === 'string' && ['panel', 'cli'].includes(String(row.surface)) && typeof row.action === 'string' && typeof row.accountId === 'string' && typeof row.machineId === 'string' && typeof row.instanceNonce === 'string'))) || value.version !== 1 || !Array.isArray(value.entries) || !Array.isArray(value.legacy)
52
+ if (!record(value) || (value.mcpCleanup !== undefined && (!Array.isArray(value.mcpCleanup) || !value.mcpCleanup.every(row => record(row) && typeof row.accountId === 'string' && typeof row.sessionId === 'string'))) || (value.legacyBootstrap !== undefined && typeof value.legacyBootstrap !== 'boolean') || (value.rpcClaims !== undefined && (!Array.isArray(value.rpcClaims) || !value.rpcClaims.every((row) => record(row) && typeof row.id === 'string' && ['panel', 'cli'].includes(String(row.surface)) && typeof row.action === 'string' && typeof row.accountId === 'string' && typeof row.machineId === 'string' && typeof row.instanceNonce === 'string' && (row.args === undefined || record(row.args)) && (row.result === undefined || Array.isArray(row.result))))) || value.version !== 1 || !Array.isArray(value.entries) || !Array.isArray(value.legacy)
50
53
  || !value.entries.every((entry) => record(entry) && typeof entry.id === 'string'
51
54
  && typeof entry.accountId === 'string' && typeof entry.machineId === 'string' && typeof entry.instanceNonce === 'string'
52
55
  && (entry.harness === null || typeof entry.harness === 'string') && (entry.reference === null || reference(entry.reference))
@@ -58,6 +61,16 @@ function readStore() {
58
61
  && (entry.gatedCreation === undefined || typeof entry.gatedCreation === 'boolean')
59
62
  && (entry.windowsJob === undefined || typeof entry.windowsJob === 'string')
60
63
  && (entry.rawPid === undefined || Number.isInteger(entry.rawPid) && entry.rawPid > 0)
64
+ && (entry.terminalOutcome === undefined || record(entry.terminalOutcome) && ['panel', 'worker', 'reply'].includes(String(entry.terminalOutcome.surface)) && typeof entry.terminalOutcome.ok === 'boolean' && (entry.terminalOutcome.text === null || typeof entry.terminalOutcome.text === 'string') && typeof entry.terminalOutcome.reason === 'string'
65
+ && (entry.terminalOutcome.failureKind === undefined || ['authentication', 'preparation-unavailable', 'missing-binary', 'timeout', 'usage-limit', 'invalid-model', 'transient', 'unknown'].includes(String(entry.terminalOutcome.failureKind)))
66
+ && (entry.terminalOutcome.completedAt === undefined || typeof entry.terminalOutcome.completedAt === 'number' && Number.isFinite(entry.terminalOutcome.completedAt) && entry.terminalOutcome.completedAt > 0)
67
+ && (entry.terminalOutcome.harnessIdentity === undefined || typeof entry.terminalOutcome.harnessIdentity === 'string')
68
+ && (entry.terminalOutcome.stopped === undefined || typeof entry.terminalOutcome.stopped === 'boolean')
69
+ && (entry.terminalOutcome.workerIsLive === undefined || typeof entry.terminalOutcome.workerIsLive === 'boolean')
70
+ && (entry.terminalOutcome.replyingToClaim === undefined || entry.terminalOutcome.replyingToClaim === null || typeof entry.terminalOutcome.replyingToClaim === 'string')
71
+ && (entry.terminalOutcome.disposition === undefined || record(entry.terminalOutcome.disposition) && ['done', 'died'].includes(String(entry.terminalOutcome.disposition.worker)) && typeof entry.terminalOutcome.disposition.reason === 'string' && ['done', 'needs-input', 'retry', 'failed'].includes(String(entry.terminalOutcome.disposition.todo)))
72
+ && (entry.terminalOutcome.level === undefined || [1, 2, 3].includes(Number(entry.terminalOutcome.level)))
73
+ && (entry.terminalOutcome.workItemId === undefined || entry.terminalOutcome.workItemId === null || typeof entry.terminalOutcome.workItemId === 'string'))
61
74
  && (entry.preparationFailure === undefined || record(entry.preparationFailure)
62
75
  && typeof entry.preparationFailure.reason === 'string' && [1, 2, 3].includes(Number(entry.preparationFailure.level))
63
76
  && record(entry.reference) && entry.reference.surface === 'panel')
@@ -73,7 +86,7 @@ function readStore() {
73
86
  const store = value;
74
87
  const intents = readIntents();
75
88
  for (const entry of store.entries) {
76
- if (!entry.preparationFailure)
89
+ if (!entry.preparationFailure && !entry.terminalOutcome)
77
90
  entry.interruption ??= intents[entry.id] ?? intents[`instance:${entry.instanceNonce}`] ?? null;
78
91
  }
79
92
  return store;
@@ -192,18 +205,19 @@ export function resumeOwnedWorkAdmission() {
192
205
  export function reserveOwnedWork(ref, harness = null, workId) {
193
206
  const owner = requireContext();
194
207
  const store = readStore();
195
- const prior = store.entries.find((row) => row.unstarted && row.instanceNonce === owner.instanceNonce
208
+ const prior = store.entries.find((row) => row.unstarted && !row.spawning && !row.interruption && !preparing.has(row.id) && row.accountId === owner.accountId && row.machineId === owner.machineId
196
209
  && row.reference && (ref ? sameAttempt(row.reference, ref) : workId ? row.reference.surface === 'panel' && row.reference.runId === workId : false));
197
210
  const entry = prior ?? {
198
211
  id: randomUUID(), accountId: owner.accountId, machineId: owner.machineId, instanceNonce: owner.instanceNonce,
199
212
  harness, reference: ref, process: null, descendants: [], state: 'pending', interruption: null,
200
213
  };
201
- entry.unstarted = false;
214
+ preparing.add(entry.id);
215
+ entry.instanceNonce = owner.instanceNonce;
202
216
  if (harness)
203
217
  entry.harness = harness;
204
218
  if (ref) {
205
219
  entry.reference = ref;
206
- store.entries = store.entries.filter((row) => row.id === entry.id || !row.unstarted || !row.reference || !sameAttempt(row.reference, ref));
220
+ store.entries = store.entries.filter((row) => row.id === entry.id || row.accountId !== owner.accountId || row.machineId !== owner.machineId || !row.unstarted || !row.reference || !sameAttempt(row.reference, ref));
207
221
  }
208
222
  if (!prior)
209
223
  store.entries.push(entry);
@@ -241,6 +255,7 @@ export function reserveOwnedWork(ref, harness = null, workId) {
241
255
  throw new Error('Work was interrupted before its harness could start.');
242
256
  }
243
257
  changeEntry(entry.id, (row) => {
258
+ row.unstarted = false;
244
259
  row.spawning = true;
245
260
  row.gatedCreation = true;
246
261
  if (process.platform === 'win32')
@@ -248,11 +263,18 @@ export function reserveOwnedWork(ref, harness = null, workId) {
248
263
  });
249
264
  creating.add(entry.id);
250
265
  },
266
+ deferOutcome: (outcome) => {
267
+ changeEntry(entry.id, row => { if (!row.interruption)
268
+ row.terminalOutcome = outcome; });
269
+ },
270
+ acknowledgeOutcome: () => { if (readStore().entries.some(row => row.id === entry.id))
271
+ changeEntry(entry.id, row => { delete row.terminalOutcome; }); },
251
272
  complete: () => {
273
+ preparing.delete(entry.id);
252
274
  const current = readStore().entries.find((row) => row.id === entry.id);
253
275
  if (current?.preparationFailure && current.state !== 'exited')
254
276
  throw new Error('The unprompted harness must exit before its preparation failure is released.');
255
- if (current && !current.interruption)
277
+ if (current && !current.interruption && !current.terminalOutcome)
256
278
  removeEntry(entry.id);
257
279
  },
258
280
  deferFailure: (reason, level) => {
@@ -268,12 +290,13 @@ export function reserveOwnedWork(ref, harness = null, workId) {
268
290
  },
269
291
  exited: () => confirmOwnedExecutionExited(entry.id),
270
292
  finishPreparation: () => {
293
+ preparing.delete(entry.id);
271
294
  if (registering)
272
295
  return;
273
296
  const current = readStore().entries.find((row) => row.id === entry.id);
274
297
  if (!current)
275
298
  return;
276
- if (current.preparationFailure)
299
+ if (current.preparationFailure || current.terminalOutcome || current.unstarted && current.rpcId && !current.interruption)
277
300
  return;
278
301
  if (current.interruption && current.reference)
279
302
  changeEntry(entry.id, (row) => { row.state = 'exited'; });
@@ -288,14 +311,14 @@ export function reserveOwnedWork(ref, harness = null, workId) {
288
311
  row.reference = value;
289
312
  if (agent)
290
313
  row.harness = agent;
291
- current.entries = current.entries.filter((item) => item.id === entry.id || !item.unstarted || !item.reference || !sameAttempt(item.reference, value));
314
+ current.entries = current.entries.filter((item) => item.id === entry.id || item.accountId !== owner.accountId || item.machineId !== owner.machineId || !item.unstarted || !item.reference || !sameAttempt(item.reference, value));
292
315
  writeStore(current);
293
316
  },
294
317
  abandon: () => {
295
318
  const current = readStore().entries.find((candidate) => candidate.id === entry.id);
296
319
  if (!current)
297
320
  return;
298
- if (current.preparationFailure)
321
+ if (current.preparationFailure || current.terminalOutcome || current.unstarted && current.rpcId && !current.interruption)
299
322
  return;
300
323
  if (current.process)
301
324
  throw new Error('A registered process must be observed exited before its record is released.');
@@ -397,6 +420,14 @@ export async function snapshotOwnedWork(deadline) {
397
420
  const store = readStore();
398
421
  const result = { active: [], pending: [], claimPending: [], unknown: [], receipts: 0, legacyHeldTodoIds: [] };
399
422
  for (const entry of store.entries) {
423
+ if (entry.terminalOutcome) {
424
+ result.receipts++;
425
+ continue;
426
+ }
427
+ if (entry.unstarted && !entry.spawning && !entry.interruption && !entry.preparationFailure && !preparing.has(entry.id)) {
428
+ result.claimPending.push(summary(entry));
429
+ continue;
430
+ }
400
431
  if (entry.preparationFailure) {
401
432
  result.receipts++;
402
433
  if (entry.state === 'exited')
@@ -574,6 +605,29 @@ export function acknowledgeInterruption(id) {
574
605
  }
575
606
  removeEntry(id);
576
607
  }
608
+ export function updateTerminalOutcome(id, outcome) {
609
+ const owner = requireContext();
610
+ const row = readStore().entries.find(entry => entry.id === id && entry.accountId === owner.accountId && entry.machineId === owner.machineId);
611
+ if (!row?.terminalOutcome)
612
+ throw new Error('The completed outcome no longer belongs to this account.');
613
+ changeEntry(id, entry => { entry.terminalOutcome = outcome; });
614
+ }
615
+ export function pendingTerminalOutcomes() {
616
+ const owner = requireContext();
617
+ return readStore().entries.flatMap(entry => entry.accountId === owner.accountId && entry.machineId === owner.machineId
618
+ && entry.reference && entry.terminalOutcome && entry.state === 'exited' && !entry.interruption
619
+ ? [{ id: entry.id, harness: entry.harness, reference: entry.reference, outcome: entry.terminalOutcome }] : []);
620
+ }
621
+ export function acknowledgeTerminalOutcome(id) {
622
+ const owner = requireContext();
623
+ const entry = readStore().entries.find(row => row.id === id);
624
+ if (!entry)
625
+ return;
626
+ if (entry.accountId !== owner.accountId || entry.machineId !== owner.machineId || entry.state !== 'exited' || !entry.terminalOutcome) {
627
+ throw new Error('Outcome acknowledgement does not match this account and ended execution.');
628
+ }
629
+ removeEntry(id);
630
+ }
577
631
  export function failedPanelPreparations() {
578
632
  const owner = requireContext();
579
633
  return readStore().entries.flatMap((entry) => entry.accountId === owner.accountId && entry.machineId === owner.machineId
@@ -703,23 +757,23 @@ function sameAttempt(a, b) {
703
757
  && a.grantLeases.every((source) => b.grantLeases.some((other) => other.id === source.id && other.resume_taken_at === source.resume_taken_at));
704
758
  return false;
705
759
  }
706
- export function beginRpcClaim(surface, action) {
760
+ export function beginRpcClaim(surface, action, args) {
707
761
  if (!ownedWorkAllowed())
708
762
  throw new Error('This machine is not accepting new work.');
709
763
  const owner = requireContext();
710
764
  const store = readStore();
711
765
  const id = randomUUID();
712
766
  liveRpcClaims.add(id);
713
- (store.rpcClaims ??= []).push({ id, surface, action, accountId: owner.accountId, machineId: owner.machineId, instanceNonce: owner.instanceNonce });
767
+ (store.rpcClaims ??= []).push({ id, surface, action, args, accountId: owner.accountId, machineId: owner.machineId, instanceNonce: owner.instanceNonce });
714
768
  writeStore(store);
715
769
  return id;
716
770
  }
717
771
  export function pendingRpcClaims(surface) {
718
772
  const owner = requireContext();
719
- return (readStore().rpcClaims ?? []).filter((row) => row.surface === surface && row.accountId === owner.accountId && row.machineId === owner.machineId && !liveRpcClaims.has(row.id));
773
+ return (readStore().rpcClaims ?? []).filter((row) => row.surface === surface && row.accountId === owner.accountId && row.machineId === owner.machineId && row.result === undefined && !liveRpcClaims.has(row.id));
720
774
  }
721
775
  export function deferRpcClaim(id) { liveRpcClaims.delete(id); }
722
- export function recordRpcClaims(id, refs, interrupted = false) {
776
+ export function recordRpcClaims(id, refs, interrupted = false, result) {
723
777
  const owner = requireContext();
724
778
  interrupted ||= forcedOperationId !== null;
725
779
  const store = readStore();
@@ -730,27 +784,77 @@ export function recordRpcClaims(id, refs, interrupted = false) {
730
784
  return;
731
785
  }
732
786
  for (const ref of refs) {
733
- if (store.entries.some((row) => row.reference && row.accountId === owner.accountId && sameAttempt(row.reference, ref)))
787
+ const prior = store.entries.find(row => row.reference && row.accountId === owner.accountId && sameAttempt(row.reference, ref));
788
+ if (prior) {
789
+ if (prior.unstarted && !prior.spawning && !prior.interruption)
790
+ prior.instanceNonce = owner.instanceNonce;
734
791
  continue;
792
+ }
735
793
  store.entries.push({ id: randomUUID(), accountId: owner.accountId, machineId: owner.machineId,
736
794
  instanceNonce: owner.instanceNonce, harness: null, reference: ref, process: null, descendants: [],
737
795
  state: interrupted ? 'exited' : 'pending', interruption: interrupted ? { operationId: id, interruptedAt: new Date().toISOString() } : null,
738
796
  rpcId: id, unstarted: true });
739
797
  }
740
- store.rpcClaims = store.rpcClaims?.filter((row) => row.id !== id);
798
+ if (result && !interrupted) {
799
+ claim.result = result;
800
+ claim.instanceNonce = owner.instanceNonce;
801
+ }
802
+ else
803
+ store.rpcClaims = store.rpcClaims?.filter((row) => row.id !== id);
804
+ writeStore(store);
805
+ if (!result || interrupted)
806
+ liveRpcClaims.delete(id);
807
+ }
808
+ export function pendingRpcReference(ref) {
809
+ const owner = requireContext();
810
+ return readStore().entries.some(row => row.unstarted && !row.spawning && !row.interruption && !preparing.has(row.id) && row.accountId === owner.accountId && row.machineId === owner.machineId && row.reference && sameAttempt(row.reference, ref));
811
+ }
812
+ export function retireRpcReference(ref) {
813
+ const owner = requireContext(), store = readStore();
814
+ store.entries = store.entries.filter(row => !row.unstarted || row.spawning || row.interruption || row.accountId !== owner.accountId || row.machineId !== owner.machineId || !row.reference || !sameAttempt(row.reference, ref));
741
815
  writeStore(store);
742
- liveRpcClaims.delete(id);
743
816
  }
744
- export function finishRpcClaims(ids) {
817
+ export function recoveredPanelClaims() { return recoveredRpcClaims('panel'); }
818
+ export function recoveredRpcClaims(surface) {
819
+ const owner = requireContext();
820
+ return (readStore().rpcClaims ?? []).filter(row => row.surface === surface && row.result !== undefined && row.accountId === owner.accountId && row.machineId === owner.machineId && !liveRpcClaims.has(row.id));
821
+ }
822
+ export function finishRpcClaims(ids, retainUnstarted = false) {
745
823
  const owner = requireContext();
746
824
  const store = readStore();
747
- store.entries = store.entries.filter((row) => !row.unstarted || !row.rpcId || !ids.includes(row.rpcId)
825
+ for (const id of ids)
826
+ liveRpcClaims.delete(id);
827
+ if (retainUnstarted)
828
+ ids = ids.filter(id => !store.rpcClaims?.some(row => row.id === id && row.result === undefined) && !store.entries.some(row => row.rpcId === id && row.unstarted && !row.interruption && !row.preparationFailure && row.accountId === owner.accountId && row.machineId === owner.machineId));
829
+ store.rpcClaims = store.rpcClaims?.filter(row => !ids.includes(row.id) || row.accountId !== owner.accountId || row.machineId !== owner.machineId);
830
+ for (const id of ids)
831
+ liveRpcClaims.delete(id);
832
+ store.entries = store.entries.filter((row) => !row.unstarted || !row.rpcId || !ids.includes(row.rpcId) || preparing.has(row.id)
748
833
  || row.accountId !== owner.accountId || row.machineId !== owner.machineId || row.interruption || row.preparationFailure);
749
834
  writeStore(store);
750
835
  }
751
836
  export function pendingPanelAttempt(runId) {
752
837
  const owner = requireContext();
753
- const row = readStore().entries.find((entry) => entry.instanceNonce === owner.instanceNonce && entry.state === 'pending'
838
+ const row = readStore().entries.find((entry) => entry.accountId === owner.accountId && entry.machineId === owner.machineId && entry.state === 'pending' && !entry.interruption && !entry.spawning
754
839
  && entry.reference?.surface === 'panel' && entry.reference.runId === runId);
755
840
  return row?.reference?.surface === 'panel' ? row.reference : null;
756
841
  }
842
+ /** Failed cleanup stays with the account that opened the MCP session. */
843
+ export function deferMcpCleanup(rows) {
844
+ if (!rows.length)
845
+ return;
846
+ const store = readStore();
847
+ for (const row of rows) {
848
+ if (!(store.mcpCleanup ??= []).some(existing => existing.accountId === row.accountId && existing.sessionId === row.sessionId))
849
+ store.mcpCleanup.push(row);
850
+ }
851
+ writeStore(store);
852
+ }
853
+ export function pendingMcpCleanup(accountId) {
854
+ return (readStore().mcpCleanup ?? []).filter(row => row.accountId === accountId).map(row => row.sessionId);
855
+ }
856
+ export function acknowledgeMcpCleanup(accountId, sessionIds) {
857
+ const store = readStore();
858
+ store.mcpCleanup = store.mcpCleanup?.filter(row => row.accountId !== accountId || !sessionIds.includes(row.sessionId));
859
+ writeStore(store);
860
+ }
@@ -64,22 +64,8 @@ export function plainFailureReason(agent, error, stderr = '') {
64
64
  || haystack.includes('429') || haystack.includes('overloaded')) {
65
65
  return `${name}'s usage limit is in effect on this account, so the run could not start.`;
66
66
  }
67
- /* SIGNED OUT. Recoverable by the user, and silent otherwise: the run just
68
- fails, over and over, for as long as the session is stale.
69
-
70
- 18a SLICE 7 ADDED THE OAUTH AND 401 SIGNATURES, from the real thing rather
71
- than from imagination. The Windows machine's Claude Code had a stale token
72
- and said, on STDOUT as a stream-json result: `"error":
73
- "authentication_failed"` and `API Error: 401 OAuth access token has
74
- expired.` The word "authentication" above would have matched that, but only
75
- once stdout was being read at all, which is the other half of the fix. `401`
76
- and `oauth` are here so the same failure is caught when it arrives in
77
- shorter words. */
78
- if (haystack.includes('unauthorized') || haystack.includes('not logged in')
79
- || haystack.includes('authentication') || haystack.includes('oauth')
80
- || haystack.includes('401')) {
81
- return `${name} is not signed in on this machine.`;
82
- }
67
+ if (nativeFailureKind(stderr, error) === 'authentication')
68
+ return failureMessage(agent, 'authentication');
83
69
  /* THE RUN WAS KILLED, by the timeout or by a signal from outside. Not the
84
70
  user's Stop, which never reaches here: a stopped run is settled with its own
85
71
  flag well before this. */
@@ -96,3 +82,77 @@ export function plainFailureReason(agent, error, stderr = '') {
96
82
  }
97
83
  return `${name} stopped without finishing.`;
98
84
  }
85
+ /** Accept only native error records and verified diagnostic lines. Assistant
86
+ * prose, a quoted status code and credential-file presence prove nothing. */
87
+ export function nativeFailureKind(output, diagnostics = '') {
88
+ const classify = (value) => {
89
+ if (!value || typeof value !== 'object')
90
+ return 'unknown';
91
+ const record = value;
92
+ const nested = record.error;
93
+ const error = nested && typeof nested === 'object' ? nested : record;
94
+ const code = typeof error.code === 'string' ? error.code : typeof nested === 'string' ? nested : typeof error.type === 'string' ? error.type : '';
95
+ if (['authentication_error', 'authentication_failed', 'invalid_api_key', 'invalid_authentication', 'token_expired', 'refresh_token_expired', 'refresh_token_reused', 'refresh_token_invalidated'].includes(code))
96
+ return 'authentication';
97
+ if (['rate_limit_error', 'rate_limit_exceeded', 'usage_limit_reached', 'insufficient_quota'].includes(code))
98
+ return 'usage-limit';
99
+ if (code === 'invalid_request_error' && record.status === 400 && typeof error.message === 'string' && /^The requested model is not supported for this account\.?$/.test(error.message))
100
+ return 'invalid-model';
101
+ if (['model_not_found', 'unsupported_model', 'unrecognized_model'].includes(code))
102
+ return 'invalid-model';
103
+ if (['overloaded_error', 'server_error', 'temporarily_unavailable', 'connection_error'].includes(code))
104
+ return 'transient';
105
+ if (typeof error.message === 'string') {
106
+ if (error.message === 'Your access token could not be refreshed. Please log out and sign in again.')
107
+ return 'authentication';
108
+ try {
109
+ return classify(JSON.parse(error.message));
110
+ }
111
+ catch { /* Not a nested native record. */ }
112
+ }
113
+ return 'unknown';
114
+ };
115
+ for (const line of output.split('\n')) {
116
+ const text = line.trim();
117
+ try {
118
+ const event = JSON.parse(text);
119
+ // Ignore native assistant messages, tool results and their quoted prose.
120
+ if (['turn.failed', 'error'].includes(event?.type) || (event?.type === 'result' && event.is_error === true)) {
121
+ if (event.type === 'result' && (event.api_error_status === undefined || event.api_error_status === 401)
122
+ && event.result === 'Failed to authenticate. API Error: 401 Invalid bearer token')
123
+ return 'authentication';
124
+ if (event.type === 'result' && event.api_error_status === 401 && typeof event.result === 'string'
125
+ && /^Failed to authenticate\. API Error: 401 OAuth access token (?:has (?:expired|been revoked)|is invalid)\./.test(event.result))
126
+ return 'authentication';
127
+ const kind = classify(event);
128
+ if (kind !== 'unknown')
129
+ return kind;
130
+ }
131
+ }
132
+ catch { /* Native diagnostics may be non-JSON. */ }
133
+ }
134
+ for (const text of diagnostics.split('\n').map(line => line.trim())) {
135
+ if (/^API Error: (529|503) (Overloaded|Service unavailable)$/.test(text))
136
+ return 'transient';
137
+ if (/^API Error: 401 (?:OAuth access token (?:has (?:expired|been revoked)|is invalid)\.|.*"type"\s*:\s*"authentication_error")/.test(text)
138
+ || /^\[claude-code:authentication_failed\]/.test(text)
139
+ || /^ERROR: Your access token could not be refreshed because your refresh token was already used\./.test(text))
140
+ return 'authentication';
141
+ if (/^\[claude-code:unrecognized_model\]/.test(text))
142
+ return 'invalid-model';
143
+ }
144
+ return 'unknown';
145
+ }
146
+ export function failureMessage(agent, kind) {
147
+ const name = agentDisplayName(agent);
148
+ switch (kind) {
149
+ case 'authentication': return `${name} needs sign-in on its assigned computer. Saved work is waiting for you to restart this card.`;
150
+ case 'preparation-unavailable': return `${name}'s local credentials or execution configuration could not be checked.`;
151
+ case 'missing-binary': return `${name} is not installed on this machine, or is not on its PATH.`;
152
+ case 'timeout': return 'The run was stopped for taking too long.';
153
+ case 'usage-limit': return `${name}'s usage limit is in effect on this account.`;
154
+ case 'invalid-model': return `${name} rejected the selected model. Choose an available model and retry.`;
155
+ case 'transient': return `${name}'s service is temporarily unavailable.`;
156
+ default: return `${name} stopped without finishing.`;
157
+ }
158
+ }
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { autostartOn, autostartOff } from './autostart.js';
6
6
  import { detectAgents } from './agents.js';
7
7
  import { getMachineIdentity, clearSession, readSession, machineHostname } from './config.js';
8
8
  import { CLI_VERSION } from './package-version.js';
9
- import { inspectLocalRuntime, runLifecycleCommand } from './daemon-lifecycle.js';
9
+ import { inspectLocalRuntime, runLifecycleCommand, notifySessionChanged } from './daemon-lifecycle.js';
10
10
  import { readMigration, readRuntime } from './daemon-lock.js';
11
11
  import { claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
12
12
  import { panelCommand } from './panel3/cli.js';
@@ -110,9 +110,12 @@ async function main() {
110
110
  throw new Error('Usage: cs ' + cmd + ' [--force]');
111
111
  return runLifecycleCommand(cmd, arg === '--force');
112
112
  case 'status': return status();
113
- case 'logout':
114
- console.log(clearSession() ? 'Signed out.' : 'Was not signed in.');
113
+ case 'logout': {
114
+ const signedIn = await clearSession();
115
+ await notifySessionChanged();
116
+ console.log(signedIn ? 'Signed out.' : 'Was not signed in.');
115
117
  return;
118
+ }
116
119
  /* THE PANEL'S OWN COMMANDS, ROUTED WHOLE. The person types one CLI, so the
117
120
  card commands are `cs` subcommands; the argument handling stays inside
118
121
  `panel3/`, which is the one import this file makes into it (named in
package/dist/login.js CHANGED
@@ -2,8 +2,9 @@ import { createServer } from 'node:http';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
4
4
  import { openBrowser } from './browser.js';
5
- import { writeSession, getMachineIdentity } from './config.js';
6
- import { getClient } from './supabase.js';
5
+ import { getMachineIdentity } from './config.js';
6
+ import { acceptLogin } from './supabase.js';
7
+ import { notifySessionChanged } from './daemon-lifecycle.js';
7
8
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
8
9
  const MAX_BODY_BYTES = 64 * 1024;
9
10
  export async function login() {
@@ -80,15 +81,12 @@ async function handleCallback(req, res, state, finish) {
80
81
  res.end(JSON.stringify({ ok: false, error: 'Invalid or missing state' }));
81
82
  return;
82
83
  }
83
- writeSession({ access_token, refresh_token });
84
84
  try {
85
- const client = await getClient({ refreshing: false });
86
- const { data, error } = await client.auth.getUser(access_token);
87
- if (error || !data.user?.email)
88
- throw new Error(error?.message ?? 'No user for this session.');
85
+ const email = await acceptLogin({ access_token, refresh_token });
86
+ await notifySessionChanged();
89
87
  res.writeHead(200, { 'Content-Type': 'application/json' });
90
88
  res.end(JSON.stringify({ ok: true }));
91
- finish({ ok: true, email: data.user.email });
89
+ finish({ ok: true, email });
92
90
  }
93
91
  catch (err) {
94
92
  const message = `Session could not be verified: ${err.message}`;