@ctrl-spc/cs 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/agents.js +6 -0
- package/dist/companion-ui.js +205 -3
- package/dist/companion.js +23 -1
- package/dist/config.js +14 -1
- package/dist/env.js +10 -0
- package/dist/mcp.js +2332 -0
- package/dist/package-version.js +10 -0
- package/dist/presence-heartbeat.js +13 -0
- package/dist/presence.js +70 -8
- package/dist/supabase.js +4 -1
- package/package.json +9 -3
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
// Resolve relative to this module so the same lookup works from source, built
|
|
3
|
+
// dist, and an installed npm package. npm always includes the package's root
|
|
4
|
+
// package.json, even though the published files allowlist contains only dist.
|
|
5
|
+
const packageMetadata = createRequire(import.meta.url)('../package.json');
|
|
6
|
+
if (typeof packageMetadata.version !== 'string' || packageMetadata.version.length === 0) {
|
|
7
|
+
throw new Error('The installed @ctrl-spc/cs package does not declare a version.');
|
|
8
|
+
}
|
|
9
|
+
/** The version of the package that is actually running this process. */
|
|
10
|
+
export const CLI_VERSION = packageMetadata.version;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CLI_VERSION } from './package-version.js';
|
|
2
|
+
/** Build the complete, path-free cliv2_agents row reported on every heartbeat. */
|
|
3
|
+
export function buildPresenceHeartbeatPayload(input, seenAt = new Date()) {
|
|
4
|
+
return {
|
|
5
|
+
user_id: input.userId,
|
|
6
|
+
machine_id: input.machineId,
|
|
7
|
+
machine_name: input.machineName,
|
|
8
|
+
agents: input.agents,
|
|
9
|
+
platform: input.platform,
|
|
10
|
+
cli_version: CLI_VERSION,
|
|
11
|
+
last_seen_at: seenAt.toISOString(),
|
|
12
|
+
};
|
|
13
|
+
}
|
package/dist/presence.js
CHANGED
|
@@ -2,7 +2,9 @@ import { platform } from 'node:os';
|
|
|
2
2
|
import { getClient } from './supabase.js';
|
|
3
3
|
import { getMachineIdentity, supersededMachineIds, clearSupersededMachineIds } from './config.js';
|
|
4
4
|
import { detectAgents } from './agents.js';
|
|
5
|
+
import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
|
|
5
6
|
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
7
|
+
import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
|
|
6
8
|
let presence = null;
|
|
7
9
|
/** In-flight guard: startPresence yields to the event loop (network setSession)
|
|
8
10
|
* before `presence` is assigned, so a plain `if (presence)` check lets two
|
|
@@ -16,14 +18,13 @@ async function heartbeat(p) {
|
|
|
16
18
|
try {
|
|
17
19
|
const { error } = await p.client
|
|
18
20
|
.from('cliv2_agents')
|
|
19
|
-
.upsert({
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
.upsert(buildPresenceHeartbeatPayload({
|
|
22
|
+
userId: p.userId,
|
|
23
|
+
machineId: p.identity.id,
|
|
24
|
+
machineName: p.identity.name,
|
|
23
25
|
agents: p.agents,
|
|
24
26
|
platform: p.platform,
|
|
25
|
-
|
|
26
|
-
}, { onConflict: 'user_id,machine_id' });
|
|
27
|
+
}), { onConflict: 'user_id,machine_id' });
|
|
27
28
|
if (error)
|
|
28
29
|
throw error;
|
|
29
30
|
}
|
|
@@ -33,9 +34,41 @@ async function heartbeat(p) {
|
|
|
33
34
|
console.warn(`heartbeat failed, will retry: ${err.message}`);
|
|
34
35
|
try {
|
|
35
36
|
p.client = await getClient();
|
|
37
|
+
// FIX 2: flow the rebuilt client (fresh/refreshed token) into the tools
|
|
38
|
+
// session-lifecycle heartbeat too, so a wedged token can't leave the session
|
|
39
|
+
// heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
|
|
40
|
+
// the tools server is running.
|
|
41
|
+
setToolsClient(p.client);
|
|
36
42
|
}
|
|
37
43
|
catch { /* stay down until the next tick */ }
|
|
38
44
|
}
|
|
45
|
+
// Re-attempt agent registration each heartbeat for any detected agent still
|
|
46
|
+
// idle or failed, while the tools server is up. Registration otherwise fires
|
|
47
|
+
// only ONCE at startPresence against that instant's detectAgents(): an agent
|
|
48
|
+
// installed AFTER sign-in would stay 'idle' forever (badge stuck "connecting…")
|
|
49
|
+
// and a 'failed' registration would never retry (making the "will retry" copy a
|
|
50
|
+
// lie). registerWithX's double-kick guard skips agents already 'registering',
|
|
51
|
+
// and 'registered' agents aren't retried, so this is safe to call every tick.
|
|
52
|
+
// Runs only in the presence process and stops when presence stops (this is the
|
|
53
|
+
// heartbeat, whose interval stopPresence clears).
|
|
54
|
+
// ponytail: a genuinely-unregisterable agent retries every ~10s indefinitely;
|
|
55
|
+
// add backoff if that churn ever matters.
|
|
56
|
+
const server = toolsServerStatus();
|
|
57
|
+
if (server.running) {
|
|
58
|
+
const status = agentRegStatus();
|
|
59
|
+
for (const agent of detectAgents()) {
|
|
60
|
+
if (status[agent] !== 'idle' && status[agent] !== 'failed')
|
|
61
|
+
continue;
|
|
62
|
+
if (agent === 'claude')
|
|
63
|
+
registerWithClaude(server.port);
|
|
64
|
+
else
|
|
65
|
+
registerWithCodex(server.port);
|
|
66
|
+
}
|
|
67
|
+
// D2b: while the tools server is up, keep every open work session's
|
|
68
|
+
// last_seen_at fresh so the web presence "working" chip stays lit while
|
|
69
|
+
// agents work. Best-effort/no-throw, exactly like the block above.
|
|
70
|
+
await heartbeatOpenSessions();
|
|
71
|
+
}
|
|
39
72
|
}
|
|
40
73
|
/** Delete cloud rows for machine ids this install has migrated off of, so an old
|
|
41
74
|
* per-install id can't linger as a duplicate "offline" row for the same box.
|
|
@@ -114,6 +147,20 @@ export async function startPresence() {
|
|
|
114
147
|
await cleanupSupersededRows(p);
|
|
115
148
|
p.hb = setInterval(() => void heartbeat(p), HEARTBEAT_INTERVAL_MS);
|
|
116
149
|
p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
|
|
150
|
+
// Bring up the local ctrl-spc MCP tools server and register it into Claude
|
|
151
|
+
// so any agent run on this machine has the tools with zero setup. Strictly
|
|
152
|
+
// best-effort: a tools-server or registration failure must never break the
|
|
153
|
+
// presence heartbeat above, so it's caught and warned like everything here.
|
|
154
|
+
try {
|
|
155
|
+
await startToolsServer({ client, userId, machineId: identity.id });
|
|
156
|
+
if (agents.includes('claude'))
|
|
157
|
+
registerWithClaude(toolsServerStatus().port);
|
|
158
|
+
if (agents.includes('codex'))
|
|
159
|
+
registerWithCodex(toolsServerStatus().port);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.warn(`Agent tools server did not start: ${err.message}`);
|
|
163
|
+
}
|
|
117
164
|
return { machineName: identity.name, agents };
|
|
118
165
|
})();
|
|
119
166
|
try {
|
|
@@ -124,14 +171,29 @@ export async function startPresence() {
|
|
|
124
171
|
}
|
|
125
172
|
}
|
|
126
173
|
/** Go offline. Best-effort stamps last_seen_at into the past so the web sheet
|
|
127
|
-
* reads offline immediately instead of waiting out the freshness window.
|
|
128
|
-
|
|
174
|
+
* reads offline immediately instead of waiting out the freshness window. Pass
|
|
175
|
+
* `unregister` (logout only, never plain shutdown) to also remove the ctrl-spc
|
|
176
|
+
* entry from each detected agent's config. */
|
|
177
|
+
export async function stopPresence({ markOffline = true, unregister = false } = {}) {
|
|
129
178
|
const p = presence;
|
|
130
179
|
if (!p)
|
|
131
180
|
return;
|
|
132
181
|
presence = null;
|
|
133
182
|
clearInterval(p.hb);
|
|
134
183
|
clearInterval(p.cp);
|
|
184
|
+
// On logout (unregister), scrub the ctrl-spc entry from each detected agent's
|
|
185
|
+
// config so a logged-out machine leaves no dead server that would read "failed
|
|
186
|
+
// to connect" on the next agent run. Fire-and-forget best-effort — never blocks
|
|
187
|
+
// logout. On a plain SIGINT shutdown the entries are intentionally left in place
|
|
188
|
+
// (the caller passes no `unregister`); the badge just reads not-connected
|
|
189
|
+
// because the server below is torn down.
|
|
190
|
+
if (unregister) {
|
|
191
|
+
if (p.agents.includes('claude'))
|
|
192
|
+
void unregisterFromClaude();
|
|
193
|
+
if (p.agents.includes('codex'))
|
|
194
|
+
void unregisterFromCodex();
|
|
195
|
+
}
|
|
196
|
+
await stopToolsServer().catch((err) => console.warn(`Agent tools server did not stop cleanly: ${err.message}`));
|
|
135
197
|
if (markOffline) {
|
|
136
198
|
try {
|
|
137
199
|
await p.client
|
package/dist/supabase.js
CHANGED
|
@@ -68,7 +68,10 @@ export async function getClient() {
|
|
|
68
68
|
global: { fetch: retryingFetch },
|
|
69
69
|
});
|
|
70
70
|
client.auth.onAuthStateChange((_event, session) => {
|
|
71
|
-
|
|
71
|
+
// Update-only: a token refresh must never recreate session.json after
|
|
72
|
+
// `cs logout` deleted it, or the logged-out machine would re-authorize
|
|
73
|
+
// itself up to an hour later.
|
|
74
|
+
if (session && readSession()) {
|
|
72
75
|
writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
|
|
73
76
|
}
|
|
74
77
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl-spc/cs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -17,11 +17,17 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc",
|
|
20
|
-
"start": "npm run build && node dist/index.js"
|
|
20
|
+
"start": "npm run build && node dist/index.js",
|
|
21
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
21
22
|
},
|
|
22
23
|
"license": "UNLICENSED",
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
+
"@supabase/supabase-js": "^2.110.1",
|
|
27
|
+
"zod": "^4.4.3"
|
|
28
|
+
},
|
|
29
|
+
"overrides": {
|
|
30
|
+
"@hono/node-server": "^2.0.5"
|
|
25
31
|
},
|
|
26
32
|
"devDependencies": {
|
|
27
33
|
"@types/node": "^26.1.1",
|