@ctrl-spc/cs 0.7.2 → 0.7.4

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.
@@ -9,5 +9,8 @@ export function buildPresenceHeartbeatPayload(input, seenAt = new Date()) {
9
9
  platform: input.platform,
10
10
  cli_version: CLI_VERSION,
11
11
  last_seen_at: seenAt.toISOString(),
12
+ // A beating heart is not stopped: clears the stamp a clean shutdown left,
13
+ // so a machine that comes back reads online again.
14
+ stopped_at: null,
12
15
  };
13
16
  }
package/dist/presence.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { platform } from 'node:os';
2
2
  import { getClient } from './supabase.js';
3
- import { getMachineIdentity, mcpToken, supersededMachineIds, clearSupersededMachineIds } from './config.js';
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';
@@ -15,9 +15,90 @@ let presence = null;
15
15
  * concurrent callers both build interval pairs — the first pair then leaks and
16
16
  * keeps heartbeating "online" past sign-out. Concurrent callers await this. */
17
17
  let starting = null;
18
+ /** In-flight guard for stopPresence, mirroring `starting` above. `stopPresence`
19
+ * clears the module singleton synchronously and then awaits its
20
+ * `cliv2_agents.stopped_at` write, so a second caller arriving mid-write used
21
+ * to see `presence === null` and return immediately. On the SIGNED_OUT path
22
+ * that second caller is real: presence's own watcher calls `stopPresence()`,
23
+ * and the daemon's watcher calls `shutdown(1)`, which calls `stopPresence()`
24
+ * again. `daemon.ts`'s `Promise.all([panel.stop(), stopPresence()])` then
25
+ * resolved on that early return, and `process.exit(code)` truncated the
26
+ * first call's write still in flight, leaving the machine reading online in
27
+ * the web UI until its freshness window lapsed. A second caller now awaits
28
+ * the same in-flight work instead. */
29
+ let stopping = null;
18
30
  export function isPresenceRunning() {
19
31
  return presence !== null;
20
32
  }
33
+ /**
34
+ * ═══ WHICHEVER CLIENT PRESENCE IS ACTUALLY HOLDING RIGHT NOW. ═══
35
+ *
36
+ * The heartbeat's catch block REASSIGNS `p.client` when `session.json` comes to
37
+ * hold a different refresh token (a concurrent `cs login`, another install on
38
+ * this box signing in as someone else). Before the rebuild guard landed that
39
+ * reassignment effectively never happened, because the rebuild threw every time
40
+ * against the same dead token, so every other holder of the client
41
+ * `startPresence` built was accidentally correct. It is not any more: a
42
+ * successful rebuild is now the normal path, and a caller holding the original
43
+ * is holding a client whose token the database refuses and whose `SIGNED_OUT`
44
+ * nobody is watching. `panel3_machines` took 37,051 writes that way in one
45
+ * window, read off this machine's own logs.
46
+ *
47
+ * So the client is READ rather than handed over, through the module singleton
48
+ * that already owns the presence lifecycle, in the exported-accessor style
49
+ * `isPresenceRunning` above is written in.
50
+ *
51
+ * NULL WHEN PRESENCE IS NOT RUNNING, and truthfully so: there is no client to
52
+ * give, and inventing one here would build a second refresh loop in a process
53
+ * that is supposed to have exactly one. A caller that starts presence and then
54
+ * reads this has a non-null answer for as long as presence is up, which is the
55
+ * whole of the daemon's life.
56
+ */
57
+ export function liveClient() {
58
+ return presence?.client ?? null;
59
+ }
60
+ /** Whether the heartbeat's catch block should rebuild its client from disk.
61
+ *
62
+ * Exact, not heuristic: a rebuild only ever helps when `session.json` holds a
63
+ * DIFFERENT refresh token than the one the live client was built from (a
64
+ * concurrent `cs login`, or another install on this box signing in as someone
65
+ * else). When it holds the SAME token, `getClient` -> `setSession` -> auth-js
66
+ * will decode it, see the exact expiry it saw last tick, and call
67
+ * `_callRefreshToken` again against a refresh token GoTrue has already
68
+ * revoked, repeating the same 400 `refresh_token_not_found` that ran once per
69
+ * heartbeat tick (every 10s) instead of once per auth-js's own 60s cooldown,
70
+ * because a brand-new client has no memory of the failure. `onDisk` null/undefined
71
+ * (`readSession()` returns null on a missing or corrupt file, and the field is
72
+ * optional on `StoredSession` reads) means there is nothing new to try, so it
73
+ * also answers false rather than forcing a rebuild against nothing.
74
+ *
75
+ * No SupabaseClient type appears here on purpose: the decision is a pure
76
+ * string comparison, so a test can call it with two strings and nothing else. */
77
+ export function shouldRebuildClient(builtFrom, onDisk) {
78
+ if (onDisk == null)
79
+ return false;
80
+ return builtFrom !== onDisk;
81
+ }
82
+ /** Register the one `SIGNED_OUT` handler presence relies on, and return its
83
+ * subscription so the caller can unsubscribe it later. Extracted so
84
+ * `startPresence`'s initial binding and the heartbeat's post-rebuild rebind
85
+ * are the same code, not two copies that could drift, and so a test can pass
86
+ * a fake client and fire the callback directly without a real Supabase
87
+ * client.
88
+ *
89
+ * `SIGNED_OUT` is the truthful signal (see the block comment on
90
+ * `startPresence` for why): auth-js fires it from `_removeSession` only when
91
+ * a refresh fails non-retryably AND the access token has already expired.
92
+ * Nothing on disk is touched here; `cs logout` owns deleting `session.json`. */
93
+ export function bindSignedOutWatcher(client) {
94
+ const { data } = client.auth.onAuthStateChange((event) => {
95
+ if (event !== 'SIGNED_OUT')
96
+ return;
97
+ console.error('Session expired and could not be refreshed. Run `cs login` to sign in again.');
98
+ void stopPresence();
99
+ });
100
+ return data.subscription;
101
+ }
21
102
  async function heartbeat(p) {
22
103
  try {
23
104
  const { error } = await p.client
@@ -33,50 +114,77 @@ async function heartbeat(p) {
33
114
  throw error;
34
115
  }
35
116
  catch (err) {
36
- // Most likely a rotated/expired session or a network blip. Rebuild the
37
- // client from disk (picks up any refreshed token) and try next tick.
117
+ // A failure here used to rebuild the client from disk unconditionally, and
118
+ // that was the production incident: `getClient` -> `setSession` decodes
119
+ // whatever refresh token is on disk and hands it to auth-js, which refreshes
120
+ // it UNCONDITIONALLY if the access token has expired. When the disk token is
121
+ // the same dead one this client was already built from, that is a guaranteed
122
+ // repeat of the exact 400 `refresh_token_not_found` that got us here, once
123
+ // per 10s heartbeat tick instead of once per auth-js's own 60s cooldown (that
124
+ // cooldown lives on the client instance, and a fresh client every tick never
125
+ // accumulates it). 7,028 refresh requests in one window, read off this
126
+ // machine's own logs. `shouldRebuildClient` below is the guard: only rebuild
127
+ // when `session.json` disagrees with the token this client holds.
38
128
  console.warn(`heartbeat failed, will retry: ${err.message}`);
39
129
  try {
40
- p.client = await getClient();
41
- // FIX 2: flow the rebuilt client (fresh/refreshed token) into the tools
42
- // session-lifecycle heartbeat too, so a wedged token can't leave the session
43
- // heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
44
- // the tools server is running.
45
- setToolsClient(p.client);
46
- /* ═══ !Cleanup PHASE 0 (I1) RE-RESOLVE WHO WE ARE, NOT JUST THE TOKEN.
47
- THE WEDGE THIS FIXES, read off this machine's own 6.1 MB log: an
48
- unbroken wall of
49
-
50
- heartbeat failed, will retry: new row violates row-level security
51
- policy for table "cliv2_agents"
52
-
53
- and presence stayed dead until the daemon was killed by hand.
54
-
55
- `p.userId` is resolved ONCE, at startPresence, and the payload sends it
56
- EXPLICITLY while the table's policy is `with check (auth.uid() =
57
- user_id)`. The recovery above rebuilt the CLIENT from disk but left
58
- `p.userId` alone so the moment `session.json` came to hold a different
59
- account (a second install on this box signing in; a demo lane; a
60
- re-login as someone else), every heartbeat sent one user's id under
61
- another user's token and the database correctly refused it. Rebuilding
62
- the client changed nothing, because the disk session was not the stale
63
- half. It could never recover on its own: the retry re-sent the same
64
- disagreement, several times a minute, forever.
65
-
66
- Evidence it really happened here rather than in theory: `cliv2_agents`
67
- holds TWO rows for this machine_id under two different user_ids, one of
68
- them stuck at the epoch.
69
-
70
- THE TOKEN IS THE AUTHORITY. `auth.uid()` is what the database will
71
- believe whatever this process thinks, so the id is taken FROM the
72
- rebuilt client rather than held against it. Warned rather than silent:
73
- a daemon that starts heartbeating as a different user has had the
74
- machine change owner underneath it, and that is worth a line. */
75
- const { data } = await p.client.auth.getUser();
76
- const currentUserId = data.user?.id;
77
- if (currentUserId && currentUserId !== p.userId) {
78
- console.warn('the signed-in account changed underneath this daemon heartbeating as the account now on disk');
79
- p.userId = currentUserId;
130
+ const onDisk = readSession()?.refresh_token;
131
+ if (shouldRebuildClient(p.builtFromRefreshToken, onDisk)) {
132
+ // KEEPING THE REBUILD, NOT DELETING IT: every client here is built with
133
+ // `persistSession: false`, which selects auth-js's in-memory storage
134
+ // adapter, so `getUser()` reads that client's own memory and never reads
135
+ // `session.json`. `getClient()` -> `readSession()` -> `setSession()` is the
136
+ // ONLY path in this process from disk to a live client. Deleting the
137
+ // rebuild (proposed twice already in earlier drafts of this fix) would make
138
+ // the account-reconciliation below unreachable and a concurrent `cs login`
139
+ // unrecoverable without restarting the daemon.
140
+ const rebuilt = await getClient();
141
+ p.authSubscription.unsubscribe();
142
+ p.authSubscription = bindSignedOutWatcher(rebuilt);
143
+ p.client = rebuilt;
144
+ // shouldRebuildClient already ruled out onDisk being null/undefined to
145
+ // reach this branch; the `as string` reflects that guarantee rather
146
+ // than reintroducing a null fallback that can't occur.
147
+ p.builtFromRefreshToken = onDisk;
148
+ // FIX 2: flow the rebuilt client (fresh/refreshed token) into the tools
149
+ // session-lifecycle heartbeat too, so a wedged token can't leave the session
150
+ // heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
151
+ // the tools server is running.
152
+ setToolsClient(p.client);
153
+ /* ═══ !Cleanup PHASE 0 (I1) RE-RESOLVE WHO WE ARE, NOT JUST THE TOKEN.
154
+ THE WEDGE THIS FIXES, read off this machine's own 6.1 MB log: an
155
+ unbroken wall of
156
+
157
+ heartbeat failed, will retry: new row violates row-level security
158
+ policy for table "cliv2_agents"
159
+
160
+ and presence stayed dead until the daemon was killed by hand.
161
+
162
+ `p.userId` is resolved ONCE, at startPresence, and the payload sends it
163
+ EXPLICITLY while the table's policy is `with check (auth.uid() =
164
+ user_id)`. The recovery above rebuilt the CLIENT from disk but left
165
+ `p.userId` alone so the moment `session.json` came to hold a different
166
+ account (a second install on this box signing in; a demo lane; a
167
+ re-login as someone else), every heartbeat sent one user's id under
168
+ another user's token and the database correctly refused it. Rebuilding
169
+ the client changed nothing, because the disk session was not the stale
170
+ half. It could never recover on its own: the retry re-sent the same
171
+ disagreement, several times a minute, forever.
172
+
173
+ Evidence it really happened here rather than in theory: `cliv2_agents`
174
+ holds TWO rows for this machine_id under two different user_ids, one of
175
+ them stuck at the epoch.
176
+
177
+ THE TOKEN IS THE AUTHORITY. `auth.uid()` is what the database will
178
+ believe whatever this process thinks, so the id is taken FROM the
179
+ rebuilt client rather than held against it. Warned rather than silent:
180
+ a daemon that starts heartbeating as a different user has had the
181
+ machine change owner underneath it, and that is worth a line. */
182
+ const { data } = await p.client.auth.getUser();
183
+ const currentUserId = data.user?.id;
184
+ if (currentUserId && currentUserId !== p.userId) {
185
+ console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
186
+ p.userId = currentUserId;
187
+ }
80
188
  }
81
189
  }
82
190
  catch { /* stay down until the next tick */ }
@@ -191,7 +299,7 @@ async function pollOrchestrator(p) {
191
299
  * already running. */
192
300
  export async function startPresence() {
193
301
  if (presence) {
194
- return { machineName: presence.identity.name, agents: presence.agents, client: presence.client };
302
+ return { machineName: presence.identity.name, agents: presence.agents };
195
303
  }
196
304
  if (starting)
197
305
  return starting;
@@ -199,6 +307,30 @@ export async function startPresence() {
199
307
  const identity = getMachineIdentity();
200
308
  const agents = detectAgents();
201
309
  const client = await getClient();
310
+ /* ═══ A DEAD SESSION STOPS THE LOOPS. IT DOES NOT SLOW THEM DOWN. ═══
311
+ The refresh token can die while the daemon runs (a re-login elsewhere
312
+ rotates it out from under this process), and until this existed nothing
313
+ reacted: `getClient`'s auto-refresh 400'd every ten seconds while all
314
+ three intervals kept firing with the expired access token. Read off the
315
+ hosted project's own logs, two minutes of it was 500 lines — 13 refusals
316
+ to refresh, and 487 401s and matching `42501 permission denied` rows
317
+ behind them, forever, because no poller here treats its own 401 as
318
+ terminal (`heartbeat` explicitly retries it, which is right for a blip
319
+ and wrong for this).
320
+
321
+ `SIGNED_OUT` is the truthful signal and the only one: auth-js fires it
322
+ from `_removeSession` only when a refresh fails non-retryably AND the
323
+ access token has already expired — a proactive refresh failure over a
324
+ still-valid token preserves the session, and a network blip is retryable,
325
+ so neither reaches here. Nothing on disk is touched: `cs logout` owns
326
+ deleting `session.json`, and this must not, or a machine that merely
327
+ needs a fresh token would look to the user like one that was signed out
328
+ deliberately.
329
+
330
+ Bound through `bindSignedOutWatcher` rather than inline, so this initial
331
+ binding and the heartbeat's post-rebuild rebind are the same code and
332
+ can never drift out of sync with each other. */
333
+ const authSubscription = bindSignedOutWatcher(client);
202
334
  const { data } = await client.auth.getUser();
203
335
  const userId = data.user?.id;
204
336
  if (!userId)
@@ -213,6 +345,8 @@ export async function startPresence() {
213
345
  cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
214
346
  ot: setInterval(() => { }, ORCHESTRATOR_POLL_INTERVAL_MS),
215
347
  listener: createListenerState(),
348
+ builtFromRefreshToken: readSession()?.refresh_token ?? null,
349
+ authSubscription,
216
350
  };
217
351
  clearInterval(p.hb);
218
352
  clearInterval(p.cp);
@@ -318,6 +452,21 @@ export async function startPresence() {
318
452
  // presence heartbeat above, so it's caught and warned like everything here.
319
453
  try {
320
454
  await startToolsServer({ client, userId, machineId: identity.id });
455
+ /* ═══ AND THE TOOLS SERVER IS POINTED AT WHATEVER CLIENT IS LIVE BY NOW,
456
+ WHICH IS NOT NECESSARILY THE ONE IT WAS JUST STARTED WITH. ═══
457
+ `startToolsServer` sets `toolsClient = deps.client`, and `deps.client`
458
+ is the local const above, which is the client this function built before
459
+ the first `heartbeat(p)` ran. That heartbeat can rebuild `p.client` (a
460
+ concurrent `cs login`, an account change on disk), and its own
461
+ `setToolsClient(p.client)` call was a SILENT NO-OP when it did, because
462
+ `setToolsClient` refuses while `handle` is unset and `handle` is only
463
+ set here, AFTER the first heartbeat. So a rebuild during startup left
464
+ the session-lifecycle heartbeat on the original client for the life of
465
+ the process, 401ing forever with nothing to re-set it: the CLI-v1
466
+ stale-token root cause, reintroduced by ordering alone.
467
+ `p.client` rather than `client`, because the whole point is to pick up a
468
+ rebuild the local const cannot see. */
469
+ setToolsClient(p.client);
321
470
  /* The instructions belong to the release, so they are rewritten beside
322
471
  registration on every start: the pair is "make this machine's agents
323
472
  ready", and a machine registered against current tools while reading a
@@ -333,7 +482,7 @@ export async function startPresence() {
333
482
  catch (err) {
334
483
  console.warn(`Agent tools server did not start: ${err.message}`);
335
484
  }
336
- return { machineName: identity.name, agents, client };
485
+ return { machineName: identity.name, agents };
337
486
  })();
338
487
  try {
339
488
  return await starting;
@@ -342,45 +491,75 @@ export async function startPresence() {
342
491
  starting = null;
343
492
  }
344
493
  }
345
- /** Go offline. Best-effort stamps last_seen_at into the past so the web sheet
346
- * reads offline immediately instead of waiting out the freshness window. Pass
347
- * `unregister` (logout only, never plain shutdown) to also remove the ctrl-spc
348
- * entry from each detected agent's config. */
349
- export async function stopPresence({ markOffline = true, unregister = false } = {}) {
494
+ /** Go offline. Best-effort stamps stopped_at so the web reads offline
495
+ * immediately; last_seen_at keeps the real last heartbeat (a Date(0) stamp
496
+ * here once rendered as "last seen 20694d ago"). Pass `unregister` (logout
497
+ * only, never plain shutdown) to also remove the ctrl-spc entry from each
498
+ * detected agent's config.
499
+ *
500
+ * A second call while one is already in flight AWAITS THE SAME WORK rather
501
+ * than returning early, mirroring `startPresence`'s `starting` guard above.
502
+ * `unregister` is read only from the caller that actually starts the stop;
503
+ * a second caller arriving mid-stop cannot change what is already running,
504
+ * so its own `unregister` value is not consulted. In practice the real
505
+ * collision is a plain shutdown racing a `SIGNED_OUT` stop with no
506
+ * `unregister` on either side (see `stopping`'s comment); `unregister: true`
507
+ * only ever comes from `cs logout`, called directly by the user, not from a
508
+ * watcher, so it is never the losing side of this race in practice. */
509
+ export async function stopPresence({ unregister = false } = {}) {
510
+ if (stopping)
511
+ return stopping;
350
512
  const p = presence;
351
513
  if (!p)
352
514
  return;
353
515
  presence = null;
354
- clearInterval(p.hb);
355
- clearInterval(p.cp);
356
- // The orchestrator listener stops with the presence. A surviving interval
357
- // would keep claiming and answering todos for a user who just signed out,
358
- // using a client whose session is about to be deleted. An agent already
359
- // spawned is left to finish killing it mid-run would abandon real work and
360
- // leave the todo claimed with no answer, which is the outcome the release
361
- // path exists to avoid; its answer write is the last thing it does, and the
362
- // ListenerState dies with `p` so nothing restarts on top of it.
363
- clearInterval(p.ot);
364
- // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
365
- // config so a logged-out machine leaves no dead server that would read "failed
366
- // to connect" on the next agent run. Fire-and-forget best-effort never blocks
367
- // logout. On a plain SIGINT shutdown the entries are intentionally left in place
368
- // (the caller passes no `unregister`); the badge just reads not-connected
369
- // because the server below is torn down.
370
- if (unregister) {
371
- if (p.agents.includes('claude'))
372
- void unregisterFromClaude();
373
- if (p.agents.includes('codex'))
374
- void unregisterFromCodex();
375
- }
376
- await stopToolsServer().catch((err) => console.warn(`Agent tools server did not stop cleanly: ${err.message}`));
377
- if (markOffline) {
516
+ stopping = (async () => {
517
+ clearInterval(p.hb);
518
+ clearInterval(p.cp);
519
+ // The orchestrator listener stops with the presence. A surviving interval
520
+ // would keep claiming and answering todos for a user who just signed out,
521
+ // using a client whose session is about to be deleted. An agent already
522
+ // spawned is left to finish killing it mid-run would abandon real work and
523
+ // leave the todo claimed with no answer, which is the outcome the release
524
+ // path exists to avoid; its answer write is the last thing it does, and the
525
+ // ListenerState dies with `p` so nothing restarts on top of it.
526
+ clearInterval(p.ot);
527
+ /* AND THE SIGNED_OUT WATCHER GOES WITH THE INTERVALS, for the same reason
528
+ the heartbeat's rebuild unsubscribes before it rebinds: an unsubscribed
529
+ handler cannot stop a presence the process no longer means it to. `cs
530
+ start` exits, so this is invisible there, but the companion does not:
531
+ `companion.ts` calls `stopPresence({ unregister: true })` on logout and
532
+ can call `startPresence()` again in the same process on a fresh sign-in.
533
+ The old client is still alive with `autoRefreshToken: true` and a revoked
534
+ token, and when it finally emits `SIGNED_OUT` its orphaned watcher would
535
+ call `stopPresence()` and kill the presence belonging to a DIFFERENT
536
+ session, one that is signed in and healthy. */
537
+ p.authSubscription.unsubscribe();
538
+ // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
539
+ // config so a logged-out machine leaves no dead server that would read "failed
540
+ // to connect" on the next agent run. Fire-and-forget best-effort — never blocks
541
+ // logout. On a plain SIGINT shutdown the entries are intentionally left in place
542
+ // (the caller passes no `unregister`); the badge just reads not-connected
543
+ // because the server below is torn down.
544
+ if (unregister) {
545
+ if (p.agents.includes('claude'))
546
+ void unregisterFromClaude();
547
+ if (p.agents.includes('codex'))
548
+ void unregisterFromCodex();
549
+ }
550
+ await stopToolsServer().catch((err) => console.warn(`Agent tools server did not stop cleanly: ${err.message}`));
378
551
  try {
379
552
  await p.client
380
553
  .from('cliv2_agents')
381
- .update({ last_seen_at: new Date(0).toISOString() })
554
+ .update({ stopped_at: new Date().toISOString() })
382
555
  .eq('machine_id', p.identity.id);
383
556
  }
384
- catch { /* ignore — a stale timestamp already reads as offline */ }
557
+ catch { /* ignore — a lapsed heartbeat reads as offline anyway */ }
558
+ })();
559
+ try {
560
+ await stopping;
561
+ }
562
+ finally {
563
+ stopping = null;
385
564
  }
386
565
  }
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile } from 'node:fs/promises';
2
2
  import { isAbsolute } from 'node:path';
3
3
  import { inflateSync } from 'node:zlib';
4
+ import { createHash } from 'node:crypto';
4
5
  export const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
5
6
  const MAX_DECODED_SCREENSHOT_BYTES = 256 * 1024 * 1024;
6
7
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -194,3 +195,47 @@ export async function readPngScreenshot(localPath) {
194
195
  }
195
196
  return { bytes, width, height, sizeBytes: bytes.length };
196
197
  }
198
+ /** 18c Slice 7. The deterministic id for a REQUEST-anchored screenshot.
199
+ *
200
+ * IT IS THE SAME DERIVATION WITH A DIFFERENT ANCHOR AND A DIFFERENT DOMAIN
201
+ * TAG, and both halves of that matter. Same derivation, because the property it
202
+ * buys is the one the work-item path already needs: an interrupted call that
203
+ * uploaded the object but never wrote the row retries onto the SAME key and
204
+ * converges, instead of leaking one private object per attempt. Different
205
+ * domain tag (`:todo:`), because the two anchors must not be able to collide —
206
+ * a task id and a request id are both uuids, and without the tag a screenshot
207
+ * with the same title, platform, target and bytes could derive one id under two
208
+ * anchors and have the second insert fail against the first's object. */
209
+ export function screenshotRequestId(todoId, title, platform, target, bytes) {
210
+ return screenshotDeterministicId('ctrl-spc:screenshot:todo:v1\0', todoId, title, platform, target, bytes);
211
+ }
212
+ export function screenshotArtifactId(taskId, title, platform, target, bytes) {
213
+ return screenshotDeterministicId('ctrl-spc:screenshot:v1\0', taskId, title, platform, target, bytes);
214
+ }
215
+ /** The shared body of the two id derivations above. Extracted rather than
216
+ * duplicated so the two anchors cannot drift into hashing different things:
217
+ * the whole point of a content-addressed id is that the same picture yields the
218
+ * same key, and two copies of this would eventually disagree about what "the
219
+ * same picture" means. `domain` is what keeps them distinct. */
220
+ function screenshotDeterministicId(domain, anchorId, title, platform, target, bytes) {
221
+ const digest = createHash('sha256')
222
+ .update(domain)
223
+ .update(anchorId)
224
+ .update('\0')
225
+ .update(title)
226
+ .update('\0')
227
+ .update(platform)
228
+ .update('\0')
229
+ .update(target)
230
+ .update('\0')
231
+ .update(bytes)
232
+ .digest()
233
+ .subarray(0, 16);
234
+ // RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
235
+ // a retry after Storage succeeded but Postgres failed converge on the same
236
+ // object instead of leaking one new object per retry.
237
+ digest[6] = (digest[6] & 0x0f) | 0x50;
238
+ digest[8] = (digest[8] & 0x3f) | 0x80;
239
+ const hex = digest.toString('hex');
240
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
241
+ }
package/dist/skills.js CHANGED
@@ -15,7 +15,7 @@ import { dirname, join } from 'node:path';
15
15
  */
16
16
  // Verbatim from the web/skill contract the copied `/ctrl-spc work <id>`
17
17
  // invocation depends on.
18
- const SKILL_DESCRIPTION = 'Work a CTRL+SPC work item or artifact by ID — delegates read-only exploration and writes analysis/plans/specs/diagrams/mocks/wireframes back via the ctrl-spc MCP tools. Use when the user pastes /ctrl-spc work <id> or /ctrl-spc artifact <id>.';
18
+ const SKILL_DESCRIPTION = 'Work a CTRL+SPC work item or artifact by ID — delegates read-only exploration and writes analysis/plans/specs/diagrams/mocks/wireframes back via the ctrl-spc MCP tools. Use when the user pastes /ctrl-spc work <id> or /ctrl-spc artifact <id>, and to fetch one object when they paste /ctrl-spc workflow <id>, /ctrl-spc skill <id>, /ctrl-spc credential <id> or /ctrl-spc document <id>.';
19
19
  /** Home the skill files are written under. `CTRL_SPC_HOME` is the existing
20
20
  * documented name for exactly this (docs/cli-surface-catalog.md), reused
21
21
  * rather than renamed so one machine has one sandbox-home concept. */
@@ -77,6 +77,15 @@ export function installSkills(agents) {
77
77
  */
78
78
  const PROTOCOL_BODY = `Resolve a CTRL+SPC work item or artifact pasted as \`/ctrl-spc work <id>\` or \`/ctrl-spc artifact <id>\`, then follow the ctrl-spc working protocol:
79
79
 
80
+ FETCH VERBS — \`workflow\`, \`skill\`, \`credential\` and \`document\` are NOT the working protocol. They name ONE object the user copied so they can use it in this conversation, and nothing below applies to them: no \`begin_work\`, no subagents, no context artifact, no question, no \`end_work\`. Fetch the object, use it for what the user asked, and answer.
81
+
82
+ - \`/ctrl-spc workflow <id>\` — call \`read_workflow_library\` and use the workflow whose \`id\` matches, with the full body of each stage it holds. To RUN it on a work item, call \`start_workflow\` with that workflow id, then follow the working protocol below for the item. Report and stop when the user only asked to see it.
83
+ - \`/ctrl-spc skill <id>\` — call \`get_skill\` with \`id\`. THE RESULT IS A DOCUMENT TO FOLLOW for the rest of this work, not reference material to summarise. Its supporting files come from \`read_skill_file\` with the same \`id\`.
84
+ - \`/ctrl-spc credential <id>\` — call \`get_credential\` with \`id\`, then USE the value to do the work: a header, an environment variable, a command, local config. Never publish it: keep it out of your answer, comments, artifacts and questions, and name the credential instead.
85
+ - \`/ctrl-spc document <id>\` — call \`get_document\` with \`id\`. One context document or one agent instruction, whole. It resolves either kind, so you do not need to know which the id names. To read a project's context as a whole instead, call \`get_project_context\`.
86
+
87
+ Pass the pasted id as \`id\`. It names exactly one object, so \`org_id\` is never needed with it and a duplicate name cannot send you to the wrong one. Everything that follows is the WORK ITEM protocol:
88
+
80
89
  WORKFLOW AUTHORITY — when \`get_task.workflow.enabled\` is true, follow only its current stage, stored instructions, capabilities, requirements, and gate. Never infer or skip a stage. Persist each required artifact, then call \`hand_off_stage\` with the work item id and this request id, and stop: the next stage is worked by a fresh agent reading the record. It refuses a stage with unfinished steps and the last stage of the process, where you answer and stop instead. Review gates advance only after explicit user approval in the web app or conversation; requested changes stay in the same stage. Only final approval sets Done.
81
90
 
82
91
  HARD STOP — unclear novel feature (No workflow only): when \`workflow.enabled\` is false, after \`record_context_exploration\` succeeds, if the task does not explicitly request a build or name a deliverable, the next tool call MUST be \`ask_question\` with category \`intent\`, the exact question “What should I produce for this feature?”, \`answer_mode = multi_select\`, and options \`build\`, \`plan\`, \`spec\`, \`diagram\`, \`mock\`, and \`wireframe\`. Then call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`. In that run, never call \`get_task\` again, \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or any other tool between the context artifact and \`ask_question\`. The context artifact is the only allowed artifact. Do not copy findings into the task description before the user answers. A read-only execution sandbox is not a missing checkout and must not change this intent question. The user may select one or more: build, plan, spec, diagram, mock, wireframe.
package/dist/supabase.js CHANGED
@@ -58,11 +58,38 @@ const retryingFetch = async (input, init) => {
58
58
  * lifetime: `autoRefreshToken: true` (v1 had it false with no refresh loop, so
59
59
  * after an hour every write 401'd silently) and the retrying fetch above.
60
60
  * Rotated tokens are written straight back to disk via onAuthStateChange.
61
+ *
62
+ * Pass `{ refreshing: false }` for a client that can never rotate the stored
63
+ * refresh token, for a caller that shares session.json with another process:
64
+ * two processes refreshing the same token family revoke it for both.
61
65
  */
62
- export async function getClient() {
66
+ export async function getClient({ refreshing = true } = {}) {
63
67
  const stored = readSession();
64
- if (!stored?.access_token || !stored?.refresh_token)
68
+ // The refreshing path still requires both, exactly as today. The
69
+ // non-refreshing one is never handed a refresh token, so it must not demand
70
+ // one: requiring it would refuse a session this client can legitimately use.
71
+ if (!stored?.access_token)
72
+ throw new NotLoggedIn();
73
+ if (refreshing && !stored?.refresh_token)
65
74
  throw new NotLoggedIn();
75
+ /* ═══ NOT REFRESHING MEANS NEVER HANDED THE REFRESH TOKEN. ═══ Not merely
76
+ `autoRefreshToken: false`: auth-js refreshes inside `setSession` whenever
77
+ the access token has already expired, regardless of that flag
78
+ (`GoTrueClient.js:2994`). A client never given the refresh token has
79
+ nothing to rotate, which is the only guarantee that holds. Two processes
80
+ rotating one `session.json` revoke the family for both.
81
+
82
+ An already-expired token is refused here rather than left to fail at the
83
+ first query, so callers keep the `NotLoggedIn` boundary they have today.
84
+ Same pattern as `scripts/prove-scope-history.mjs:34-41`. */
85
+ if (!refreshing) {
86
+ if (accessTokenExpired(stored.access_token))
87
+ throw new NotLoggedIn();
88
+ return createClient(SUPABASE_URL, SUPABASE_KEY, {
89
+ auth: { persistSession: false, autoRefreshToken: false },
90
+ global: { fetch: retryingFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
91
+ });
92
+ }
66
93
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
67
94
  auth: { persistSession: false, autoRefreshToken: true },
68
95
  global: { fetch: retryingFetch },
@@ -84,3 +111,17 @@ export async function getClient() {
84
111
  }
85
112
  return client;
86
113
  }
114
+ /** Whether a stored access token's own `exp` claim has already passed. A token
115
+ * this process cannot parse is treated as unusable rather than trusted: the
116
+ * caller's next request would 401 anyway, and `NotLoggedIn` is the honest
117
+ * answer at the boundary rather than a raw SyntaxError from the middle of a
118
+ * request handler. Exported so a test can pass a string and nothing else. */
119
+ export function accessTokenExpired(token) {
120
+ try {
121
+ const claims = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString());
122
+ return typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now();
123
+ }
124
+ catch {
125
+ return true;
126
+ }
127
+ }