@ctrl-spc/cs 0.7.13 → 0.7.15

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/daemon.js CHANGED
@@ -1,49 +1,17 @@
1
- import { ensureAutostart } from './autostart.js';
2
- import { liveClient, startPresence, stopPresence } from './presence.js';
3
- /**
4
- * The terminal presence daemon (`cs start`). Comes online and heartbeats until
5
- * the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
6
- * restarted by the OS. The companion server (`cs open`) shares the same presence
7
- * loop via presence.ts, so the two front-ends never diverge.
8
- *
9
- * ═══ AND SINCE recovery-1 SLICE 4 IT ALSO ANSWERS AGENT PANEL CARDS. ═══ Lane's
10
- * ruling (2026-08-21): *the user experience must never require them to launch or
11
- * authenticate multiple CLIs. They launch the CLI with `cs start`.* Before this
12
- * the panel was a second binary with a second sign-in, so a stranded card could
13
- * name the right machine and print a command that started the wrong daemon.
14
- *
15
- * THE PANEL GETS THE SESSION THIS ALREADY HOLDS, never one of its own. Two
16
- * clients in one process is two refresh loops on one rotating refresh token,
17
- * both writing `session.json`.
18
- *
19
- * Companion and the terminal daemon both use the same presence lifecycle,
20
- * which owns the card worker, session and per-machine lock.
21
- */
1
+ import { inspectLocalRuntime, startLocalRuntime, stopLocalOwnerForSignal } from './daemon-lifecycle.js';
2
+ import { CLI_VERSION } from './package-version.js';
3
+ import { machineHostname } from './config.js';
4
+ /** Foreground entry; local lifecycle owns service control and cloud reconnects. */
22
5
  export async function runDaemon() {
23
- const { machineName, agents } = await startPresence();
24
- ensureAutostart(); // default-on: install the login item unless the user opted out
25
- console.log(`CTRL+SPC this computer: ${machineName}`);
26
- console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
27
- console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
28
- let stopping = false;
29
- async function shutdown(code = 0) {
30
- if (stopping)
31
- return;
32
- stopping = true;
33
- // Shared presence stops the card worker and both readiness records.
34
- await stopPresence();
35
- process.exit(code);
6
+ if (!await startLocalRuntime()) {
7
+ const current = await inspectLocalRuntime();
8
+ console.log(`CTRL+SPC is already running on ${machineHostname()} (pid ${current?.record.process.pid ?? 'unknown'}).`);
9
+ console.log(`Running version: ${current?.status?.version ?? 'unavailable'}. Installed version: ${CLI_VERSION}.`);
10
+ console.log('Use cs status to check it, cs stop to stop it, or cs restart to load this installed version.');
11
+ return;
36
12
  }
37
- process.on('SIGINT', () => void shutdown());
38
- process.on('SIGTERM', () => void shutdown());
39
- // Presence owns sign-out cleanup; the terminal entry also exits.
40
- const live = liveClient();
41
- if (!live)
42
- throw new Error('the presence loop is not running, so there is no session to watch');
43
- live.auth.onAuthStateChange((event) => {
44
- if (event === 'SIGNED_OUT')
45
- void shutdown(1);
46
- });
47
- // Keep the event loop alive.
48
- await new Promise(() => { });
13
+ console.log(`CTRL+SPC is running on ${machineHostname()}. Loaded version: ${CLI_VERSION}.`);
14
+ console.log('Cloud connection is being checked. Run cs status for readiness. Use cs stop or cs restart to manage this service.');
15
+ process.on('SIGINT', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
16
+ process.on('SIGTERM', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
49
17
  }
@@ -0,0 +1,340 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { createServer, createConnection } from 'node:net';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const execute = promisify(execFile);
9
+ const modulePath = fileURLToPath(import.meta.url);
10
+ const executable = join(dirname(modulePath), 'native', 'darwin-coalition');
11
+ export function validMacCoalition(value) {
12
+ const v = value;
13
+ return !!v && /^\d+$/.test(v.id) && /^[\da-f-]{36}$/i.test(v.boot) && /^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(v.label)
14
+ && (v.domain === undefined || v.domain === 'gui' || v.domain === 'user');
15
+ }
16
+ function timeout(deadline) {
17
+ if (Date.now() >= deadline)
18
+ throw new Error('Service operation exceeded its deadline.');
19
+ return Math.max(1, Math.min(5000, deadline - Date.now()));
20
+ }
21
+ async function native(args, deadline = Date.now() + 5000) {
22
+ for (;;) {
23
+ try {
24
+ const { stdout } = await execute(executable, args, { timeout: timeout(deadline), maxBuffer: 8 * 1024 * 1024 });
25
+ return stdout.trim() ? JSON.parse(stdout) : null;
26
+ }
27
+ catch (error) {
28
+ // A fork/exit between enumeration and the atomic count is a known,
29
+ // read-only race. Retry only this result within the caller's deadline.
30
+ if (error.code !== 75 || Date.now() + 25 >= deadline) {
31
+ // Preparation failures can become cloud-visible text. execFile's own
32
+ // message embeds local executable paths; the helper's fixed messages
33
+ // contain only the operation and OS error number.
34
+ const detail = error.stderr;
35
+ throw new Error(typeof detail === 'string' && detail.trim() ? detail.trim().slice(0, 500)
36
+ : 'Mac execution ownership could not be verified. Reinstall CTRL+SPC or retry the command.', { cause: error });
37
+ }
38
+ await new Promise((resolve) => setTimeout(resolve, 25));
39
+ }
40
+ }
41
+ }
42
+ async function processCoalition(pid, deadline) {
43
+ const value = await native(['process', String(pid)], deadline);
44
+ if (value === null)
45
+ return null;
46
+ if (!value || typeof value.id !== 'string' || !/^\d+$/.test(value.id) || typeof value.boot !== 'string' || !/^[\da-f-]{36}$/i.test(value.boot)
47
+ || value.pid !== pid || value.owner !== process.getuid())
48
+ throw new Error('Mac execution identity is unavailable.');
49
+ return value;
50
+ }
51
+ export async function inspectMacCoalition(coalition, deadline = Date.now() + 5000) {
52
+ if (!validMacCoalition(coalition))
53
+ throw new Error('Mac execution identity is invalid.');
54
+ const result = await native(['inspect', coalition.id, coalition.boot], deadline);
55
+ if (!result || typeof result.exists !== 'boolean' || !Number.isSafeInteger(result.active) || result.active < 0 || !Array.isArray(result.members))
56
+ throw new Error('Mac execution membership is unavailable.');
57
+ const rows = result.members.map((row) => nativeIdentity(row, coalition));
58
+ if (result.active > rows.length || (result.active === 0 && rows.length))
59
+ throw new Error('Mac execution membership changed. Retry the command.');
60
+ return rows;
61
+ }
62
+ function nativeIdentity(row, coalition) {
63
+ if (!row || !Number.isInteger(row.pid) || row.pid <= 0 || !Number.isInteger(row.ppid) || !Number.isInteger(row.pgid)
64
+ || row.owner !== String(process.getuid()) || typeof row.birth !== 'string' || !Number.isFinite(Date.parse(row.birth))
65
+ || !Number.isInteger(row.version) || row.version <= 0)
66
+ throw new Error('Mac process identity is invalid.');
67
+ return { pid: row.pid, ppid: row.ppid, pgid: row.pgid, owner: row.owner, birth: row.birth,
68
+ commandHash: createHash('sha256').update(`${row.pid}:${row.version}`).digest('hex'), macProcessVersion: row.version, ...(coalition ? { macCoalition: coalition } : {}) };
69
+ }
70
+ export async function inspectMacProcess(pid, deadline) {
71
+ const result = await native(['process', String(pid)], deadline);
72
+ if (result === null)
73
+ return null;
74
+ return nativeIdentity({ ...result, owner: String(result.owner) });
75
+ }
76
+ export async function inspectMacProcesses(deadline) {
77
+ const result = await native(['all'], deadline);
78
+ if (!result || !Array.isArray(result.members))
79
+ throw new Error('Mac process enumeration is unavailable.');
80
+ return result.members.map((row) => nativeIdentity(row));
81
+ }
82
+ export async function signalMacMember(identity, deadline, signal = 'KILL') {
83
+ if (!identity.macCoalition || !identity.macProcessVersion)
84
+ throw new Error('Mac process ownership is unavailable.');
85
+ await native(['signal', identity.macCoalition.id, identity.macCoalition.boot, String(identity.pid), String(identity.macProcessVersion), signal], deadline);
86
+ }
87
+ export async function signalMacProcess(identity, deadline) {
88
+ if (identity.owner !== String(process.getuid()))
89
+ throw new Error('Mac process belongs to another user.');
90
+ const own = await processCoalition(process.pid, deadline);
91
+ if (!own)
92
+ throw new Error('Mac process signaling is unavailable.');
93
+ await native(['signal-process', own.boot, String(identity.pid), identity.birth], deadline);
94
+ }
95
+ export async function unloadMacExecution(coalition, deadline) {
96
+ if (!validMacCoalition(coalition))
97
+ throw new Error('Mac execution identity is invalid.');
98
+ // Only the first unpublished candidate omitted this field and used gui.
99
+ // Never try both namespaces: the recorded one is the only owned job.
100
+ try {
101
+ await execute('/bin/launchctl', ['bootout', `${coalition.domain ?? 'gui'}/${process.getuid()}/${coalition.label}`], { timeout: timeout(deadline) });
102
+ }
103
+ catch (error) {
104
+ if (![3, 113].includes(error.code ?? -1))
105
+ throw new Error('The owned Mac execution job could not be unloaded.', { cause: error });
106
+ }
107
+ }
108
+ export function spawnMacExecution(bin, args, options, reservationId, file) {
109
+ if (!existsSync(executable))
110
+ throw new Error('The Mac execution helper is missing. Reinstall CTRL+SPC before starting work.');
111
+ if (!/^[\da-f-]{36}$/i.test(reservationId))
112
+ throw new Error('Invalid execution reservation.');
113
+ // A private short path avoids sockaddr_un's path limit for long config dirs.
114
+ const directory = mkdtempSync(`/tmp/ctrl-spc-execution-${process.getuid()}-`);
115
+ chmodSync(directory, 0o700);
116
+ const access = statSync(directory);
117
+ if (access.uid !== process.getuid() || (access.mode & 0o777) !== 0o700)
118
+ throw new Error('Mac execution transport could not be made private.');
119
+ const ready = join(directory, 'ready.json');
120
+ const launch = { bin, args, cwd: options.cwd?.toString() ?? process.cwd(), env: options.env ?? process.env,
121
+ shell: options.shell ?? false, label: `com.ctrl-spc.execution.${reservationId}`, domain: 'user', directory, ready };
122
+ try {
123
+ writeFileSync(file, JSON.stringify(launch), { flag: 'wx', mode: 0o600 });
124
+ return { child: spawn(process.execPath, [modulePath, '--bridge', file], { ...options, shell: false, detached: true }), ready };
125
+ }
126
+ catch (error) {
127
+ rmSync(directory, { recursive: true, force: true });
128
+ rmSync(file, { force: true });
129
+ throw error;
130
+ }
131
+ }
132
+ export function readMacExecution(ready) {
133
+ let text;
134
+ try {
135
+ text = readFileSync(ready, 'utf8');
136
+ }
137
+ catch (error) {
138
+ if (error.code === 'ENOENT')
139
+ return null;
140
+ throw error;
141
+ }
142
+ const result = JSON.parse(text);
143
+ if (!validMacCoalition(result))
144
+ throw new Error('Mac execution identity is invalid.');
145
+ return result;
146
+ }
147
+ export function removeMacExecutionFiles(ready) { rmSync(dirname(ready), { recursive: true, force: true }); }
148
+ function send(socket, frame, source) {
149
+ if (!socket.write(JSON.stringify(frame) + '\n') && source) {
150
+ source.pause();
151
+ socket.once('drain', () => source.resume());
152
+ }
153
+ }
154
+ function frames(socket, receive) {
155
+ let pending = '';
156
+ socket.setEncoding('utf8');
157
+ socket.on('data', (chunk) => {
158
+ pending += chunk;
159
+ if (pending.length > 2 * 1024 * 1024) {
160
+ socket.destroy(new Error('Mac execution transport exceeded its frame limit.'));
161
+ return;
162
+ }
163
+ let end;
164
+ while ((end = pending.indexOf('\n')) >= 0) {
165
+ const line = pending.slice(0, end);
166
+ pending = pending.slice(end + 1);
167
+ try {
168
+ receive(JSON.parse(line));
169
+ }
170
+ catch {
171
+ socket.destroy(new Error('Mac execution transport is invalid.'));
172
+ return;
173
+ }
174
+ }
175
+ });
176
+ }
177
+ function xml(text) { return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
178
+ async function bridge(file) {
179
+ const launch = JSON.parse(readFileSync(file, 'utf8'));
180
+ const own = await processCoalition(process.pid);
181
+ if (!own)
182
+ throw new Error('Mac execution capability is unavailable; no agent was started.');
183
+ const socketPath = join(launch.directory, 'io.sock'), plist = join(launch.directory, 'job.plist');
184
+ let socket = null, connected = false, ended = false, exitReceived = false;
185
+ let jobIdentity = null;
186
+ process.stdin.pause();
187
+ const server = createServer((peer) => {
188
+ if (connected) {
189
+ peer.destroy();
190
+ return;
191
+ }
192
+ connected = true;
193
+ socket = peer;
194
+ peer.on('error', () => { process.exitCode = 1; });
195
+ peer.on('close', () => {
196
+ if (!exitReceived)
197
+ process.exitCode = 1;
198
+ process.stdin.destroy();
199
+ if (!exitReceived)
200
+ server.close();
201
+ });
202
+ frames(peer, (frame) => {
203
+ if (frame.kind === 'hello' && typeof frame.pid === 'number') {
204
+ void (async () => {
205
+ const identity = await processCoalition(frame.pid);
206
+ if (!identity || identity.id === own.id)
207
+ throw new Error('The Mac execution job is not isolated.');
208
+ const coalition = { id: identity.id, boot: identity.boot, label: launch.label, domain: launch.domain };
209
+ const members = await inspectMacCoalition(coalition);
210
+ if (members.length !== 1 || members[0].pid !== frame.pid)
211
+ throw new Error('The Mac execution job is not exclusively owned.');
212
+ jobIdentity = members[0];
213
+ const temp = launch.ready + '.tmp';
214
+ writeFileSync(temp, JSON.stringify(coalition), { flag: 'wx', mode: 0o600 });
215
+ renameSync(temp, launch.ready);
216
+ process.stdin.on('data', (chunk) => send(peer, { kind: 'stdin', data: chunk.toString('base64') }, process.stdin));
217
+ process.stdin.on('end', () => { ended = true; send(peer, { kind: 'end' }); });
218
+ process.stdin.resume();
219
+ })().catch(() => { console.error('The Mac execution job could not be safely registered.'); process.exitCode = 1; peer.destroy(); });
220
+ }
221
+ else if ((frame.kind === 'stdout' || frame.kind === 'stderr') && typeof frame.data === 'string') {
222
+ const output = frame.kind === 'stdout' ? process.stdout : process.stderr;
223
+ if (!output.write(Buffer.from(frame.data, 'base64'))) {
224
+ peer.pause();
225
+ output.once('drain', () => peer.resume());
226
+ }
227
+ }
228
+ else if (frame.kind === 'exit' && typeof frame.code === 'number') {
229
+ exitReceived = true;
230
+ process.exitCode = frame.code;
231
+ peer.end();
232
+ process.stdin.destroy();
233
+ void (async () => {
234
+ const deadline = Date.now() + 5000;
235
+ while (jobIdentity && Date.now() < deadline) {
236
+ const current = await inspectMacProcess(jobIdentity.pid, deadline);
237
+ if (!current || current.birth !== jobIdentity.birth)
238
+ return;
239
+ await new Promise((resolve) => setTimeout(resolve, 10));
240
+ }
241
+ if (jobIdentity)
242
+ throw new Error('Mac execution helper did not close.');
243
+ })().catch(() => { process.exitCode = 1; }).finally(() => server.close());
244
+ }
245
+ else
246
+ throw new Error('Invalid execution transport frame.');
247
+ });
248
+ });
249
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
250
+ const content = '<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>' + launch.label + '</string>'
251
+ + '<key>ProgramArguments</key><array>' + [process.execPath, modulePath, '--job', file, launch.domain].map((value) => '<string>' + xml(value) + '</string>').join('') + '</array>'
252
+ + '<key>LimitLoadToSessionType</key><string>Background</string><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>AbandonProcessGroup</key><true/></dict></plist>';
253
+ writeFileSync(plist, content, { flag: 'wx', mode: 0o600 });
254
+ try {
255
+ await execute('/bin/launchctl', ['bootstrap', `${launch.domain}/${process.getuid()}`, plist], { timeout: 5000 });
256
+ }
257
+ catch (error) {
258
+ socket?.destroy();
259
+ server.close();
260
+ throw error;
261
+ }
262
+ // Only bootstrap/registration is bounded. Running agents have no timer.
263
+ const timer = setTimeout(() => { if (!existsSync(launch.ready)) {
264
+ console.error('The Mac execution job did not become ready.');
265
+ process.exitCode = 1;
266
+ socket?.destroy();
267
+ server.close();
268
+ } }, 5000);
269
+ timer.unref();
270
+ process.on('exit', () => { if (!ended)
271
+ process.stdin.destroy(); });
272
+ }
273
+ function job(file) {
274
+ const closeUnpromptedJob = (label, domain = process.argv[4] === 'user' ? 'user' : 'gui') => {
275
+ if (!label || !/^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(label))
276
+ return;
277
+ // The creator can disappear before this queued job even reads its launch
278
+ // file. No grant can then arrive; remove this exact one-shot job as well.
279
+ void execute('/bin/launchctl', ['bootout', `${domain}/${process.getuid()}/${label}`], { timeout: 5000 }).catch(() => { process.exitCode = 1; });
280
+ };
281
+ let launch;
282
+ try {
283
+ launch = JSON.parse(readFileSync(file, 'utf8'));
284
+ }
285
+ catch {
286
+ closeUnpromptedJob(process.env.XPC_SERVICE_NAME);
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ const socket = createConnection(join(launch.directory, 'io.sock'));
291
+ let provider = null, admitted = false;
292
+ socket.on('connect', () => send(socket, { kind: 'hello', pid: process.pid }));
293
+ socket.on('error', () => { if (!admitted)
294
+ process.exitCode = 1; });
295
+ socket.on('close', () => { if (!admitted)
296
+ closeUnpromptedJob(launch.label, launch.domain ?? 'gui'); });
297
+ frames(socket, (frame) => {
298
+ if (frame.kind === 'stdin' && typeof frame.data === 'string') {
299
+ let bytes = Buffer.from(frame.data, 'base64');
300
+ if (!admitted) {
301
+ if (!bytes.length || bytes[0] !== 1)
302
+ throw new Error('Invalid execution grant.');
303
+ admitted = true;
304
+ bytes = bytes.subarray(1);
305
+ provider = spawn(launch.bin, launch.args, { cwd: launch.cwd, env: launch.env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'] });
306
+ provider.stdin?.on('error', () => { });
307
+ provider.stdout?.on('data', (chunk) => { if (!socket.destroyed)
308
+ send(socket, { kind: 'stdout', data: chunk.toString('base64') }, provider.stdout); });
309
+ provider.stderr?.on('data', (chunk) => { if (!socket.destroyed)
310
+ send(socket, { kind: 'stderr', data: chunk.toString('base64') }, provider.stderr); });
311
+ provider.on('error', () => { if (!socket.destroyed)
312
+ send(socket, { kind: 'stderr', data: Buffer.from('The harness could not start.').toString('base64') }); });
313
+ provider.on('close', (code, signal) => { if (!socket.destroyed) {
314
+ send(socket, { kind: 'exit', code: code ?? 1, signal });
315
+ socket.end();
316
+ } });
317
+ }
318
+ if (bytes.length && !provider?.stdin?.write(bytes)) {
319
+ socket.pause();
320
+ provider?.stdin?.once('drain', () => socket.resume());
321
+ }
322
+ }
323
+ else if (frame.kind === 'end') {
324
+ if (provider)
325
+ provider.stdin?.end();
326
+ else {
327
+ send(socket, { kind: 'exit', code: 0 });
328
+ socket.end();
329
+ }
330
+ }
331
+ else
332
+ throw new Error('Invalid execution transport frame.');
333
+ });
334
+ }
335
+ if (process.argv[1] === modulePath) {
336
+ if (process.argv[2] === '--bridge')
337
+ void bridge(process.argv[3]).catch(() => { console.error('Mac safe execution is unavailable; no agent was started.'); process.exitCode = 1; });
338
+ else if (process.argv[2] === '--job')
339
+ job(process.argv[3]);
340
+ }
package/dist/index.js CHANGED
@@ -4,9 +4,11 @@ import { runDaemon } from './daemon.js';
4
4
  import { openCompanion } from './companion.js';
5
5
  import { autostartOn, autostartOff } from './autostart.js';
6
6
  import { detectAgents } from './agents.js';
7
- import { getMachineIdentity, clearSession, readSession } from './config.js';
8
- import { getClient } from './supabase.js';
9
- import { foreignToolsServerAlive, claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
7
+ import { getMachineIdentity, clearSession, readSession, machineHostname } from './config.js';
8
+ import { CLI_VERSION } from './package-version.js';
9
+ import { inspectLocalRuntime, runLifecycleCommand } from './daemon-lifecycle.js';
10
+ import { readMigration, readRuntime } from './daemon-lock.js';
11
+ import { claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
10
12
  import { panelCommand } from './panel3/cli.js';
11
13
  const HELP = `cs — CTRL+SPC
12
14
 
@@ -14,7 +16,11 @@ const HELP = `cs — CTRL+SPC
14
16
  cs open Open the Companion app in your browser
15
17
  cs login Sign in from the terminal and link this computer
16
18
  cs start Come online and answer cards, no window (used by auto-start)
17
- cs status Show the whole setup state and the one next step to take
19
+ cs stop Stop this service; refuses while work is active
20
+ cs restart Load this installed version and keep it running in background
21
+ cs stop --force Interrupt owned work, save recovery, then stop
22
+ cs restart --force Interrupt owned work, then load this installed version
23
+ cs status Show local service, cloud readiness and running version
18
24
  cs autostart on Come online automatically at login
19
25
  cs autostart off Stop coming online at login
20
26
  cs logout Sign this computer out
@@ -28,80 +34,62 @@ const HELP = `cs — CTRL+SPC
28
34
 
29
35
  cs help Show this help
30
36
  `;
31
- /**
32
- * The whole setup state, and the ONE next step to take, for an agent — the
33
- * reader of this command in every story is Claude Code or Codex, not a person
34
- * at a prompt, so every step below addresses the agent and names the user only
35
- * for sign-in, which is the one step only the user can take.
36
- *
37
- * A non-zero exit means "not fully set up", NEVER "a command failed". A machine
38
- * mid-setup exits 1 while everything is working correctly.
39
- */
37
+ /** Inspection never creates a competing cloud refresh owner. */
40
38
  async function status() {
41
- const id = getMachineIdentity();
39
+ const machine = getMachineIdentity();
42
40
  const installed = detectAgents();
43
- /* Signed-out is a FILE question, not a network one. getClient() throws
44
- NotLoggedIn whenever setSession fails FOR ANY REASON, including a network
45
- failure (supabase.ts), so trusting it would tell a signed-in user on bad
46
- wifi to go and sign in again the confident-wrong instruction this feature
47
- exists to kill. readSession() returning null is the only true "signed out". */
48
- const stored = readSession();
49
- let signedIn = false;
50
- let email = null;
51
- let sessionProblem = null;
52
- if (stored) {
53
- try {
54
- const client = await getClient();
55
- const { data } = await client.auth.getUser();
56
- email = data.user?.email ?? null;
57
- signedIn = true;
41
+ console.log('Computer: ' + machine.name);
42
+ console.log('Installed CLI version: ' + CLI_VERSION);
43
+ console.log('Harnesses installed: ' + (installed.join(', ') || 'none'));
44
+ console.log('Harness sign-in: not checked by this command; each work request checks its selected harness.');
45
+ const inspection = await inspectLocalRuntime();
46
+ if (inspection?.status) {
47
+ const current = inspection.status;
48
+ console.log('Local service: ' + current.local + ' (pid ' + current.pid + ')');
49
+ console.log('Running version: ' + current.version + ' — verified');
50
+ console.log('Cloud connection: ' + current.cloud);
51
+ const work = [...current.work.active, ...current.work.pending, ...current.work.unknown];
52
+ console.log('Owned work: ' + work.length + (current.work.unknown.length ? ' (includes unverified execution)' : ''));
53
+ for (const item of work)
54
+ console.log(' ' + (item.cardId ?? item.workId ?? 'Card identity unavailable') + ' — ' + (item.harness ?? 'harness unknown'));
55
+ if (current.work.receipts || current.work.legacyHeldTodoIds.length)
56
+ console.log('Saved interruptions are waiting for reconciliation or explicit continuation.');
57
+ if (current.cloud === 'sign-in-required')
58
+ console.log('Run cs login on ' + machine.name + '. Local stop and restart remain available.');
59
+ else if (current.cloud !== 'online')
60
+ console.log('Cloud readiness is not confirmed. Check the connection; cs status will show when it is ready.');
61
+ if (current.version !== CLI_VERSION)
62
+ console.log('Run cs restart to activate the installed CLI version.');
63
+ else
64
+ console.log('Stop: cs stop. Restart this installed version: cs restart.');
65
+ console.log('Explicit interruption: cs stop --force or cs restart --force. Interrupted cards need a new message to continue.');
66
+ if (current.cloud === 'online') {
67
+ for (const agent of installed)
68
+ console.log(' ' + agent + ': ' + ((agent === 'claude' ? claudeRegisteredOnDisk() : codexRegisteredOnDisk()) ? 'registered' : 'not registered'));
58
69
  }
59
- catch (err) {
60
- /* The REASON only. getClient()'s NotLoggedIn message ends with its own
61
- "Run `cs login` again.", which would print twice in the step below. */
62
- const raw = err instanceof Error ? err.message : String(err);
63
- sessionProblem = raw.replace(/\s*Run `cs login` again\.?\s*$/, '').replace(/\.$/, '');
64
- }
65
- }
66
- const serving = await foreignToolsServerAlive();
67
- /* Gated on `serving` because a plain shutdown DELIBERATELY leaves the agent
68
- config entries in place (presence.ts), so an ungated read prints
69
- "registered" against a daemon that is not there. */
70
- const registered = {
71
- claude: serving && claudeRegisteredOnDisk(),
72
- codex: serving && codexRegisteredOnDisk(),
73
- };
74
- console.log(`Computer: ${id.name}`);
75
- console.log(`Signed in: ${signedIn ? (email ?? 'unknown') : stored ? 'saved sign-in unusable' : 'Not signed in'}`);
76
- console.log(`Daemon: ${serving ? 'running' : 'not running'}`);
77
- console.log(`Agents: ${installed.length ? installed.join(', ') : 'none installed'}`);
78
- for (const agent of installed) {
79
- console.log(` ${agent.padEnd(8)} ${registered[agent] ? 'registered' : 'not registered'}`);
80
- }
81
- console.log('');
82
- const unregistered = installed.filter((a) => !registered[a]);
83
- if (!stored) {
84
- console.log('Nobody is signed in. Ask the user to run `cs login` themselves: it opens a');
85
- console.log('browser and waits up to five minutes, so do not run it yourself. They sign in');
86
- console.log('with the same email and password they use on ctrl-spc.com. Then run `cs status`');
87
- console.log('again.');
88
- }
89
- else if (!signedIn) {
90
- console.log(`The saved sign-in could not be used: ${sessionProblem ?? 'unknown'}. Ask the user to run`);
91
- console.log('`cs login` again.');
92
- }
93
- else if (!serving) {
94
- console.log('Nothing is serving the tools. `cs start` never exits on its own, so start it in');
95
- console.log('the background and leave it running, then run `cs status` again.');
70
+ if (current.cloud !== 'online' || current.work.unknown.length)
71
+ process.exitCode = 1;
72
+ return;
96
73
  }
97
- else if (unregistered.length) {
98
- console.log(`The tools are being served but are not registered into ${unregistered.join(' or ')}. Restart the`);
99
- console.log('background daemon, then run `cs status` again.');
74
+ if (inspection) {
75
+ console.log('Local service: alive; control is unavailable (pid ' + inspection.record.process.pid + ')');
76
+ console.log('Running version: unavailable. Last recorded version: ' + inspection.record.version);
77
+ console.log('Cloud connection and owned work: unknown');
78
+ console.log('Run cs restart --force to recover verified owned execution. This interrupts work; saved cards need a new message to continue.');
100
79
  }
101
80
  else {
102
- console.log('Tools are registered. If this agent cannot see them, restart it. An agent picks');
103
- console.log('up MCP servers only when it starts.');
104
- return;
81
+ const migration = readMigration();
82
+ console.log('Local service: stopped or not yet managed by this release');
83
+ console.log('Running version: unavailable');
84
+ console.log('Cloud connection: unknown; local service is not responding');
85
+ if (migration && !migration.completed)
86
+ console.log('One-time upgrade is pending. Run cs restart to check whether this computer still needs a restart.');
87
+ else if (readRuntime())
88
+ console.log('A previous service exited. Run cs restart to recover this instance.');
89
+ else
90
+ console.log('Run cs start. If an older service needs a one-time computer restart, the command will explain it.');
91
+ if (!readSession())
92
+ console.log('No saved sign-in. Run cs login on ' + machine.name + '. Local service controls work without sign-in.');
105
93
  }
106
94
  process.exitCode = 1;
107
95
  }
@@ -112,7 +100,15 @@ async function main() {
112
100
  case undefined: return openCompanion();
113
101
  case 'open': return openCompanion();
114
102
  case 'login': return login();
115
- case 'start': return runDaemon();
103
+ case 'start':
104
+ if (arg && !(arg.startsWith('--lifecycle-handover=') && process.env.CTRL_SPC_LIFECYCLE_HANDOVER === arg.slice('--lifecycle-handover='.length)))
105
+ throw new Error('Usage: cs start');
106
+ return runDaemon();
107
+ case 'stop':
108
+ case 'restart':
109
+ if (process.argv.length > 4 || (arg !== undefined && arg !== '--force'))
110
+ throw new Error('Usage: cs ' + cmd + ' [--force]');
111
+ return runLifecycleCommand(cmd, arg === '--force');
116
112
  case 'status': return status();
117
113
  case 'logout':
118
114
  console.log(clearSession() ? 'Signed out.' : 'Was not signed in.');
@@ -145,6 +141,6 @@ async function main() {
145
141
  }
146
142
  }
147
143
  main().catch((err) => {
148
- console.error(err instanceof Error ? err.message : String(err));
144
+ console.error('CTRL+SPC on ' + machineHostname() + ': ' + (err instanceof Error ? err.message : String(err)));
149
145
  process.exit(1);
150
146
  });
package/dist/login.js CHANGED
@@ -82,8 +82,8 @@ async function handleCallback(req, res, state, finish) {
82
82
  }
83
83
  writeSession({ access_token, refresh_token });
84
84
  try {
85
- const client = await getClient();
86
- const { data, error } = await client.auth.getUser();
85
+ const client = await getClient({ refreshing: false });
86
+ const { data, error } = await client.auth.getUser(access_token);
87
87
  if (error || !data.user?.email)
88
88
  throw new Error(error?.message ?? 'No user for this session.');
89
89
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -169,7 +169,9 @@ function renderPage(state) {
169
169
  tin.onclick=()=>{tin.classList.add('active');tup.classList.remove('active');fin.classList.add('active');fup.classList.remove('active')}
170
170
  tup.onclick=()=>{tup.classList.add('active');tin.classList.remove('active');fup.classList.add('active');fin.classList.remove('active')}
171
171
  const { createClient } = await import('https://esm.sh/@supabase/supabase-js@2')
172
- const sb = createClient(${JSON.stringify(SUPABASE_URL)}, ${JSON.stringify(SUPABASE_KEY)})
172
+ const sb = createClient(${JSON.stringify(SUPABASE_URL)}, ${JSON.stringify(SUPABASE_KEY)}, {
173
+ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false }
174
+ })
173
175
  async function done(session){
174
176
  const r = await fetch('/callback',{method:'POST',headers:{'Content-Type':'application/json'},
175
177
  body:JSON.stringify({access_token:session.access_token,refresh_token:session.refresh_token,state:STATE})})
Binary file
@@ -0,0 +1 @@
1
+ {"sourceHash":"e817b52cb4cf445046d83c9d4a6a7f8e79624f498564a3e40cb66d575ed4d63a","compiler":"Apple clang version 21.0.0 (clang-2100.1.1.101)\nTarget: arm64-apple-darwin25.3.0\nThread model: posix\nInstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin\n","binaryHash":"25d9d65d0ee5718ddb8f54a08fa5000b9c73508f6eb532f085e812b249adf9bb"}