@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.
- package/README.md +50 -3
- package/dist/agents.js +175 -1
- package/dist/autostart.js +103 -122
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +54 -6
- package/dist/companion.js +86 -169
- package/dist/config.js +199 -17
- package/dist/daemon-lifecycle.js +575 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +860 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +75 -76
- package/dist/login.js +9 -9
- package/dist/mcp.js +55 -56
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +892 -575
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +996 -558
- package/dist/panel3/spawn.js +85 -23
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +271 -135
- package/dist/supabase.js +173 -37
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/companion-ui.js
CHANGED
|
@@ -286,9 +286,44 @@ async function api(path, opts) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
async function boot() {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 (
|
|
352
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
}
|