@ctrl-spc/cs 0.7.14 → 0.7.15

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.
@@ -123,7 +123,7 @@ import { spawn as spawnChild } from 'node:child_process';
123
123
  import { randomUUID } from 'node:crypto';
124
124
  import { agentPath } from '../agents.js';
125
125
  import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
126
- import { windowsSafeSpawn } from '../win-shell.js';
126
+ import { windowsSafeSpawn, spawnOwnedProcess, releaseOwnedProcess } from '../win-shell.js';
127
127
  import { modelChoiceRules } from './prompt.js';
128
128
  const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
129
129
  /**
@@ -405,11 +405,16 @@ function tail(text, chars = 500) {
405
405
  * process still alive" after the daemon that started it has been killed. So the
406
406
  * caller gets the pid immediately, writes it, and then waits.
407
407
  */
408
- export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}) {
408
+ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}, lifecycle) {
409
+ if (lifecycle?.interrupted())
410
+ return { pid: null, session: Promise.resolve(null),
411
+ answered: Promise.resolve({ ok: false, reason: 'Work was interrupted by the service command.' }),
412
+ interrupted: lifecycle.interrupted, completed: lifecycle.complete };
409
413
  const failed = (reason) => ({
410
414
  pid: null,
411
415
  session: Promise.resolve(null),
412
416
  answered: Promise.resolve({ ok: false, reason }),
417
+ ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}),
413
418
  });
414
419
  let agent;
415
420
  try {
@@ -450,10 +455,12 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
450
455
  const { args, shell } = windowsSafeSpawn(bin, ARGS);
451
456
  let child;
452
457
  try {
453
- child = spawnChild(bin, args, {
458
+ lifecycle?.beforeSpawn();
459
+ const options = {
454
460
  // The repo-wide invariant for ANY child process (MEMORY: "Windows silence
455
461
  // decision"). A user must never see a console flash.
456
462
  windowsHide: true,
463
+ detached: process.platform !== 'win32',
457
464
  shell,
458
465
  stdio: ['pipe', 'pipe', 'pipe'],
459
466
  // NEVER the daemon's inherited cwd, which under a launchd login item is
@@ -465,7 +472,10 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
465
472
  said. Claude is spawned with the environment untouched, exactly as
466
473
  before. */
467
474
  ...(home ? { env: { ...process.env, CODEX_HOME: home } } : {}),
468
- });
475
+ };
476
+ child = lifecycle
477
+ ? spawnOwnedProcess(bin, args, options, lifecycle.id)
478
+ : spawnChild(bin, args, options);
469
479
  }
470
480
  catch (err) {
471
481
  // NOT `err.message`, which names the binary's absolute path. See
@@ -488,6 +498,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
488
498
  else if (agent === 'claude') {
489
499
  observeSession(launchedOwnerSession?.resumeSessionId ?? launchedOwnerSession?.freshSessionId ?? null);
490
500
  }
501
+ let registration = Promise.resolve();
491
502
  const answered = new Promise((resolve) => {
492
503
  let stdout = '';
493
504
  let stderr = '';
@@ -512,11 +523,32 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
512
523
  /* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
513
524
  the primary reclaim and the startup sweep the backstop; a home left
514
525
  behind holds a copy of the user's codex credential. */
515
- if (home && !ownerSession)
516
- removeCodexRunHome(home);
517
- if (!sessionSettled)
518
- observeSession(null);
519
- resolve(answer);
526
+ void (async () => {
527
+ await registration.catch(() => { });
528
+ if (lifecycle && child.pid) {
529
+ // A wrapper's close is not tree exit. Keep the run owned until the
530
+ // actual descendants end; lifecycle commands have their own deadline.
531
+ let warned = false;
532
+ while (true) {
533
+ try {
534
+ await lifecycle.exited();
535
+ break;
536
+ }
537
+ catch (error) {
538
+ if (!warned) {
539
+ console.warn(`Owned agent execution has not ended: ${error.message}`);
540
+ warned = true;
541
+ }
542
+ await new Promise((done) => setTimeout(done, 250));
543
+ }
544
+ }
545
+ }
546
+ if (home && !ownerSession)
547
+ removeCodexRunHome(home);
548
+ if (!sessionSettled)
549
+ observeSession(null);
550
+ resolve(answer);
551
+ })();
520
552
  };
521
553
  child.stdout?.on('data', (d) => {
522
554
  stdout = collect(stdout, String(d));
@@ -582,7 +614,23 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
582
614
  handler the EPIPE is an unhandled stream error and takes the daemon down
583
615
  with it. */
584
616
  child.stdin?.on('error', () => { });
585
- child.stdin?.end(prompt);
617
+ if (lifecycle) {
618
+ registration = lifecycle.register(child, agent);
619
+ void registration.then(() => {
620
+ if (lifecycle.interrupted())
621
+ return;
622
+ releaseOwnedProcess(child);
623
+ child.stdin?.end(prompt);
624
+ }).catch((error) => {
625
+ // No prompt was delivered. The held native handle may be cancelled
626
+ // even when durable ownership registration itself failed.
627
+ child.stdin?.destroy();
628
+ child.kill('SIGKILL');
629
+ finish({ ok: false, reason: `The agent could not be safely registered: ${error.message}` });
630
+ });
631
+ }
632
+ else
633
+ child.stdin?.end(prompt);
586
634
  });
587
- return { pid: child.pid ?? null, session, answered };
635
+ return { pid: child.pid ?? null, session, answered, ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}) };
588
636
  }
package/dist/presence.js CHANGED
@@ -1,17 +1,81 @@
1
1
  import { platform } from 'node:os';
2
- import { getClient } from './supabase.js';
2
+ import { getClient, disposeClient, confirmedSessionRejection, NotLoggedIn } from './supabase.js';
3
3
  import { getMachineIdentity, mcpToken, readSession, supersededMachineIds, clearSupersededMachineIds } from './config.js';
4
4
  import { detectAgents } from './agents.js';
5
5
  import { installSkills } from './skills.js';
6
6
  import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
7
7
  import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS, ORCHESTRATOR_POLL_INTERVAL_MS } from './env.js';
8
8
  import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
9
- import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
9
+ import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, reconcileInterruptedWork, refreshLegacyTodoHolds, takeRunMessages, liveAgents, } from './orchestrator.js';
10
10
  /* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
11
11
  import { sweepStrandedCodexHomes } from './codex-home.js';
12
- import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
12
+ import { hasOwnedWorkContext, ownedWorkAllowed, beginOwnedClaim, suspendOwnedWorkAdmission, resumeOwnedWorkAdmission, setOwnedWorkAccount, reserveOwnedWork, interruptionReceipts, acknowledgeInterruption, failedPanelPreparations, acknowledgePreparationFailure, legacyPanelSnapshotNeeded, completeLegacyPanelSnapshot, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims, pendingPanelAttempt, ownedPanelAttemptHeld, ownedLocalPanelOwnerHeld, stopOwnedPanelAttempt } from './daemon-processes.js';
13
13
  import { startPanel } from './panel3/run.js';
14
14
  let presence = null;
15
+ let cloudState = 'connecting';
16
+ let suspended = false;
17
+ let presenceGeneration = 0;
18
+ export function presenceCloudState() { return cloudState; }
19
+ export async function suspendPresenceWork(options = {}) {
20
+ suspended = true;
21
+ await suspendOwnedWorkAdmission(options);
22
+ }
23
+ export function resumePresenceWork() { suspended = false; resumeOwnedWorkAdmission(); }
24
+ function mayClaim() { return !suspended && cloudState === 'online' && ownedWorkAllowed(); }
25
+ function panelWorkLifecycle(accountId) {
26
+ const allowed = () => mayClaim() && presence?.userId === accountId;
27
+ return {
28
+ allowed,
29
+ beginClaim: () => {
30
+ if (!allowed())
31
+ throw new Error('This machine is not accepting new work.');
32
+ return beginOwnedClaim();
33
+ },
34
+ prepare: (runId) => {
35
+ if (presence?.userId !== accountId)
36
+ throw new Error('This execution belongs to the previous signed-in account.');
37
+ const reservation = reserveOwnedWork(null, null, runId);
38
+ return {
39
+ setAttempt: (attempt) => reservation.setReference({ surface: 'panel', ...attempt }),
40
+ deferFailure: reservation.deferFailure,
41
+ execution: {
42
+ id: reservation.id,
43
+ register: async (child, harness) => {
44
+ await reservation.register(child, harness);
45
+ },
46
+ interrupted: reservation.interrupted,
47
+ exited: reservation.exited,
48
+ complete: reservation.complete,
49
+ beforeSpawn: reservation.beforeSpawn,
50
+ waitForPromptAdmission: reservation.waitForPromptAdmission,
51
+ },
52
+ finish: reservation.finishPreparation,
53
+ };
54
+ },
55
+ claimAttempt: pendingPanelAttempt,
56
+ heldLocalOwner: ownedLocalPanelOwnerHeld,
57
+ heldAttempt: (attempt) => presence?.userId !== accountId || ownedPanelAttemptHeld(attempt),
58
+ stopHeldAttempt: async (attempt) => {
59
+ if (presence?.userId !== accountId)
60
+ throw new Error('This execution belongs to the previous signed-in account.');
61
+ await stopOwnedPanelAttempt(attempt);
62
+ },
63
+ beginRpc: (action) => beginRpcClaim('panel', action),
64
+ deferRpc: deferRpcClaim,
65
+ pendingRpcs: () => pendingRpcClaims('panel'),
66
+ recordRpc: (id, attempts, interrupted) => recordRpcClaims(id, attempts.map((attempt) => ({ surface: 'panel', ...attempt })), interrupted),
67
+ finishRpcs: finishRpcClaims,
68
+ failedPreparations: failedPanelPreparations,
69
+ acknowledgePreparationFailure,
70
+ receipts: () => interruptionReceipts('panel').flatMap((receipt) => receipt.reference.surface === 'panel'
71
+ ? [{ id: receipt.id, operationId: receipt.operationId, interruptedAt: receipt.interruptedAt,
72
+ pid: receipt.process?.pid ?? null, attempt: receipt.reference }] : []),
73
+ acknowledge: acknowledgeInterruption,
74
+ legacyRequired: legacyPanelSnapshotNeeded,
75
+ recordLegacy: (attempt) => recordExitedInterruption({ surface: 'panel', ...attempt }),
76
+ legacyComplete: completeLegacyPanelSnapshot,
77
+ };
78
+ }
15
79
  /** In-flight guard: startPresence yields to the event loop (network setSession)
16
80
  * before `presence` is assigned, so a plain `if (presence)` check lets two
17
81
  * concurrent callers both build interval pairs — the first pair then leaks and
@@ -97,12 +161,70 @@ export function bindSignedOutWatcher(client) {
97
161
  if (event !== 'SIGNED_OUT')
98
162
  return;
99
163
  console.error('Session expired and could not be refreshed. Run `cs login` to sign in again.');
100
- void stopPresence();
164
+ cloudState = 'sign-in-required';
165
+ void suspendOwnedWorkAdmission();
166
+ void stopToolsServer().catch((error) => console.warn(`Agent tools could not be closed: ${String(error)}`));
101
167
  });
102
168
  return data.subscription;
103
169
  }
170
+ /** A new sign-in can succeed before recovery or local tools do. Retry that
171
+ * unfinished readiness on this same client; never rebuild a refresh writer just
172
+ * because a recovery RPC was temporarily unavailable. */
173
+ async function restorePresenceWork(p) {
174
+ const { data, error } = await p.client.auth.getUser();
175
+ if (error)
176
+ throw error;
177
+ const currentUserId = data.user?.id;
178
+ if (!currentUserId)
179
+ throw new NotLoggedIn();
180
+ if (presence !== p)
181
+ throw new Error('Cloud recovery was cancelled by the service command.');
182
+ if (currentUserId !== p.userId) {
183
+ console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
184
+ await p.panel?.stop();
185
+ p.userId = currentUserId;
186
+ p.panel = null;
187
+ }
188
+ if (hasOwnedWorkContext())
189
+ setOwnedWorkAccount(currentUserId);
190
+ await reconcileInterruptedWork(p.client, p.identity.id);
191
+ if (presence !== p)
192
+ throw new Error('Cloud recovery was cancelled by the service command.');
193
+ if (!p.panel)
194
+ p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
195
+ try {
196
+ await p.panel.ready;
197
+ }
198
+ catch (error) {
199
+ await p.panel.stop();
200
+ p.panel = null;
201
+ throw error;
202
+ }
203
+ await startToolsServer({ client: p.client, userId: p.userId, machineId: p.identity.id });
204
+ if (presence !== p)
205
+ throw new Error('Cloud recovery was cancelled by the service command.');
206
+ p.readinessPending = false;
207
+ cloudState = 'online';
208
+ if (!suspended)
209
+ resumeOwnedWorkAdmission();
210
+ }
104
211
  async function heartbeat(p) {
212
+ if (p.heartbeating)
213
+ return;
214
+ p.heartbeating = true;
105
215
  try {
216
+ await heartbeatInner(p);
217
+ }
218
+ finally {
219
+ p.heartbeating = false;
220
+ }
221
+ }
222
+ async function heartbeatInner(p) {
223
+ if (cloudState === 'sign-in-required' && !shouldRebuildClient(p.builtFromRefreshToken, readSession()?.refresh_token))
224
+ return;
225
+ try {
226
+ if (p.readinessPending)
227
+ await restorePresenceWork(p);
106
228
  const { error } = await p.client
107
229
  .from('cliv2_agents')
108
230
  .upsert(buildPresenceHeartbeatPayload({
@@ -114,8 +236,10 @@ async function heartbeat(p) {
114
236
  }), { onConflict: 'user_id,machine_id' });
115
237
  if (error)
116
238
  throw error;
239
+ cloudState = 'online';
117
240
  }
118
241
  catch (err) {
242
+ cloudState = confirmedSessionRejection(err) ? 'sign-in-required' : 'offline';
119
243
  // A failure here used to rebuild the client from disk unconditionally, and
120
244
  // that was the production incident: `getClient` -> `setSession` decodes
121
245
  // whatever refresh token is on disk and hands it to auth-js, which refreshes
@@ -139,8 +263,14 @@ async function heartbeat(p) {
139
263
  // rebuild (proposed twice already in earlier drafts of this fix) would make
140
264
  // the account-reconciliation below unreachable and a concurrent `cs login`
141
265
  // unrecoverable without restarting the daemon.
142
- const rebuilt = await getClient();
266
+ await suspendOwnedWorkAdmission();
267
+ p.readinessPending = true;
143
268
  p.authSubscription.unsubscribe();
269
+ // Tool transports close over their startup client/account. Replacing
270
+ // only the session heartbeat client would leave new work on old auth.
271
+ await stopToolsServer();
272
+ await disposeClient(p.client);
273
+ const rebuilt = await getClient();
144
274
  p.authSubscription = bindSignedOutWatcher(rebuilt);
145
275
  p.client = rebuilt;
146
276
  // shouldRebuildClient already ruled out onDisk being null/undefined to
@@ -181,15 +311,12 @@ async function heartbeat(p) {
181
311
  rebuilt client rather than held against it. Warned rather than silent:
182
312
  a daemon that starts heartbeating as a different user has had the
183
313
  machine change owner underneath it, and that is worth a line. */
184
- const { data } = await p.client.auth.getUser();
185
- const currentUserId = data.user?.id;
186
- if (currentUserId && currentUserId !== p.userId) {
187
- console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
188
- p.userId = currentUserId;
189
- }
314
+ await restorePresenceWork(p);
190
315
  }
191
316
  }
192
- catch { /* stay down until the next tick */ }
317
+ catch (error) {
318
+ cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
319
+ }
193
320
  }
194
321
  // Re-attempt agent registration each heartbeat for any detected agent still
195
322
  // idle or failed, while the tools server is up. Registration otherwise fires
@@ -248,6 +375,8 @@ async function cleanupSupersededRows(p) {
248
375
  }
249
376
  }
250
377
  async function pollCommands(p) {
378
+ if (cloudState !== 'online')
379
+ return;
251
380
  if (p.pollingCommands)
252
381
  return;
253
382
  p.pollingCommands = true;
@@ -314,6 +443,8 @@ async function pollCommands(p) {
314
443
  * daemon. Same reasoning as the heartbeat's per-tick `detectAgents()`.
315
444
  */
316
445
  async function pollOrchestrator(p) {
446
+ if (!mayClaim())
447
+ return;
317
448
  /* !Cleanup Phase 0 (I5c) — WHICH CTRL+SPC SERVER THE WORKER MAY REACH.
318
449
  Read here, per tick, for the same reason `liveAgents()` is: the tools server
319
450
  can come up after the first tick, and a snapshot taken at `startPresence`
@@ -343,13 +474,16 @@ export async function startPresence() {
343
474
  }
344
475
  if (starting)
345
476
  return starting;
477
+ const generation = presenceGeneration;
346
478
  starting = (async () => {
347
- const lock = claimDaemonLock();
348
- if (lock.held)
349
- throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
479
+ cloudState = 'connecting';
350
480
  const identity = getMachineIdentity();
351
481
  const agents = detectAgents();
352
482
  const client = await getClient();
483
+ if (generation !== presenceGeneration) {
484
+ await disposeClient(client);
485
+ throw new Error('Cloud startup was cancelled by the service command.');
486
+ }
353
487
  /* ═══ A DEAD SESSION STOPS THE LOOPS. IT DOES NOT SLOW THEM DOWN. ═══
354
488
  The refresh token can die while the daemon runs (a re-login elsewhere
355
489
  rotates it out from under this process), and until this existed nothing
@@ -374,10 +508,20 @@ export async function startPresence() {
374
508
  binding and the heartbeat's post-rebuild rebind are the same code and
375
509
  can never drift out of sync with each other. */
376
510
  const authSubscription = bindSignedOutWatcher(client);
377
- const { data } = await client.auth.getUser();
511
+ const { data, error: identityError } = await client.auth.getUser();
378
512
  const userId = data.user?.id;
379
- if (!userId)
380
- throw new Error('Signed-in user could not be resolved. Sign in again.');
513
+ if (identityError || !userId) {
514
+ authSubscription.unsubscribe();
515
+ await disposeClient(client);
516
+ throw identityError ?? new NotLoggedIn();
517
+ }
518
+ if (generation !== presenceGeneration) {
519
+ authSubscription.unsubscribe();
520
+ await disposeClient(client);
521
+ throw new Error('Cloud startup was cancelled by the service command.');
522
+ }
523
+ if (hasOwnedWorkContext())
524
+ setOwnedWorkAccount(userId);
381
525
  const p = {
382
526
  client,
383
527
  panel: null,
@@ -404,6 +548,14 @@ export async function startPresence() {
404
548
  self-heals, and the worker's unique index would refuse the retry. Scoped
405
549
  to this machine's own id: if this daemon is starting, nothing it started
406
550
  is still running. */
551
+ await reconcileInterruptedWork(p.client, identity.id);
552
+ if (presence !== p)
553
+ throw new Error('Cloud startup was cancelled by the service command.');
554
+ if (!p.panel)
555
+ p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
556
+ await p.panel.ready;
557
+ if (presence !== p)
558
+ throw new Error('Cloud startup was cancelled by the service command.');
407
559
  await recoverStrandedWorkers(p.client, identity.id);
408
560
  /* 18c SLICE 8 — AND RECLAIM WHAT A PREVIOUS LIFE LEFT ON DISK. A codex worker
409
561
  runs against a per-run `$CODEX_HOME` seeded with a COPY OF THE USER'S
@@ -429,6 +581,8 @@ export async function startPresence() {
429
581
  argument, including why an age-based scope was measured and rejected, is
430
582
  in `sweepStrandedCodexHomes`. Do not re-widen this to the root. */
431
583
  sweepStrandedCodexHomes();
584
+ if (presence !== p)
585
+ throw new Error('Cloud startup was cancelled by the service command.');
432
586
  p.hb = setInterval(() => {
433
587
  /* ═══ 18k SLICE 7 — TAKE THE MESSAGES, AWAITED AFTER THE HEARTBEAT. ═══
434
588
 
@@ -463,9 +617,13 @@ export async function startPresence() {
463
617
  leave every answer for the rest of the session with no tools. Null
464
618
  while it is down, and `takeRunMessages` then answers nothing rather
465
619
  than starting an agent that could read none of the work. */
620
+ if (!mayClaim())
621
+ return;
622
+ await reconcileInterruptedWork(p.client, p.identity.id);
623
+ await refreshLegacyTodoHolds(p.client, p.identity.id);
466
624
  const server = toolsServerStatus();
467
625
  void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null);
468
- })();
626
+ })().catch((error) => { cloudState = confirmedSessionRejection(error) ? 'sign-in-required' : 'offline'; console.warn(`Cloud work is paused: ${String(error)}`); });
469
627
  /* !Cleanup Phase 6b (I43) — THE LIVENESS WATCH, on the heartbeat rather
470
628
  than the orchestrator tick.
471
629
 
@@ -477,7 +635,8 @@ export async function startPresence() {
477
635
  Scoped to this machine's own workers and gated on BOTH a stale
478
636
  `updated_at` and a genuinely dead process, so an agent inside a long
479
637
  tool call is never reaped for being quiet. */
480
- void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
638
+ if (mayClaim())
639
+ void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
481
640
  }, HEARTBEAT_INTERVAL_MS);
482
641
  p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
483
642
  p.ot = setInterval(() => void pollOrchestrator(p), ORCHESTRATOR_POLL_INTERVAL_MS);
@@ -495,7 +654,7 @@ export async function startPresence() {
495
654
  // best-effort: a tools-server or registration failure must never break the
496
655
  // presence heartbeat above, so it's caught and warned like everything here.
497
656
  try {
498
- await startToolsServer({ client, userId, machineId: identity.id });
657
+ await startToolsServer({ client: p.client, userId: p.userId, machineId: identity.id });
499
658
  /* ═══ AND THE TOOLS SERVER IS POINTED AT WHATEVER CLIENT IS LIVE BY NOW,
500
659
  WHICH IS NOT NECESSARILY THE ONE IT WAS JUST STARTED WITH. ═══
501
660
  `startToolsServer` sets `toolsClient = deps.client`, and `deps.client`
@@ -528,7 +687,6 @@ export async function startPresence() {
528
687
  }
529
688
  // Both cs open and cs start answer cards through this one lifecycle.
530
689
  // Read the owner's client on every use, including after refresh or logout.
531
- p.panel = startPanel(() => p.client);
532
690
  return { machineName: identity.name, agents };
533
691
  })();
534
692
  try {
@@ -536,7 +694,7 @@ export async function startPresence() {
536
694
  }
537
695
  catch (error) {
538
696
  await stopPresence();
539
- releaseDaemonLock();
697
+ cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
540
698
  throw error;
541
699
  }
542
700
  finally {
@@ -561,6 +719,7 @@ export async function startPresence() {
561
719
  export async function stopPresence({ unregister = false } = {}) {
562
720
  if (stopping)
563
721
  return stopping;
722
+ presenceGeneration++;
564
723
  const p = presence;
565
724
  if (!p)
566
725
  return;
@@ -588,6 +747,7 @@ export async function stopPresence({ unregister = false } = {}) {
588
747
  session, one that is signed in and healthy. */
589
748
  p.authSubscription.unsubscribe();
590
749
  await p.panel?.stop();
750
+ await disposeClient(p.client);
591
751
  // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
592
752
  // config so a logged-out machine leaves no dead server that would read "failed
593
753
  // to connect" on the next agent run. Fire-and-forget best-effort — never blocks
@@ -614,6 +774,5 @@ export async function stopPresence({ unregister = false } = {}) {
614
774
  }
615
775
  finally {
616
776
  stopping = null;
617
- releaseDaemonLock();
618
777
  }
619
778
  }
package/dist/supabase.js CHANGED
@@ -7,6 +7,23 @@ export class NotLoggedIn extends Error {
7
7
  this.name = 'NotLoggedIn';
8
8
  }
9
9
  }
10
+ /** A rejected credential is different from a request that never reached Auth. */
11
+ export function confirmedSessionRejection(error) {
12
+ if (!error || typeof error !== 'object')
13
+ return false;
14
+ const value = error;
15
+ return value.status === 401 || [
16
+ 'refresh_token_not_found', 'refresh_token_already_used', 'session_not_found',
17
+ 'session_expired', 'bad_jwt', 'user_not_found', 'invalid_credentials',
18
+ ].includes(value.code ?? '');
19
+ }
20
+ const sessionWriters = new WeakMap();
21
+ /** Dispose the sole refresh writer before replacing it or abandoning startup. */
22
+ export async function disposeClient(client) {
23
+ sessionWriters.get(client)?.unsubscribe();
24
+ sessionWriters.delete(client);
25
+ await client.auth.stopAutoRefresh();
26
+ }
10
27
  /**
11
28
  * Sign in with email + password and persist the session to disk (shared with
12
29
  * the terminal CLI via session.json). Used by the companion GUI's sign-in form,
@@ -34,12 +51,17 @@ export async function signIn(email, password) {
34
51
  */
35
52
  const RETRY_DELAYS_MS = [250, 1000, 3000];
36
53
  const retryingFetch = async (input, init) => {
54
+ const supplied = init?.signal ?? (input instanceof Request ? input.signal : undefined);
55
+ const deadline = AbortSignal.timeout(20_000);
56
+ const signal = supplied ? AbortSignal.any([supplied, deadline]) : deadline;
37
57
  let lastErr;
38
58
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
39
59
  try {
40
- return await fetch(input, init);
60
+ return await fetch(input, { ...init, signal });
41
61
  }
42
62
  catch (err) {
63
+ if (signal.aborted)
64
+ throw err;
43
65
  if (!(err instanceof TypeError))
44
66
  throw err;
45
67
  lastErr = err;
@@ -94,7 +116,7 @@ export async function getClient({ refreshing = true } = {}) {
94
116
  auth: { persistSession: false, autoRefreshToken: true },
95
117
  global: { fetch: retryingFetch },
96
118
  });
97
- client.auth.onAuthStateChange((_event, session) => {
119
+ const writer = client.auth.onAuthStateChange((_event, session) => {
98
120
  // Update-only: a token refresh must never recreate session.json after
99
121
  // `cs logout` deleted it, or the logged-out machine would re-authorize
100
122
  // itself up to an hour later.
@@ -102,14 +124,26 @@ export async function getClient({ refreshing = true } = {}) {
102
124
  writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
103
125
  }
104
126
  });
105
- const { data, error } = await client.auth.setSession({
106
- access_token: stored.access_token,
107
- refresh_token: stored.refresh_token,
108
- });
109
- if (error || !data.session) {
110
- throw new NotLoggedIn(`Stored session is invalid or expired (${error?.message ?? 'no session'}). Run \`cs login\` again.`);
127
+ sessionWriters.set(client, writer.data.subscription);
128
+ try {
129
+ const { data, error } = await client.auth.setSession({
130
+ access_token: stored.access_token,
131
+ refresh_token: stored.refresh_token,
132
+ });
133
+ if (error) {
134
+ if (confirmedSessionRejection(error)) {
135
+ throw new NotLoggedIn(`Stored sign-in was rejected (${error.message}). Run \`cs login\` again.`);
136
+ }
137
+ throw new Error(`Cloud sign-in could not be checked: ${error.message}`, { cause: error });
138
+ }
139
+ if (!data.session)
140
+ throw new Error('Cloud sign-in did not return a session. Try again when the connection is available.');
141
+ return client;
142
+ }
143
+ catch (error) {
144
+ await disposeClient(client);
145
+ throw error;
111
146
  }
112
- return client;
113
147
  }
114
148
  /** Whether a stored access token's own `exp` claim has already passed. A token
115
149
  * this process cannot parse is treated as unusable rather than trusted: the