@ctrl-spc/cs 0.1.0 → 0.3.0

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.
@@ -0,0 +1,206 @@
1
+ import { platform } from 'node:os';
2
+ import { getClient } from './supabase.js';
3
+ import { getMachineIdentity, supersededMachineIds, clearSupersededMachineIds } from './config.js';
4
+ import { detectAgents } from './agents.js';
5
+ import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
6
+ import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
7
+ let presence = null;
8
+ /** In-flight guard: startPresence yields to the event loop (network setSession)
9
+ * before `presence` is assigned, so a plain `if (presence)` check lets two
10
+ * concurrent callers both build interval pairs — the first pair then leaks and
11
+ * keeps heartbeating "online" past sign-out. Concurrent callers await this. */
12
+ let starting = null;
13
+ export function isPresenceRunning() {
14
+ return presence !== null;
15
+ }
16
+ async function heartbeat(p) {
17
+ try {
18
+ const { error } = await p.client
19
+ .from('cliv2_agents')
20
+ .upsert({
21
+ user_id: p.userId,
22
+ machine_id: p.identity.id,
23
+ machine_name: p.identity.name,
24
+ agents: p.agents,
25
+ platform: p.platform,
26
+ last_seen_at: new Date().toISOString(),
27
+ }, { onConflict: 'user_id,machine_id' });
28
+ if (error)
29
+ throw error;
30
+ }
31
+ catch (err) {
32
+ // Most likely a rotated/expired session or a network blip. Rebuild the
33
+ // client from disk (picks up any refreshed token) and try next tick.
34
+ console.warn(`heartbeat failed, will retry: ${err.message}`);
35
+ try {
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);
42
+ }
43
+ catch { /* stay down until the next tick */ }
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
+ }
72
+ }
73
+ /** Delete cloud rows for machine ids this install has migrated off of, so an old
74
+ * per-install id can't linger as a duplicate "offline" row for the same box.
75
+ * Runs after the first heartbeat, so the new row already exists throughout. */
76
+ async function cleanupSupersededRows(p) {
77
+ const stale = supersededMachineIds();
78
+ if (!stale.length)
79
+ return;
80
+ try {
81
+ // Reap BOTH per-machine_id tables in the same pass so they stay consistent:
82
+ // the orphaned cliv2_agents row (an offline duplicate) AND any orphaned
83
+ // cliv2_codebase_locations rows — otherwise the web would show a codebase as
84
+ // "not on any of your computers" while the companion still shows it located.
85
+ // The companion re-reports located from its local codebase-paths on the next
86
+ // add/locate, so a plain delete of the stale rows is enough. Only clear the
87
+ // marker once BOTH deletes succeed, so a partial failure retries next start.
88
+ const agents = await p.client.from('cliv2_agents').delete().in('machine_id', stale);
89
+ if (agents.error)
90
+ throw agents.error;
91
+ const locations = await p.client.from('cliv2_codebase_locations').delete().in('machine_id', stale);
92
+ if (locations.error)
93
+ throw locations.error;
94
+ clearSupersededMachineIds();
95
+ }
96
+ catch (err) {
97
+ // Best-effort — the orphaned rows are the pre-fix state. Retried next start.
98
+ console.warn(`superseded-row cleanup failed, will retry: ${err.message}`);
99
+ }
100
+ }
101
+ async function pollCommands(p) {
102
+ try {
103
+ const { data, error } = await p.client
104
+ .from('cliv2_commands')
105
+ .update({ status: 'ack', acked_at: new Date().toISOString() })
106
+ .eq('machine_id', p.identity.id)
107
+ .eq('status', 'pending')
108
+ .select('id, command');
109
+ if (error)
110
+ throw error;
111
+ for (const cmd of data ?? [])
112
+ console.log(`Acked ${cmd.command} (${cmd.id})`);
113
+ }
114
+ catch (err) {
115
+ console.warn(`command poll failed, will retry: ${err.message}`);
116
+ }
117
+ }
118
+ /** Come online. Throws NotLoggedIn (from getClient) if no session — callers in
119
+ * the companion guard for that; the terminal daemon lets it surface. No-op if
120
+ * already running. */
121
+ export async function startPresence() {
122
+ if (presence)
123
+ return { machineName: presence.identity.name, agents: presence.agents };
124
+ if (starting)
125
+ return starting;
126
+ starting = (async () => {
127
+ const identity = getMachineIdentity();
128
+ const agents = detectAgents();
129
+ const client = await getClient();
130
+ const { data } = await client.auth.getUser();
131
+ const userId = data.user?.id;
132
+ if (!userId)
133
+ throw new Error('Signed-in user could not be resolved. Sign in again.');
134
+ const p = {
135
+ client,
136
+ userId,
137
+ identity,
138
+ agents,
139
+ platform: platform(),
140
+ hb: setInterval(() => { }, HEARTBEAT_INTERVAL_MS),
141
+ cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
142
+ };
143
+ clearInterval(p.hb);
144
+ clearInterval(p.cp);
145
+ presence = p;
146
+ await heartbeat(p);
147
+ await cleanupSupersededRows(p);
148
+ p.hb = setInterval(() => void heartbeat(p), HEARTBEAT_INTERVAL_MS);
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
+ }
164
+ return { machineName: identity.name, agents };
165
+ })();
166
+ try {
167
+ return await starting;
168
+ }
169
+ finally {
170
+ starting = null;
171
+ }
172
+ }
173
+ /** Go offline. Best-effort stamps last_seen_at into the past so the web sheet
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 } = {}) {
178
+ const p = presence;
179
+ if (!p)
180
+ return;
181
+ presence = null;
182
+ clearInterval(p.hb);
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}`));
197
+ if (markOffline) {
198
+ try {
199
+ await p.client
200
+ .from('cliv2_agents')
201
+ .update({ last_seen_at: new Date(0).toISOString() })
202
+ .eq('machine_id', p.identity.id);
203
+ }
204
+ catch { /* ignore — a stale timestamp already reads as offline */ }
205
+ }
206
+ }
@@ -0,0 +1,32 @@
1
+ import { readMappings, writeMapping } from './config.js';
2
+ /**
3
+ * The signed-in user's projects, each merged with this machine's mapping.
4
+ *
5
+ * Projects come from the shared product `projects` table via the user's RLS
6
+ * (org-scoped — the same rows the web app sees). The per-machine folder + remote
7
+ * come from a LOCAL file (config.ts), never the cloud, so absolute paths never
8
+ * leave this machine — the product's path-privacy invariant. The v2 CLI thus
9
+ * only READS shared product data and never writes outside its own local state.
10
+ */
11
+ export async function loadProjects(client) {
12
+ const { data, error } = await client
13
+ .from('projects')
14
+ .select('id, name, git_remote_url')
15
+ .order('created_at', { ascending: true });
16
+ if (error)
17
+ throw new Error(error.message);
18
+ const mappings = readMappings();
19
+ return (data ?? []).map((project) => {
20
+ const mapping = mappings[project.id];
21
+ return {
22
+ id: project.id,
23
+ name: project.name ?? 'Untitled project',
24
+ localPath: mapping?.localPath ?? null,
25
+ gitRemoteUrl: mapping?.gitRemoteUrl ?? project.git_remote_url ?? null,
26
+ };
27
+ });
28
+ }
29
+ /** Saves this machine's mapping for one project — locally, never uploaded. */
30
+ export function saveMapping(projectId, mapping) {
31
+ writeMapping(projectId, mapping);
32
+ }
package/dist/supabase.js CHANGED
@@ -1,12 +1,32 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
3
- import { readSession, writeSession } from './config.js';
3
+ import { readSession, writeSession, getMachineIdentity } from './config.js';
4
4
  export class NotLoggedIn extends Error {
5
5
  constructor(message = 'Not logged in. Run `cs login` first.') {
6
6
  super(message);
7
7
  this.name = 'NotLoggedIn';
8
8
  }
9
9
  }
10
+ /**
11
+ * Sign in with email + password and persist the session to disk (shared with
12
+ * the terminal CLI via session.json). Used by the companion GUI's sign-in form,
13
+ * whose CSP keeps its page confined to this local server — the exchange with
14
+ * Supabase happens here, server-side, not in the browser.
15
+ */
16
+ export async function signIn(email, password) {
17
+ const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
18
+ auth: { persistSession: false, autoRefreshToken: false },
19
+ global: { fetch: retryingFetch },
20
+ });
21
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
22
+ if (error)
23
+ throw new Error(error.message);
24
+ if (!data.session || !data.user?.email)
25
+ throw new Error('Sign-in did not return a session.');
26
+ writeSession({ access_token: data.session.access_token, refresh_token: data.session.refresh_token });
27
+ getMachineIdentity(); // ensure a stable machine id exists post sign-in
28
+ return { email: data.user.email };
29
+ }
10
30
  /**
11
31
  * Network-layer retry — the v1 CLI's most valuable robustness trick. Retries
12
32
  * only fetch-level failures (DNS/wifi drop surface as TypeError); real HTTP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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"
@@ -21,7 +21,9 @@
21
21
  },
22
22
  "license": "UNLICENSED",
23
23
  "dependencies": {
24
- "@supabase/supabase-js": "^2.110.1"
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@supabase/supabase-js": "^2.110.1",
26
+ "zod": "^4.4.3"
25
27
  },
26
28
  "devDependencies": {
27
29
  "@types/node": "^26.1.1",