@ctrl-spc/cs 0.7.14 → 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/README.md +50 -3
- package/dist/autostart.js +103 -122
- package/dist/companion-ui.js +34 -6
- package/dist/companion.js +59 -150
- package/dist/config.js +52 -1
- package/dist/daemon-lifecycle.js +548 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +756 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/index.js +70 -74
- package/dist/login.js +5 -3
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +620 -428
- package/dist/panel3/run.js +822 -520
- package/dist/panel3/spawn.js +59 -11
- package/dist/presence.js +183 -24
- package/dist/supabase.js +43 -9
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/daemon.js
CHANGED
|
@@ -1,49 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
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
|
|
39
|
+
const machine = getMachineIdentity();
|
|
42
40
|
const installed = detectAgents();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
98
|
-
console.log(
|
|
99
|
-
console.log('
|
|
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
|
-
|
|
103
|
-
console.log('
|
|
104
|
-
|
|
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':
|
|
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"}
|