@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.
- package/dist/companion.js +178 -18
- package/dist/daemon.js +103 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/login.js +9 -1
- package/dist/mcp.js +230 -308
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +22 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +802 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/skills.js +10 -1
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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({
|
|
554
|
+
.update({ stopped_at: new Date().toISOString() })
|
|
382
555
|
.eq('machine_id', p.identity.id);
|
|
383
556
|
}
|
|
384
|
-
catch { /* ignore — a
|
|
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
|
}
|
package/dist/screenshots.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|