@ctrl-spc/cs 0.7.3 → 0.7.5

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/dist/companion.js CHANGED
@@ -4,9 +4,9 @@ import { COMPANION_PORT } from './env.js';
4
4
  import { openBrowser } from './browser.js';
5
5
  import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
6
6
  import { getClient, signIn, NotLoggedIn } from './supabase.js';
7
- import { startPresence, stopPresence } from './presence.js';
7
+ import { startPresence, stopPresence, liveClient, isPresenceRunning } from './presence.js';
8
8
  import { detectAgents } from './agents.js';
9
- import { agentToolsBadgeState } from './mcp.js';
9
+ import { agentToolsBadgeState, foreignToolsServerAlive, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
10
10
  import { loadProjects, saveMapping } from './projects.js';
11
11
  import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
12
12
  import { hostedRemoteIdentity } from './git-remote.js';
@@ -14,9 +14,64 @@ import { chooseFolder, detectGitRemote } from './folders.js';
14
14
  import { renderCompanionUi } from './companion-ui.js';
15
15
  const MAX_BODY_BYTES = 64 * 1024;
16
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
+ */
40
+ let refreshDecided = Promise.resolve();
17
41
  function url(token = companionToken()) {
18
42
  return `http://127.0.0.1:${COMPANION_PORT}/?token=${encodeURIComponent(token)}`;
19
43
  }
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
+ */
62
+ async function startPresenceUnlessDaemonServes(onError) {
63
+ if (await foreignToolsServerAlive()) {
64
+ companionRefreshing = false;
65
+ return;
66
+ }
67
+ companionRefreshing = true;
68
+ try {
69
+ await startPresence();
70
+ }
71
+ catch (err) {
72
+ onError(err);
73
+ }
74
+ }
20
75
  /** Confirms the process already holding the port is really our companion before
21
76
  * we hand it the token via the browser URL. */
22
77
  async function isOurCompanion(token) {
@@ -50,6 +105,14 @@ export async function openCompanion() {
50
105
  */
51
106
  export async function serveCompanion({ open = false } = {}) {
52
107
  const token = companionToken();
108
+ // Assigned BEFORE the socket is accepting, so there is no instant at which a
109
+ // request could be served without waiting for the decision. `resolve` is
110
+ // called on every path out of the block below, including the failures, so a
111
+ // presence that never started leaves the server answering rather than hung.
112
+ let decided;
113
+ refreshDecided = new Promise((resolve) => {
114
+ decided = resolve;
115
+ });
53
116
  const server = createServer((req, res) => {
54
117
  void handle(req, res, token).catch((err) => {
55
118
  if (!res.headersSent)
@@ -63,6 +126,9 @@ export async function serveCompanion({ open = false } = {}) {
63
126
  });
64
127
  }
65
128
  catch (err) {
129
+ // This process never took the port, so it will serve nothing; settle anyway
130
+ // rather than leave a resolved-by-nobody promise behind for a later caller.
131
+ decided();
66
132
  if (err.code === 'EADDRINUSE') {
67
133
  // Port is taken. Only treat it as our own resident companion (and hand it
68
134
  // the token via the browser) once a health probe confirms it — otherwise
@@ -83,15 +149,19 @@ export async function serveCompanion({ open = false } = {}) {
83
149
  }
84
150
  // Come online immediately if already signed in (terminal `cs login` or a
85
151
  // prior GUI sign-in). Not signed in is fine — the GUI shows the sign-in screen.
86
- if (readSession()) {
87
- try {
88
- await startPresence();
89
- }
90
- catch (err) {
91
- if (!(err instanceof NotLoggedIn))
92
- console.warn(`Presence did not start: ${err.message}`);
152
+ try {
153
+ if (readSession()) {
154
+ await startPresenceUnlessDaemonServes((err) => {
155
+ if (!(err instanceof NotLoggedIn))
156
+ console.warn(`Presence did not start: ${err.message}`);
157
+ });
93
158
  }
94
159
  }
160
+ finally {
161
+ // Signed out, presence started, or the probe itself threw: the decision is
162
+ // as made as it is going to get, and requests must not wait past it.
163
+ decided();
164
+ }
95
165
  console.log(`Companion running — ${url(token)}`);
96
166
  if (open)
97
167
  openBrowser(url(token));
@@ -101,7 +171,7 @@ export async function serveCompanion({ open = false } = {}) {
101
171
  if (closing)
102
172
  return;
103
173
  closing = true;
104
- void stopPresence({ markOffline: true }).finally(() => {
174
+ void stopPresence().finally(() => {
105
175
  server.close(() => resolve());
106
176
  process.exit(0);
107
177
  });
@@ -166,6 +236,75 @@ function agentToolsState(signedIn) {
166
236
  const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
167
237
  return agentToolsBadgeState({ signedIn, installed });
168
238
  }
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
+ */
305
+ export async function requestClient() {
306
+ return liveClient() ?? (await getClient({ refreshing: companionRefreshing }));
307
+ }
169
308
  async function handle(req, res, token) {
170
309
  if (!allowedHost(req.headers.host)) {
171
310
  res.writeHead(421, { 'Content-Type': 'text/plain' }).end('Misdirected request.');
@@ -203,12 +342,16 @@ async function handle(req, res, token) {
203
342
  res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
204
343
  }
205
344
  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`.
348
+ await refreshDecided;
206
349
  if (req.method === 'GET' && path === '/api/session') {
207
350
  const machine = getMachineIdentity();
208
351
  let email = null;
209
352
  if (readSession()) {
210
353
  try {
211
- email = (await (await getClient()).auth.getUser()).data.user?.email ?? null;
354
+ email = (await (await requestClient()).auth.getUser()).data.user?.email ?? null;
212
355
  }
213
356
  catch { /* stale/expired session reads as signed out */ }
214
357
  }
@@ -232,12 +375,9 @@ async function handleApi(req, res, path, query) {
232
375
  }
233
376
  try {
234
377
  const result = await signIn(email, password);
235
- try {
236
- await startPresence();
237
- }
238
- catch (err) {
378
+ await startPresenceUnlessDaemonServes((err) => {
239
379
  console.warn(`Presence did not start after sign-in: ${err.message}`);
240
- }
380
+ });
241
381
  json(res, 200, { ok: true, email: result.email });
242
382
  }
243
383
  catch (err) {
@@ -248,7 +388,27 @@ async function handleApi(req, res, path, query) {
248
388
  if (req.method === 'POST' && path === '/api/logout') {
249
389
  // Logout also unregisters ctrl-spc from the detected agents (Phase 3), so no
250
390
  // dead server entry is left behind for the next agent run.
251
- await stopPresence({ markOffline: true, unregister: true });
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. */
406
+ const agents = detectAgents();
407
+ if (agents.includes('claude'))
408
+ void unregisterFromClaude();
409
+ if (agents.includes('codex'))
410
+ void unregisterFromCodex();
411
+ }
252
412
  clearSession();
253
413
  json(res, 200, { ok: true });
254
414
  return;
@@ -256,7 +416,7 @@ async function handleApi(req, res, path, query) {
256
416
  // Everything below needs a signed-in session.
257
417
  let client;
258
418
  try {
259
- client = await getClient();
419
+ client = await requestClient();
260
420
  }
261
421
  catch (err) {
262
422
  if (err instanceof NotLoggedIn) {
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configDir } from './config.js';
4
+ import { processIsAlive } from './win-shell.js';
5
+ /**
6
+ * One `cs start` per config dir.
7
+ *
8
+ * Two daemons on one machine share one `cliv2_agents` row and take turns
9
+ * overwriting it every heartbeat. The case that produced it: `cs start` was
10
+ * running, `npm i -g` replaced dist under it, and a second `cs start` came up
11
+ * on the new code. The row then flipped between 0.7.0 and 0.7.4 every few
12
+ * seconds and the Machines popover flickered with it.
13
+ *
14
+ * Keyed on the config dir, not the machine id, so the documented two-instance
15
+ * setup (`CTRL_SPC_V2_CONFIG_DIR` plus `CTRL_SPC_V2_MACHINE_ID`) still works.
16
+ *
17
+ * ponytail: a pid file, not an OS lock. A pid recycled onto an unrelated
18
+ * process after a crash reads as "already running" until that process exits;
19
+ * `rm ~/.config/ctrl-spc-v2/daemon.pid` is the way out. Upgrade to an
20
+ * exclusive-open lock if that is ever hit in practice.
21
+ */
22
+ function lockPath() {
23
+ return join(configDir(), 'daemon.pid');
24
+ }
25
+ /** Claims the lock for this process, or returns the pid of the daemon that holds it. */
26
+ export function claimDaemonLock() {
27
+ const path = lockPath();
28
+ if (existsSync(path)) {
29
+ const pid = Number(readFileSync(path, 'utf8').trim());
30
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
31
+ return { held: true, pid };
32
+ }
33
+ }
34
+ mkdirSync(configDir(), { recursive: true });
35
+ writeFileSync(path, String(process.pid));
36
+ return { held: false };
37
+ }
38
+ /** Removes the lock if this process wrote it. Never throws: it runs during shutdown. */
39
+ export function releaseDaemonLock() {
40
+ try {
41
+ const path = lockPath();
42
+ if (Number(readFileSync(path, 'utf8').trim()) === process.pid)
43
+ rmSync(path);
44
+ }
45
+ catch {
46
+ // no lock, or not ours
47
+ }
48
+ }
package/dist/daemon.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ensureAutostart } from './autostart.js';
2
- import { startPresence, stopPresence } from './presence.js';
2
+ import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
3
+ import { liveClient, startPresence, stopPresence } from './presence.js';
3
4
  /* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
4
5
  `.implementations/19-agent-panel-v3/conventions.md` and enforced by
5
6
  `test/panel3-isolation.contract.test.mjs`, whose MOUNTS map allows this file
@@ -28,14 +29,87 @@ import { startPanel } from './panel3/run.js';
28
29
  * starts doing the right thing with nothing else typed.
29
30
  */
30
31
  export async function runDaemon() {
32
+ /* One daemon per config dir. See daemon-lock.ts for the flicker this stops.
33
+ Exits rather than replacing the holder: under launchd's KeepAlive a
34
+ replacement would be restarted and would then replace the replacer. */
35
+ const lock = claimDaemonLock();
36
+ if (lock.held) {
37
+ console.error(`cs start is already running on this computer (pid ${lock.pid}). Stop it first to start another.`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
31
41
  ensureAutostart(); // default-on: install the login item unless the user opted out
32
- const { machineName, agents, client } = await startPresence();
33
- const panel = startPanel(client);
42
+ const { machineName, agents } = await startPresence();
43
+ /* ═══ THE PANEL READS PRESENCE'S CLIENT, IT IS NOT HANDED A COPY OF IT. ═══
44
+ `startPresence` used to return the client it had just built and this line
45
+ passed that value to `startPanel`, which held it for the process's life. The
46
+ heartbeat's catch block REBUILDS that client when `session.json` comes to
47
+ hold a different refresh token, and since the rebuild guard landed a
48
+ successful rebuild is the normal path rather than a throw. The value handed
49
+ over here was therefore a client the database refuses and nobody watches,
50
+ while the panel's two-second poll went on writing `panel3_machines` with it.
51
+ That table took 37,051 writes in the incident window, the largest single
52
+ share of it.
53
+ `liveClient` is presence's own accessor, so the panel asks the module that
54
+ owns the presence lifecycle rather than being told once. It cannot answer
55
+ null here: `startPresence` has returned, so presence is running. */
56
+ /* ═══ AND THE LAST CLIENT IT SAW IS KEPT, FOR THE ONE READ THAT HAPPENS AFTER
57
+ PRESENCE HAS ALREADY STOPPED. ═══ `startPanel` returns `stop: () =>
58
+ stopListening(current(), ...)`, and `current()` is evaluated in the arrow,
59
+ BEFORE `stopListening` is entered, so `stopListening`'s own best-effort
60
+ try/catch cannot cover it. On `SIGNED_OUT` both watchers are bound to this
61
+ same client and auth-js runs them in registration order: presence's fires
62
+ first and `stopPresence` sets its module singleton to null synchronously,
63
+ so by the time the daemon's watcher reaches `shutdown` -> `panel.stop()`
64
+ the getter below has nothing live to answer with. Throwing there is a throw
65
+ inside `shutdown` before its first await, which `void shutdown(1)` does not
66
+ contain: `panel3_machines.stopped_at` never gets written, the card goes on
67
+ saying an agent is working, `Promise.all` rejects, `process.exit` is never
68
+ reached, and the panel's `for(;;)` spins on a caught-and-logged failure
69
+ every two seconds forever. launchd's `KeepAlive` cannot help a process that
70
+ never exits. `shutdown()` must reach `process.exit` on every path.
71
+
72
+ THE FALLBACK IS THE LAST CLIENT PRESENCE ITSELF HELD, not the one it built
73
+ at startup: a rebuild replaces `p.client`, and a goodbye written with the
74
+ pre-rebuild snapshot is the same refused write this branch exists to stop.
75
+ Recording it on every successful read is what keeps it current without a
76
+ second subscription to the presence lifecycle.
77
+
78
+ THE THROW IS NOT WEAKENED FOR THE POLL, and that is the point of ordering
79
+ the two lines this way. A poll only reaches the fallback once presence has
80
+ stopped, which in this process means a shutdown is already in flight and
81
+ the exit is milliseconds away; every other poll, for the whole life of the
82
+ daemon, reads a live client or fails loudly exactly as before. What the
83
+ fallback buys is the goodbye write, which `stopListening` documents as
84
+ best-effort precisely because it is the last thing this process does.
85
+
86
+ THE ONE THING THAT WINDOW COSTS, NAMED RATHER THAN LEFT TO BE REDISCOVERED:
87
+ a poll landing in it used to throw at the getter and be swallowed by the
88
+ per-poll catch, so it could not write at all. Now it takes the fallback and
89
+ reaches `sayListening`, whose upsert deliberately sends `stopped_at: null`
90
+ on every poll, since that is what lets a restarted daemon come back on a row
91
+ stamped as gone. If such a poll's write lands AFTER `stopListening`'s stamp,
92
+ it erases the goodbye and the row reads listening again. That is bounded and
93
+ self-healing: `LISTENING_WINDOW_MS` is 15s, so the freshness check calls the
94
+ machine gone 15 seconds later whatever the column says, and the process is
95
+ already exiting. Not worth a second seam to close, and worth knowing about
96
+ if a machine is ever seen lingering for a few seconds after `cs start` ends. */
97
+ let lastKnownClient = liveClient();
98
+ const panel = startPanel(() => {
99
+ const live = liveClient();
100
+ if (live) {
101
+ lastKnownClient = live;
102
+ return live;
103
+ }
104
+ if (!lastKnownClient)
105
+ throw new Error('the presence loop is not running, so there is no session to work through');
106
+ return lastKnownClient;
107
+ });
34
108
  console.log(`CTRL+SPC — this computer: ${machineName}`);
35
109
  console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
36
110
  console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
37
111
  let stopping = false;
38
- async function shutdown() {
112
+ async function shutdown(code = 0) {
39
113
  if (stopping)
40
114
  return;
41
115
  stopping = true;
@@ -47,11 +121,45 @@ export async function runDaemon() {
47
121
  so the panel's own signal handling stands down when it is started from
48
122
  here (see `run()`). Both are best-effort and neither throws, which is why
49
123
  they can settle together. */
50
- await Promise.all([panel.stop(), stopPresence({ markOffline: true })]);
51
- process.exit(0);
124
+ await Promise.all([panel.stop(), stopPresence()]);
125
+ releaseDaemonLock();
126
+ process.exit(code);
52
127
  }
53
128
  process.on('SIGINT', () => void shutdown());
54
129
  process.on('SIGTERM', () => void shutdown());
130
+ /* ═══ AND THE PANEL GOES DOWN WITH THE SESSION TOO. ═══ presence.ts stops its
131
+ own three loops on `SIGNED_OUT`, which leaves this process holding a panel
132
+ that would go on upserting `panel3_machines` every few seconds with a token
133
+ the database refuses. It shares this client, so it hears the same event: one
134
+ shutdown, both halves, and the exit says the daemon needs a person rather
135
+ than pretending it is still online. Non-zero because this is a failure, not
136
+ a Ctrl-C. The login item's `KeepAlive` restarts either way, and that is the
137
+ point: a restart refuses at `getClient` after one refresh attempt and exits
138
+ again, so an unattended machine retries on launchd's throttle instead of
139
+ polling four times a second forever. */
140
+ /* ═══ BOUND TO WHATEVER CLIENT IS LIVE AT THIS INSTANT, WHICH IS WHY IT IS
141
+ READ AND NOT CAPTURED. ═══ This used to bind to the client `startPresence`
142
+ returned. A rebuild replaces that client, and presence re-binds its OWN
143
+ watcher to the replacement (`bindSignedOutWatcher`) while this one would
144
+ stay on the discarded client, where `SIGNED_OUT` can never fire again. That
145
+ leaves the daemon's half of the shutdown deaf for the rest of the process.
146
+ Reading it here binds to the current one; a client rebuilt LATER is
147
+ presence's watcher to carry, and that one calls `stopPresence`, which clears
148
+ the intervals this process depends on. This is not a second terminal stop,
149
+ it is the same one bound to a client that exists.
150
+
151
+ THE NULL IS THROWN ON RATHER THAN SKIPPED, matching the getter above. `?.`
152
+ would read as though no client were an expected state, and it is not:
153
+ `startPresence` has returned, so presence is running. Worse, it would
154
+ silently drop half the shutdown in exactly the state it claims to guard
155
+ against, leaving a daemon that answers a dead session by doing nothing. */
156
+ const live = liveClient();
157
+ if (!live)
158
+ throw new Error('the presence loop is not running, so there is no session to watch');
159
+ live.auth.onAuthStateChange((event) => {
160
+ if (event === 'SIGNED_OUT')
161
+ void shutdown(1);
162
+ });
55
163
  // Keep the event loop alive.
56
164
  await new Promise(() => { });
57
165
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ═══ THE WEB FIREWALL IN FRONT OF THE DATABASE. ═══
3
+ *
4
+ * Supabase puts a Cloudflare web application firewall in front of PostgREST.
5
+ * When a request body carries text its pattern matcher reads as an injection
6
+ * payload, the edge answers 403 with a 4.5 KB HTML block page, the row is never
7
+ * written, and — because of how the database client handles a non-JSON error
8
+ * body — that entire page arrives as `error.message`.
9
+ *
10
+ * WHAT THE CLIENT ACTUALLY HANDS BACK, established by reading its source rather
11
+ * than inferred: the promise RESOLVES, `error` is `{ message: <the whole page> }`
12
+ * with no `code`, `details` or `hint`, and the 403 status is real but is thrown
13
+ * away by both of this package's error guards, which destructure only the data
14
+ * and the error. So recognition is on the body. There is no other option here
15
+ * short of changing every call site of both guards.
16
+ *
17
+ * THE PHRASES BELOW WERE CAPTURED FROM A REAL REFUSAL, not paraphrased from
18
+ * memory. One request carrying the shape the incident report describes was sent
19
+ * to this project's REST endpoint with no credentials; the firewall answers
20
+ * ahead of authentication, so the page is reproducible at will and costs
21
+ * nothing. Inventing the markers and the test fixture from the same paraphrase
22
+ * would have agreed with itself and matched nothing in production.
23
+ *
24
+ * WHY THE RAY IDENTIFIER SURVIVES when the rest of the page does not. Supabase
25
+ * exposes no control over this firewall on any plan: no dashboard setting, no
26
+ * management endpoint, no documented exception procedure. The one route to a
27
+ * rule exclusion is a support ticket, and that ticket needs the ray identifier
28
+ * of a real refusal. It appears in the page body and in a response header, and
29
+ * the header is not visible where this runs. Discard the page and keep twelve
30
+ * characters, or the only copy anyone ever holds is gone.
31
+ *
32
+ * THE FALLBACK IS TO CHANGE NOTHING. A message this does not recognise is
33
+ * returned byte for byte, so an ordinary database failure still reads exactly as
34
+ * it does today, and if Cloudflare rewords the page one day the caller is back
35
+ * to the behaviour that prompted this work rather than to a new wrong one.
36
+ */
37
+ /** The heading, the sub-line and the footer element of the block page, exactly
38
+ * as captured. The title says "Attention Required", which a CHALLENGE page also
39
+ * says, so the title is deliberately not one of these: a challenge is not a body
40
+ * problem and telling somebody to reword their document would be a lie. */
41
+ const BLOCK_PAGE_MARKERS = ['you have been blocked', 'you are unable to access', 'cf-error-details'];
42
+ /** `Cloudflare Ray ID: ` then the identifier, wrapped in a tag on the real page. */
43
+ const RAY_ID = /cloudflare ray id:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i;
44
+ /**
45
+ * What a caller reads instead of the block page.
46
+ *
47
+ * It says REQUEST rather than body, and makes the advice conditional, because
48
+ * both guards this passes through carry more reads than writes and one of them
49
+ * also sits behind the commands a person types. Telling somebody who ran a read
50
+ * to reword their document would be worse than the HTML.
51
+ */
52
+ function refusalSentence(rayId) {
53
+ const reference = rayId ? ` (Cloudflare ray ${rayId})` : '';
54
+ return ('the firewall in front of the database refused this request because something in it reads as ' +
55
+ 'attack-shaped text; if you were saving a document, reword the part that reads like an injection ' +
56
+ `and try again${reference}`);
57
+ }
58
+ /**
59
+ * Translate a database error message, leaving anything that is not the
60
+ * firewall's block page exactly as it was.
61
+ *
62
+ * Takes and returns a string so every call site is one call and nothing has to
63
+ * learn a new shape. Callers keep their own `could not <verb> <subject>: ` or
64
+ * `<tool> failed: ` prefix, so the operation that failed is still named.
65
+ */
66
+ export function readableWriteError(message) {
67
+ const lowered = message.toLowerCase();
68
+ const isBlockPage = message.trimStart().startsWith('<') &&
69
+ lowered.includes('cloudflare') &&
70
+ BLOCK_PAGE_MARKERS.some((marker) => lowered.includes(marker));
71
+ if (!isBlockPage)
72
+ return message;
73
+ return refusalSentence(RAY_ID.exec(message)?.[1]);
74
+ }
75
+ /**
76
+ * Taught VERBATIM in the description of every tool that writes a body, and in
77
+ * the standing instructions of both agent surfaces, for the reason the citation
78
+ * teaching constant in mcp.ts records: a convention that lives in only one
79
+ * description is a convention half the flows never see.
80
+ *
81
+ * EVERY SHAPE IS NAMED IN WORDS AND NONE IS DEMONSTRATED. This paragraph is not
82
+ * only read by agents, it is WRITTEN THROUGH THE FIREWALL IT DESCRIBES — into
83
+ * the stored brief on every start and every dispatch. A worked example here
84
+ * would refuse every brief write and stop the product from starting anything.
85
+ */
86
+ export const FIREWALL_WRITING_RULE = 'This body is saved through a web firewall that refuses text shaped like an attack. Keep out of it: ' +
87
+ 'query fragments that read as database injection, such as a quoted value closed early and followed by ' +
88
+ 'an always-true comparison, a set-combining select, or a statement that drops a table; shell injection, ' +
89
+ 'such as a pipe or semicolon followed by a command name, a command inside backticks, or a command inside ' +
90
+ 'a dollar-parenthesis substitution; an interpolation placed next to a call that executes or matches a ' +
91
+ 'string; script-carrying links and inline error or load handlers on a tag; repeated parent-directory ' +
92
+ 'steps in a path; and template expressions wrapping a call in double braces or in a dollar-brace. When ' +
93
+ 'you must describe one of these, describe it in words rather than writing it out. If a save comes back ' +
94
+ 'refused by the firewall, that is why: reword the body, do not retry it unchanged, and do not write ' +
95
+ 'around the tool.';