@ctrl-spc/cs 0.7.14 → 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.
@@ -286,9 +286,44 @@ async function api(path, opts) {
286
286
  }
287
287
 
288
288
  async function boot() {
289
- const r = await api('/api/session');
290
- if (r.data && r.data.signedIn) renderHome(r.data);
291
- else renderSignIn(r.data || {});
289
+ try {
290
+ const r = await api('/api/session');
291
+ if (!r.ok || r.data.signedIn === null || r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online')) {
292
+ renderSessionUnavailable(r.data.sessionError || r.data.error, r.data);
293
+ } else if (r.data.signedIn) renderHome(r.data);
294
+ else renderSignIn(r.data || {});
295
+ } catch (_) { renderSessionUnavailable(); }
296
+ }
297
+
298
+ function renderSessionUnavailable(message, session) {
299
+ session = session || {};
300
+ stopBadgePoll();
301
+ app.replaceChildren();
302
+ const wrap = el('div', 'auth');
303
+ const card = el('div', 'auth-card');
304
+ card.append(logo(), el('h1', 'auth-title', session.sessionRenewing ? 'Renewing session' : 'Connection unavailable'));
305
+ const status = el('p', 'auth-sub', session.sessionRenewing ? 'Your saved sign-in is being renewed. Your work remains saved.' : 'Your saved sign-in is preserved. Check the connection or start the local service.');
306
+ status.setAttribute('role', 'status'); card.append(status);
307
+ card.append(el('p', 'auth-sub', message || 'Sign-in could not be checked. Check your connection and try again.'));
308
+ const retry = el('button', 'btn btn-primary', 'Try again');
309
+ retry.addEventListener('click', boot);
310
+ card.append(retry);
311
+ if (session.connectionState === 'unknown') {
312
+ const start = el('button', 'btn', 'Start');
313
+ start.addEventListener('click', async function () {
314
+ if (start.disabled) return;
315
+ start.disabled = true; start.textContent = 'Starting…';
316
+ try { const result = await api('/api/start', {method:'POST'}); if (!result.ok) throw new Error(result.data.error || 'The local service could not start.'); await boot(); }
317
+ catch (error) { status.textContent = error.message || 'The local service could not start. Try again.'; start.disabled = false; start.textContent = 'Start'; }
318
+ });
319
+ card.append(start);
320
+ }
321
+ wrap.append(card);
322
+ app.append(wrap);
323
+ badgePollTimer = setInterval(function () { void api('/api/session').then(function (r) {
324
+ if (!card.isConnected || !r.ok || r.data.signedIn === null || r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online')) return;
325
+ if (r.data.signedIn) renderHome(r.data); else renderSignIn(r.data);
326
+ }).catch(function () {}); }, 3000);
292
327
  }
293
328
 
294
329
  /** The passive "Agent tools" badge (feature 05). Read-only: no button, no
@@ -308,6 +343,9 @@ function agentToolsBadge(state) {
308
343
  if (reason === 'connected') {
309
344
  title = 'Agent tools: connected \\u2713';
310
345
  sub = (state.agent || 'Claude') + ' \\u00b7 ' + (state.server || 'ctrl-spc');
346
+ } else if (reason === 'unavailable') {
347
+ title = 'Connection unavailable';
348
+ sub = state.message || 'Sign-in could not be checked. Retrying.';
311
349
  } else if (reason === 'connecting') {
312
350
  title = 'Agent tools: connecting\\u2026';
313
351
  sub = state.agent || '';
@@ -348,13 +386,21 @@ async function pollBadgeOnce() {
348
386
  const r = await api('/api/session');
349
387
  // Home may have been torn down while the fetch was in flight.
350
388
  if (!badge.isConnected) return;
351
- if (!(r.data && r.data.signedIn)) { stopBadgePoll(); return; }
352
- const fresh = agentToolsBadge(r.data.agentTools);
389
+ if (r.ok && r.data.signedIn === false) { renderSignIn(r.data); return; }
390
+ if (r.ok && (r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online'))) { renderSessionUnavailable(r.data.sessionError, r.data); return; }
391
+ const fresh = agentToolsBadge(!r.ok || r.data.signedIn === null
392
+ ? { reason: 'unavailable', message: r.data.sessionError }
393
+ : r.data.agentTools);
353
394
  fresh.id = 'agent-tools';
354
395
  fresh.style.marginBottom = 'var(--sp-5)';
355
396
  badge.replaceWith(fresh);
356
397
  } catch (e) {
357
- /* transient — try again next tick */
398
+ if (badge.isConnected) {
399
+ const fresh = agentToolsBadge({ reason: 'unavailable' });
400
+ fresh.id = 'agent-tools';
401
+ fresh.style.marginBottom = 'var(--sp-5)';
402
+ badge.replaceWith(fresh);
403
+ }
358
404
  } finally {
359
405
  badgePolling = false;
360
406
  }
@@ -370,6 +416,8 @@ function renderSignIn(session) {
370
416
  app.replaceChildren();
371
417
  const wrap = el('div', 'auth');
372
418
  const card = el('div', 'auth-card');
419
+ if (session.sessionState === 'signed-out') { const status = el('p', 'auth-sub', 'Signed out'); status.setAttribute('role', 'status'); card.append(status); }
420
+ if (session.sessionState === 'rejected') { const warning = el('p', 'auth-sub', 'CTRL+SPC sign-in was rejected. Sign in here to reconnect. Provider sign-in and stopped assignments remain separate.'); warning.setAttribute('role', 'status'); card.append(warning); }
373
421
  card.append(logo());
374
422
  card.append(el('h1', 'auth-title', 'Sign in'));
375
423
  card.append(el('p', 'auth-sub', 'The board your agents report to. Sign in to link this computer to your account.'));
package/dist/companion.js CHANGED
@@ -1,72 +1,31 @@
1
+ import { isAuthSessionMissingError } from '@supabase/supabase-js';
1
2
  import { createServer } from 'node:http';
2
3
  import { timingSafeEqual } from 'node:crypto';
3
4
  import { COMPANION_PORT } from './env.js';
4
5
  import { openBrowser } from './browser.js';
5
- import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
6
- import { getClient, signIn, NotLoggedIn } from './supabase.js';
7
- import { startPresence, stopPresence, liveClient, isPresenceRunning } from './presence.js';
6
+ import { companionToken, getMachineIdentity, readSession, readSessionRecord, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
7
+ import { getClient, signIn, NotLoggedIn, confirmedSessionRejection } from './supabase.js';
8
+ import { liveClient } from './presence.js';
9
+ import { startLocalRuntime, localRuntimeOwned, stopLocalOwnerForSignal, inspectLocalRuntime, checkLegacyUpgrade, notifySessionChanged } from './daemon-lifecycle.js';
10
+ import { CLI_VERSION } from './package-version.js';
8
11
  import { detectAgents } from './agents.js';
9
- import { agentToolsBadgeState, foreignToolsServerAlive, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
12
+ import { agentToolsBadgeState, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
10
13
  import { loadProjects, saveMapping } from './projects.js';
11
14
  import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
12
15
  import { hostedRemoteIdentity } from './git-remote.js';
13
16
  import { chooseFolder, detectGitRemote } from './folders.js';
14
17
  import { renderCompanionUi } from './companion-ui.js';
15
18
  const MAX_BODY_BYTES = 64 * 1024;
16
- const VERSION = '0.1.0';
17
- /** Whether THIS process is the one refreshing the machine's token. False once
18
- * the companion has found a daemon already serving and deferred to it, so
19
- * `requestClient`'s fallback stops building a rival refresher. Starts true
20
- * because a companion with no daemon beside it is the common case (bare `cs`
21
- * is `openCompanion()`, and `cs open` never installs autostart). */
22
- let companionRefreshing = true;
23
- /**
24
- * Resolves once `companionRefreshing` holds this boot's real decision.
25
- *
26
- * The server starts listening before that decision is taken: the probe inside
27
- * `startPresenceUnlessDaemonServes` costs up to a second, and the
28
- * `startPresence()` behind it costs seconds more. A browser tab left open by a
29
- * previous `cs open` polls `GET /api/session` every two seconds, so without
30
- * this it can be served during that window, while `companionRefreshing` is
31
- * still its `true` initializer, by a refreshing client on a machine where a
32
- * daemon is already rotating the token. That is the very collision the
33
- * deferral exists to prevent, so no authenticated request is served until the
34
- * decision is in.
35
- *
36
- * It is only ever a decision barrier. A failure to start presence is already
37
- * handled by the `onError` callback and must not become a permanently wedged
38
- * server, so the assignment below settles this promise on every path.
39
- */
19
+ const VERSION = CLI_VERSION;
40
20
  let refreshDecided = Promise.resolve();
21
+ let closeCompanion;
41
22
  function url(token = companionToken()) {
42
- return `http://127.0.0.1:${COMPANION_PORT}/?token=${encodeURIComponent(token)}`;
23
+ return 'http://127.0.0.1:' + COMPANION_PORT + '/?token=' + encodeURIComponent(token);
43
24
  }
44
- /**
45
- * Come online, unless a daemon on this machine already has.
46
- *
47
- * `foreignToolsServerAlive()` probes the fixed tools-server port over loopback
48
- * and does NOT exclude the calling process, so this must be asked before
49
- * anything in this process could bind that port. Both call sites (boot and
50
- * `POST /api/login`) ask exactly once, at a moment where only another `cs` could
51
- * be answering.
52
- *
53
- * When one is: presence, the tools server, agent registration, the orchestrator
54
- * and the recovery sweeps are all the daemon's job, and running a second copy
55
- * here duplicates them on the one rotating refresh token this machine has.
56
- *
57
- * `companionRefreshing` is assigned on BOTH branches rather than left at its
58
- * initializer, because login runs this again: a companion that deferred to a
59
- * daemon and later signs in with that daemon gone must stop reading as
60
- * non-refreshing.
61
- */
25
+ /** Only explicit open/login/start intent starts the shared local owner. */
62
26
  async function startPresenceUnlessDaemonServes(onError) {
63
- if (await foreignToolsServerAlive()) {
64
- companionRefreshing = false;
65
- return;
66
- }
67
- companionRefreshing = true;
68
27
  try {
69
- await startPresence();
28
+ await startLocalRuntime({ onStop: closeCompanion });
70
29
  }
71
30
  catch (err) {
72
31
  onError(err);
@@ -90,8 +49,7 @@ async function isOurCompanion(token) {
90
49
  }
91
50
  }
92
51
  /**
93
- * `cs open`: opens the Companion GUI. If a companion is already resident, just
94
- * points the browser at it; otherwise becomes the resident server itself.
52
+ * `cs open` explicitly starts the shared service and opens the resident GUI.
95
53
  */
96
54
  export async function openCompanion() {
97
55
  if (!readSession())
@@ -134,6 +92,13 @@ export async function serveCompanion({ open = false } = {}) {
134
92
  // the token via the browser) once a health probe confirms it — otherwise
135
93
  // some unrelated process holds the port and must not receive the token.
136
94
  if (await isOurCompanion(token)) {
95
+ if (!await inspectLocalRuntime())
96
+ await checkLegacyUpgrade();
97
+ const start = await fetch('http://127.0.0.1:' + COMPANION_PORT + '/api/start', { method: 'POST', headers: { 'x-ctrl-spc-token': token }, signal: AbortSignal.timeout(30000) });
98
+ if (!start.ok) {
99
+ const result = await start.json().catch(() => null);
100
+ throw new Error(result?.error ?? 'The resident Companion cannot start this service. Run cs restart on this computer, then cs open.');
101
+ }
137
102
  console.log(`Companion already running — ${url(token)}`);
138
103
  if (open)
139
104
  openBrowser(url(token));
@@ -147,15 +112,16 @@ export async function serveCompanion({ open = false } = {}) {
147
112
  }
148
113
  throw err;
149
114
  }
150
- // Come online immediately if already signed in (terminal `cs login` or a
151
- // prior GUI sign-in). Not signed in is fine — the GUI shows the sign-in screen.
115
+ closeCompanion = () => new Promise((resolve, reject) => {
116
+ server.close((error) => error ? reject(error) : resolve());
117
+ // An attached browser may still be awaiting a status request during a
118
+ // connection outage. Closing this GUI must also close its own active HTTP
119
+ // connections; they cannot keep the already-stopped runtime alive.
120
+ server.closeAllConnections();
121
+ });
122
+ // Local control starts before cloud sign-in. The GUI remains usable offline.
152
123
  try {
153
- if (readSession()) {
154
- await startPresenceUnlessDaemonServes((err) => {
155
- if (!(err instanceof NotLoggedIn))
156
- console.warn(`Presence did not start: ${err.message}`);
157
- });
158
- }
124
+ await startPresenceUnlessDaemonServes((err) => console.warn(`Local service did not start: ${err.message}`));
159
125
  }
160
126
  finally {
161
127
  // Signed out, presence started, or the probe itself threw: the decision is
@@ -165,20 +131,14 @@ export async function serveCompanion({ open = false } = {}) {
165
131
  console.log(`Companion running — ${url(token)}`);
166
132
  if (open)
167
133
  openBrowser(url(token));
168
- await new Promise((resolve) => {
169
- let closing = false;
170
- const shutdown = () => {
171
- if (closing)
172
- return;
173
- closing = true;
174
- void stopPresence().finally(() => {
175
- server.close(() => resolve());
176
- process.exit(0);
177
- });
178
- };
179
- process.on('SIGINT', shutdown);
180
- process.on('SIGTERM', shutdown);
181
- });
134
+ const shutdown = () => {
135
+ if (localRuntimeOwned())
136
+ void stopLocalOwnerForSignal().catch((error) => console.error(error.message));
137
+ else
138
+ void closeCompanion?.().then(() => process.exit(0));
139
+ };
140
+ process.on('SIGINT', shutdown);
141
+ process.on('SIGTERM', shutdown);
182
142
  }
183
143
  // --- request handling --------------------------------------------------------
184
144
  /** Two legitimate Host values for a loopback service. Anything else is a
@@ -236,74 +196,9 @@ function agentToolsState(signedIn) {
236
196
  const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
237
197
  return agentToolsBadgeState({ signedIn, installed });
238
198
  }
239
- /**
240
- * The client an authenticated API request is served through.
241
- *
242
- * ═══ IT REUSES PRESENCE'S CLIENT, AND THAT IS THE WHOLE POINT OF THIS
243
- * FUNCTION. ═══ `getClient()` builds a BRAND NEW client every call, with
244
- * `autoRefreshToken: true` and an `onAuthStateChange` that writes
245
- * `session.json` back (`supabase.ts:61-89`). Calling it per HTTP request meant
246
- * `cs open` ran a whole family of refreshing, disk-writing clients beside the
247
- * daemon's, all on the ONE rotating refresh token the machine has. Supabase
248
- * rotates that token on every refresh and revokes the previous family member
249
- * outside a 10-second reuse window (`supabase/config.toml:171-175`), so two
250
- * independent refreshers race: one wins, the loser replays a superseded token,
251
- * GoTrue revokes the family, and the token on disk is PERMANENTLY dead rather
252
- * than merely stale. Investigation
253
- * `.bugs/20260831-supabase-health/investigations/04-refresh-token-loop.md` §3
254
- * names this row 6 of its rotation-collision map and calls it the most likely
255
- * precipitating cause of the 2026-08-30 incident, after which the daemon's
256
- * heartbeat spun on `400 refresh_token_not_found` roughly 360 times an hour
257
- * with no way to self-heal.
258
- *
259
- * So the rule `daemon.ts` and `panel3/run.ts` already state in their own
260
- * comments now holds here too: ONE REFRESH LOOP PER PROCESS, and one writer of
261
- * `session.json`. When presence is running, its client is the one that owns
262
- * refreshing, and the companion reads it rather than standing up a rival.
263
- *
264
- * Per process was never enough on its own, because `cs start` and `cs open` are
265
- * two processes over one `session.json`: the rule is ONE REFRESH LOOP PER
266
- * MACHINE. A companion that boots (or signs in) while a daemon is already
267
- * serving defers to it entirely, starting no presence of its own, and everything
268
- * below follows from that decision.
269
- *
270
- * ═══ AND `getClient()` REMAINS THE FALLBACK, BECAUSE NULL HERE MEANS "NO
271
- * PRESENCE", NOT "SIGNED OUT". ═══ `liveClient()` is null before the first
272
- * sign-in, and after `stopPresence()`, and in a companion serving on a machine
273
- * whose presence failed to start, and in one that found a daemon already
274
- * serving and deferred to it. Only `getClient()` can answer the signed-in
275
- * question, by reading the session off disk and throwing `NotLoggedIn` when
276
- * there is none, so the fallback is what every caller's 401 path still hangs
277
- * off.
278
- *
279
- * What the fallback may NOT do is refresh while another `cs` process on this
280
- * machine is refreshing, which is the deferring case above: `cs start` is
281
- * already rotating the one token family, and a client built here with
282
- * `refreshing: true` would race it exactly as the per-request family did.
283
- * `companionRefreshing` carries the decision this process took at boot or at
284
- * sign-in, and `getClient({ refreshing: false })` then returns a client that was
285
- * never handed the refresh token, so it cannot rotate anything. It still reads
286
- * the session off disk and still throws `NotLoggedIn`, including when the stored
287
- * access token has already expired, so the 401 boundary is unchanged. A single
288
- * companion request building a single client is not the defect; a client per
289
- * request forever, beside the daemon's, is.
290
- *
291
- * ═══ READ AT CALL TIME, NEVER CACHED. ═══ `POST /api/login` signs in and then
292
- * takes the deferral decision above, which on the no-daemon branch starts
293
- * presence. A companion that resolved this once at startup would hold the
294
- * pre-login null for the life of the process and go on building its own clients
295
- * after the very moment presence became available to share. The login path also
296
- * reassigns `companionRefreshing`, so caching would freeze the boot decision
297
- * too, past a sign-in that legitimately changed it.
298
- *
299
- * Exported so the rule above is what a test holds: with no presence running it
300
- * falls through to `getClient()`, refreshing only when no other `cs` process on
301
- * this machine is, and lets `NotLoggedIn` out rather than softening it into a
302
- * null, which is the half of the behaviour reachable without a real session and
303
- * a live network.
304
- */
199
+ /** Requests borrow the owner's client or read credentials without refreshing. */
305
200
  export async function requestClient() {
306
- return liveClient() ?? (await getClient({ refreshing: companionRefreshing }));
201
+ return liveClient() ?? (await getClient({ refreshing: false }));
307
202
  }
308
203
  async function handle(req, res, token) {
309
204
  if (!allowedHost(req.headers.host)) {
@@ -342,21 +237,53 @@ async function handle(req, res, token) {
342
237
  res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
343
238
  }
344
239
  async function handleApi(req, res, path, query) {
345
- // Nothing here is served until this boot's refresh decision is in, so no
346
- // request can be answered by a client built from the `true` initializer on a
347
- // machine where a daemon is the one refreshing. See `refreshDecided`.
240
+ // Requests wait for the explicit startup attempt; reads never start a service.
348
241
  await refreshDecided;
242
+ if (req.method === 'POST' && path === '/api/start') {
243
+ await startLocalRuntime({ onStop: closeCompanion });
244
+ json(res, 200, { ok: true });
245
+ return;
246
+ }
349
247
  if (req.method === 'GET' && path === '/api/session') {
350
248
  const machine = getMachineIdentity();
351
249
  let email = null;
352
- if (readSession()) {
250
+ let sessionError = null;
251
+ let record = null;
252
+ try {
253
+ record = readSessionRecord();
254
+ }
255
+ catch {
256
+ sessionError = 'Local sign-in storage could not be read. Your service controls remain available.';
257
+ }
258
+ const local = await inspectLocalRuntime(Date.now() + 2000).catch(() => null);
259
+ const renewal = local?.status?.sessionRenewing === true;
260
+ const connectionState = local?.status?.cloud ?? 'unknown';
261
+ const session = record?.state === 'signed-in' ? record.tokens : null;
262
+ if (session) {
353
263
  try {
354
- email = (await (await requestClient()).auth.getUser()).data.user?.email ?? null;
264
+ const { data, error } = await (await requestClient()).auth.getUser(session.access_token);
265
+ if (error)
266
+ throw error;
267
+ email = data.user?.email ?? null;
268
+ if (!email)
269
+ throw new Error('Sign-in verification returned no account.');
270
+ }
271
+ catch (error) {
272
+ if (error instanceof NotLoggedIn) {
273
+ sessionError = 'The local service needs to check your sign-in. Use Start in Companion if the service is stopped, then check again.';
274
+ }
275
+ else if (!confirmedSessionRejection(error) && !isAuthSessionMissingError(error)) {
276
+ // getUser received an explicit stored JWT; Auth normalizes its typed session_not_found rejection to this class.
277
+ sessionError = 'Sign-in could not be checked. Check your connection and try again.';
278
+ }
355
279
  }
356
- catch { /* stale/expired session reads as signed out */ }
357
280
  }
358
281
  json(res, 200, {
359
- signedIn: email !== null,
282
+ signedIn: sessionError ? null : email !== null,
283
+ sessionError,
284
+ sessionRenewing: renewal,
285
+ connectionState,
286
+ sessionState: record?.state ?? 'signed-out',
360
287
  email,
361
288
  machineName: machine.name,
362
289
  platform: process.platform,
@@ -375,6 +302,7 @@ async function handleApi(req, res, path, query) {
375
302
  }
376
303
  try {
377
304
  const result = await signIn(email, password);
305
+ await notifySessionChanged();
378
306
  await startPresenceUnlessDaemonServes((err) => {
379
307
  console.warn(`Presence did not start after sign-in: ${err.message}`);
380
308
  });
@@ -386,30 +314,19 @@ async function handleApi(req, res, path, query) {
386
314
  return;
387
315
  }
388
316
  if (req.method === 'POST' && path === '/api/logout') {
389
- // Logout also unregisters ctrl-spc from the detected agents (Phase 3), so no
390
- // dead server entry is left behind for the next agent run.
391
- if (isPresenceRunning()) {
392
- await stopPresence({ unregister: true });
393
- }
394
- else {
395
- /* ═══ A COMPANION THAT DEFERRED TO A DAEMON STILL HAS TO UNREGISTER. ═══
396
- `stopPresence` returns early when this process holds no presence
397
- (`presence.ts:603-604`), so without this branch logout would delete
398
- `session.json` and leave the `ctrl-spc` entry in Claude's and Codex's
399
- configs pointing at a server whose session is gone.
400
-
401
- Only the unregistration is copied, deliberately. The other two things
402
- `stopPresence` does belong to a process that owns them, and this one
403
- owns neither: the running tools server is the daemon's, and the daemon
404
- still owns and heartbeats `cliv2_agents`. Stamping `stopped_at` from
405
- here would mark a machine offline that is genuinely online. */
317
+ try {
318
+ await clearSession();
319
+ await notifySessionChanged();
406
320
  const agents = detectAgents();
407
321
  if (agents.includes('claude'))
408
322
  void unregisterFromClaude();
409
323
  if (agents.includes('codex'))
410
324
  void unregisterFromCodex();
411
325
  }
412
- clearSession();
326
+ catch (error) {
327
+ json(res, 503, { ok: false, error: error instanceof Error ? error.message : 'Sign-out could not be confirmed.' });
328
+ return;
329
+ }
413
330
  json(res, 200, { ok: true });
414
331
  return;
415
332
  }