@ctrl-spc/cs 0.7.14 → 0.7.16
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/agents.js +175 -1
- package/dist/autostart.js +103 -122
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +54 -6
- package/dist/companion.js +86 -169
- package/dist/config.js +199 -17
- package/dist/daemon-lifecycle.js +575 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +860 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +75 -76
- package/dist/login.js +9 -9
- package/dist/mcp.js +55 -56
- 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 +892 -575
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +996 -558
- package/dist/panel3/spawn.js +85 -23
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +271 -135
- package/dist/supabase.js +173 -37
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -7,10 +7,11 @@ signing in and attaching your codebases to your projects.
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm i -g @ctrl-spc/cs
|
|
10
|
+
npm i -g @ctrl-spc/cs
|
|
11
|
+
cs
|
|
11
12
|
```
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
Running `cs` opens your browser straight to sign-in — you barely touch
|
|
14
15
|
the terminal:
|
|
15
16
|
|
|
16
17
|
```
|
|
@@ -43,7 +44,11 @@ cs Open the Companion app (the front door)
|
|
|
43
44
|
cs open Open the Companion app in your browser
|
|
44
45
|
cs login Sign in from the terminal and link this computer
|
|
45
46
|
cs start Come online now, no window (used by auto-start)
|
|
46
|
-
cs status
|
|
47
|
+
cs status Inspect this service, its running version and cloud connection
|
|
48
|
+
cs stop Stop this service when no owned work is active
|
|
49
|
+
cs restart Load the installed CLI and keep it running in the background
|
|
50
|
+
cs stop --force Interrupt owned work, preserve recovery, then stop
|
|
51
|
+
cs restart --force Interrupt owned work, then load the installed CLI
|
|
47
52
|
cs autostart on Come online automatically at login
|
|
48
53
|
cs autostart off Stop coming online at login
|
|
49
54
|
cs logout Sign this computer out
|
|
@@ -53,5 +58,47 @@ cs help Show this help
|
|
|
53
58
|
## Requirements
|
|
54
59
|
|
|
55
60
|
- Node.js >= 22.
|
|
61
|
+
- Service stop and restart support macOS and Windows.
|
|
62
|
+
|
|
63
|
+
## Updating and restarting
|
|
64
|
+
|
|
65
|
+
Installing a package does not replace a service already running in memory. On
|
|
66
|
+
the computer you want to update, run:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm install -g @ctrl-spc/cs
|
|
70
|
+
cs restart
|
|
71
|
+
cs status
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`cs status` separates the version of the installed command from the verified
|
|
75
|
+
version running in the service. A successful restart stays running after the
|
|
76
|
+
terminal closes. `cs stop` leaves the computer's next-login startup preference
|
|
77
|
+
unchanged; `cs autostart off` changes that future preference.
|
|
78
|
+
|
|
79
|
+
A normal stop or restart refuses while an assignment is active. Let the work
|
|
80
|
+
finish, or explicitly use `--force` to interrupt the owned assignments. Saved
|
|
81
|
+
files and conversations remain; open each interrupted card and send a new
|
|
82
|
+
message to continue. Restarting the service does not automatically replay work.
|
|
83
|
+
|
|
84
|
+
Local service controls remain available without a cloud connection or usable
|
|
85
|
+
sign-in. Cloud readiness is reported separately. An offline interruption appears
|
|
86
|
+
in the app after the service can reconnect and reconcile it.
|
|
87
|
+
|
|
88
|
+
### One-time upgrade from an older service
|
|
89
|
+
|
|
90
|
+
A service installed before these lifecycle controls cannot prove which work is
|
|
91
|
+
still running. The new command prepares the future-login launcher and explains
|
|
92
|
+
when a one-time **computer restart** is required. Save your work, restart that
|
|
93
|
+
computer, then run `cs status` after signing in. If automatic startup is off, run
|
|
94
|
+
`cs start` first. Interrupted work needs an explicit new message to continue.
|
|
95
|
+
|
|
96
|
+
Later service stop and restart do not need a computer restart. If ownership or
|
|
97
|
+
execution cannot be verified, the command reports that problem and does not
|
|
98
|
+
start a second service.
|
|
99
|
+
|
|
100
|
+
The app's **Restart worker** control reconnects work processing inside the
|
|
101
|
+
current service. Activating an installed CLI update uses `cs restart` in a
|
|
102
|
+
terminal on the named computer.
|
|
56
103
|
|
|
57
104
|
Not sure what a piece does? Open the Companion (`cs`) and click **How it works**.
|
package/dist/agents.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { configDir } from './config.js';
|
|
4
|
+
import { userCodexHome, ensureCodexRunHome, removeCodexRunHome, CodexHomeFailure } from './codex-home.js';
|
|
5
|
+
import { windowsSafeSpawn, killTree } from './win-shell.js';
|
|
6
|
+
import { accessSync, constants, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
7
|
import { homedir } from 'node:os';
|
|
3
8
|
import { delimiter, join, win32 } from 'node:path';
|
|
4
9
|
const AGENTS = ['claude', 'codex'];
|
|
@@ -108,3 +113,172 @@ export function detectAgents() {
|
|
|
108
113
|
export function agentPath(agent) {
|
|
109
114
|
return resolve(agent);
|
|
110
115
|
}
|
|
116
|
+
/** Native status output only. Successful exit without a supported positive
|
|
117
|
+
* response is unknown; it may be a newer or unsupported CLI. */
|
|
118
|
+
export function parseHarnessStatus(agent, output, exitCode) {
|
|
119
|
+
if (agent === 'claude') {
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(output);
|
|
122
|
+
if (value?.loggedIn === true && exitCode === 0)
|
|
123
|
+
return { state: 'authenticated', reason: 'native-status' };
|
|
124
|
+
if (value?.loggedIn === false)
|
|
125
|
+
return { state: 'sign-in-required', reason: 'provider-rejected' };
|
|
126
|
+
}
|
|
127
|
+
catch { /* Unsupported output never implies rejection. */ }
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
if (exitCode === 0 && /^Logged in using (ChatGPT|an API key)(?:\s|$)/m.test(output))
|
|
131
|
+
return { state: 'authenticated', reason: 'native-status' };
|
|
132
|
+
if (/^Not logged in\s*$/m.test(output))
|
|
133
|
+
return { state: 'sign-in-required', reason: 'provider-rejected' };
|
|
134
|
+
}
|
|
135
|
+
return { state: 'unknown', reason: 'unsupported' };
|
|
136
|
+
}
|
|
137
|
+
let evidenceDirectory = '';
|
|
138
|
+
let evidence = {};
|
|
139
|
+
const probeTimes = new Map();
|
|
140
|
+
const probes = new Map();
|
|
141
|
+
export function executionIdentity(agent) {
|
|
142
|
+
return createHash('sha256').update(JSON.stringify([agentPath(agent), agent === 'codex' ? userCodexHome() : process.env.CLAUDE_CONFIG_DIR ?? homedir(), process.platform])).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
function loadHarnessEvidence() {
|
|
145
|
+
const directory = configDir();
|
|
146
|
+
if (evidenceDirectory === directory)
|
|
147
|
+
return;
|
|
148
|
+
evidenceDirectory = directory;
|
|
149
|
+
evidence = {};
|
|
150
|
+
probeTimes.clear();
|
|
151
|
+
try {
|
|
152
|
+
const saved = JSON.parse(readFileSync(join(directory, 'harness-auth.json'), 'utf8'));
|
|
153
|
+
for (const agent of AGENTS) {
|
|
154
|
+
const value = saved[agent];
|
|
155
|
+
if (!value)
|
|
156
|
+
continue;
|
|
157
|
+
const item = value.evidence;
|
|
158
|
+
if (value.identity !== executionIdentity(agent) || !item || !['authenticated', 'sign-in-required', 'unknown'].includes(item.state)
|
|
159
|
+
|| !['native-status', 'dispatch-success', 'provider-rejected', 'not-installed', 'preparation-unavailable', 'timeout', 'unsupported', 'check-failed'].includes(item.reason)
|
|
160
|
+
|| !Number.isFinite(Date.parse(item.observed_at))
|
|
161
|
+
|| ![null, 'authenticated', 'sign-in-required'].includes(item.last_result)
|
|
162
|
+
|| (item.last_result_at !== null && !Number.isFinite(Date.parse(item.last_result_at))))
|
|
163
|
+
continue;
|
|
164
|
+
evidence[agent] = { identity: value.identity, evidence: {
|
|
165
|
+
state: item.state === 'sign-in-required' ? item.state : 'unknown',
|
|
166
|
+
observed_at: item.observed_at, last_result: item.last_result, last_result_at: item.last_result_at,
|
|
167
|
+
reason: item.state === 'sign-in-required' ? 'provider-rejected' : 'check-failed',
|
|
168
|
+
} };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== 'ENOENT')
|
|
173
|
+
throw new Error('Saved provider status could not be read.', { cause: error });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function recordHarnessObservation(agent, state, reason, now = Date.now(), expectedIdentity) {
|
|
177
|
+
loadHarnessEvidence();
|
|
178
|
+
const identity = executionIdentity(agent);
|
|
179
|
+
if (expectedIdentity !== undefined && expectedIdentity !== identity)
|
|
180
|
+
return;
|
|
181
|
+
const prior = evidence[agent]?.evidence;
|
|
182
|
+
if (prior && Date.parse(prior.observed_at) > now)
|
|
183
|
+
return;
|
|
184
|
+
const observed_at = new Date(now).toISOString();
|
|
185
|
+
const item = {
|
|
186
|
+
state, observed_at, reason,
|
|
187
|
+
last_result: state === 'unknown' ? prior?.last_result ?? null : state,
|
|
188
|
+
last_result_at: state === 'unknown' ? prior?.last_result_at ?? null : observed_at,
|
|
189
|
+
};
|
|
190
|
+
evidence[agent] = { identity: executionIdentity(agent), evidence: item };
|
|
191
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
192
|
+
const temporary = join(configDir(), `harness-auth.${randomUUID()}.tmp`);
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(temporary, JSON.stringify(evidence), { flag: 'wx', mode: 0o600 });
|
|
195
|
+
renameSync(temporary, join(configDir(), 'harness-auth.json'));
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
rmSync(temporary, { force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function harnessAuthEvidence(now = Date.now()) {
|
|
202
|
+
loadHarnessEvidence();
|
|
203
|
+
return Object.fromEntries(AGENTS.flatMap(agent => {
|
|
204
|
+
const saved = evidence[agent];
|
|
205
|
+
if (!saved)
|
|
206
|
+
return [];
|
|
207
|
+
const item = saved.evidence;
|
|
208
|
+
const age = now - Date.parse(item.observed_at);
|
|
209
|
+
const current = saved.identity === executionIdentity(agent) && age >= -5000 && age < 120_000;
|
|
210
|
+
return [[agent, { ...item, state: saved.identity !== executionIdentity(agent) || item.state === 'authenticated' && !current ? 'unknown' : item.state }]];
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/** Called only by Presence. Same executable and seeded credential context as
|
|
214
|
+
* dispatch; no probe starts an agent conversation or carries product credentials. */
|
|
215
|
+
export async function probeHarnessAuth(agent, now = Date.now()) {
|
|
216
|
+
loadHarnessEvidence();
|
|
217
|
+
if (probes.has(agent))
|
|
218
|
+
return probes.get(agent);
|
|
219
|
+
if (now - (probeTimes.get(agent) ?? -Infinity) < 60_000)
|
|
220
|
+
return;
|
|
221
|
+
probeTimes.set(agent, now);
|
|
222
|
+
let closed = true;
|
|
223
|
+
let home = null;
|
|
224
|
+
const cleanup = () => { if (home) {
|
|
225
|
+
removeCodexRunHome(home);
|
|
226
|
+
home = null;
|
|
227
|
+
} ; probes.delete(agent); };
|
|
228
|
+
const pending = (async () => {
|
|
229
|
+
const bin = agentPath(agent);
|
|
230
|
+
if (!bin) {
|
|
231
|
+
recordHarnessObservation(agent, 'unknown', 'not-installed');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (agent === 'codex' && process.platform !== 'win32') {
|
|
235
|
+
const prepared = ensureCodexRunHome({ url: 'http://127.0.0.1:1/mcp' }, null, `status-${randomUUID()}`, false);
|
|
236
|
+
if (prepared instanceof CodexHomeFailure) {
|
|
237
|
+
recordHarnessObservation(agent, 'unknown', 'preparation-unavailable');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
home = prepared;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const args = agent === 'codex' ? ['login', 'status'] : ['auth', 'status'];
|
|
244
|
+
const launch = windowsSafeSpawn(bin, args);
|
|
245
|
+
const result = await new Promise((resolve) => {
|
|
246
|
+
const child = spawn(bin, launch.args, { shell: launch.shell, windowsHide: true, detached: process.platform !== 'win32',
|
|
247
|
+
env: home ? { ...process.env, CODEX_HOME: home } : process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
248
|
+
closed = false;
|
|
249
|
+
let output = '', timedOut = false;
|
|
250
|
+
const collect = (chunk) => { output = (output + chunk.toString()).slice(-16_384); };
|
|
251
|
+
child.stdout.on('data', collect);
|
|
252
|
+
child.stderr.on('data', collect);
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
timedOut = true;
|
|
255
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
256
|
+
try {
|
|
257
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
killTree(child);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else
|
|
264
|
+
killTree(child);
|
|
265
|
+
resolve({ output: '', code: -1, timedOut: true });
|
|
266
|
+
}, 5000);
|
|
267
|
+
child.once('error', () => { closed = true; clearTimeout(timer); cleanup(); resolve({ output: '', code: -1, timedOut }); });
|
|
268
|
+
child.once('close', code => { closed = true; clearTimeout(timer); cleanup(); resolve({ output, code: code ?? -1, timedOut }); });
|
|
269
|
+
});
|
|
270
|
+
const resultState = result.timedOut ? { state: 'unknown', reason: 'timeout' } : parseHarnessStatus(agent, result.output, result.code);
|
|
271
|
+
recordHarnessObservation(agent, resultState.state, resultState.reason);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
recordHarnessObservation(agent, 'unknown', 'check-failed');
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
if (closed)
|
|
278
|
+
cleanup();
|
|
279
|
+
}
|
|
280
|
+
})().finally(() => { if (closed)
|
|
281
|
+
cleanup(); });
|
|
282
|
+
probes.set(agent, pending);
|
|
283
|
+
return pending;
|
|
284
|
+
}
|
package/dist/autostart.js
CHANGED
|
@@ -1,141 +1,122 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
6
8
|
import { configDir } from './config.js';
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
18
|
-
|
|
9
|
+
const BASE_LABEL = 'com.ctrl-spc.presence';
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
/** Capture a loaded job only while its PID is the verified service owner. */
|
|
12
|
+
export async function loadedStartupJob(pid, deadline) {
|
|
13
|
+
if (process.platform !== 'darwin')
|
|
14
|
+
return null;
|
|
15
|
+
const target = 'gui/' + process.getuid() + '/' + startupLabel();
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execute('/bin/launchctl', ['print', target], { timeout: Math.max(1, Math.min(2000, deadline - Date.now())) });
|
|
18
|
+
const match = stdout.match(/^\s*pid = (\d+)\s*$/m);
|
|
19
|
+
return match && Number(match[1]) === pid ? target : null;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 113)
|
|
23
|
+
return null;
|
|
24
|
+
throw new Error('The login service could not be inspected. No replacement was started.', { cause: error });
|
|
25
|
+
}
|
|
19
26
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* no-op (so the launchd/VBS-spawned `cs start` doesn't re-install itself), and
|
|
24
|
-
* an unsupported platform or transient error never breaks coming online.
|
|
25
|
-
*/
|
|
26
|
-
export function ensureAutostart() {
|
|
27
|
-
if (existsSync(offMarkerPath()) || autostartEnabled())
|
|
27
|
+
/** Work has already drained. Unload the current job, retaining its next-login file. */
|
|
28
|
+
export async function unloadStoppedStartupJob(target, deadline) {
|
|
29
|
+
if (!target)
|
|
28
30
|
return;
|
|
29
31
|
try {
|
|
30
|
-
|
|
32
|
+
await execute('/bin/launchctl', ['bootout', target], { timeout: Math.max(1, deadline - Date.now()) });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== 113)
|
|
36
|
+
throw new Error('The previous login service could not be unloaded. No replacement was started.', { cause: error });
|
|
31
37
|
}
|
|
32
|
-
catch { /* unsupported platform or transient failure — ignore */ }
|
|
33
|
-
}
|
|
34
|
-
function plistPath() {
|
|
35
|
-
return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
function
|
|
39
|
-
|
|
39
|
+
function offMarkerPath() { return join(configDir(), 'autostart-off'); }
|
|
40
|
+
export function startupLabel() {
|
|
41
|
+
if (resolve(configDir()) === resolve(join(homedir(), '.config', 'ctrl-spc-v2')) && !process.env.CTRL_SPC_V2_MACHINE_ID)
|
|
42
|
+
return BASE_LABEL;
|
|
43
|
+
return BASE_LABEL + '.' + createHash('sha256').update(resolve(configDir()) + '\0' + (process.env.CTRL_SPC_V2_MACHINE_ID ?? '')).digest('hex').slice(0, 12);
|
|
40
44
|
}
|
|
41
|
-
|
|
42
|
-
export function
|
|
43
|
-
if (existsSync(offMarkerPath()))
|
|
44
|
-
rmSync(offMarkerPath());
|
|
45
|
+
export function entryPath() { return join(dirname(fileURLToPath(import.meta.url)), 'index.js'); }
|
|
46
|
+
export function startupPath() {
|
|
45
47
|
if (process.platform === 'darwin')
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
return join(homedir(), 'Library', 'LaunchAgents', startupLabel() + '.plist');
|
|
49
|
+
const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
50
|
+
const stem = startupLabel() === BASE_LABEL ? 'ctrl-spc-presence' : startupLabel();
|
|
51
|
+
return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', stem + '.vbs');
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
mkdirSync(configDir(), { recursive: true });
|
|
54
|
-
writeFileSync(offMarkerPath(), '');
|
|
55
|
-
if (process.platform === 'darwin')
|
|
56
|
-
return macOff();
|
|
57
|
-
if (process.platform === 'win32')
|
|
58
|
-
return winOff();
|
|
59
|
-
throw new Error('Auto-start supports macOS and Windows only.');
|
|
53
|
+
export function autostartEnabled() {
|
|
54
|
+
return !existsSync(offMarkerPath()) && ['darwin', 'win32'].includes(process.platform) && existsSync(startupPath());
|
|
60
55
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// login shim. Upgrade to a Scheduled Task with restart settings if the daemon
|
|
65
|
-
// crashing between logins ever matters.
|
|
66
|
-
function startupVbsPath() {
|
|
67
|
-
const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
68
|
-
return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'ctrl-spc-presence.vbs');
|
|
56
|
+
export function autostartDisabled() { return existsSync(offMarkerPath()); }
|
|
57
|
+
function escapeXml(value) {
|
|
58
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
69
59
|
}
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
console.log('Auto-start enabled. CTRL+SPC comes online at login. Disable: cs autostart off');
|
|
60
|
+
function instanceEnvironment() {
|
|
61
|
+
const env = { PATH: process.env.PATH ?? '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', HOME: homedir(), USER: process.env.USER ?? process.env.USERNAME ?? '' };
|
|
62
|
+
for (const key of ['CTRL_SPC_V2_CONFIG_DIR', 'CTRL_SPC_V2_MACHINE_ID', 'CTRL_SPC_V2_COMPANION_PORT', 'CTRL_SPC_V2_TOOLS_PORT', 'CTRL_SPC_SUPABASE_URL', 'CTRL_SPC_SUPABASE_KEY', 'CTRL_SPC_V3_AGENT', 'CTRL_SPC_V2_MAX_CONCURRENT', 'CTRL_SPC_WEB_URL', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR']) {
|
|
63
|
+
if (process.env[key])
|
|
64
|
+
env[key] = process.env[key];
|
|
65
|
+
}
|
|
66
|
+
return env;
|
|
78
67
|
}
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
function launcherContents() {
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
const quoted = (value) => value.replace(/"/g, '""');
|
|
71
|
+
const command = '"' + process.execPath + '" "' + entryPath() + '" start';
|
|
72
|
+
const env = Object.entries(instanceEnvironment()).map(([key, value]) => 's.Environment("PROCESS")("' + quoted(key) + '") = "' + quoted(value) + '"').join('\r\n');
|
|
73
|
+
return 'Set s = CreateObject("WScript.Shell")\r\n' + env + '\r\ns.Run "' + quoted(command) + '", 0, False\r\n';
|
|
74
|
+
}
|
|
75
|
+
const env = Object.entries(instanceEnvironment()).map(([key, value]) => '<key>' + escapeXml(key) + '</key><string>' + escapeXml(value) + '</string>').join('');
|
|
76
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict>' +
|
|
77
|
+
'<key>Label</key><string>' + startupLabel() + '</string>' +
|
|
78
|
+
'<key>ProgramArguments</key><array><string>' + escapeXml(process.execPath) + '</string><string>' + escapeXml(entryPath()) + '</string><string>start</string></array>' +
|
|
79
|
+
'<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>' +
|
|
80
|
+
'<key>StandardOutPath</key><string>' + escapeXml(join(configDir(), 'daemon.log')) + '</string>' +
|
|
81
|
+
'<key>StandardErrorPath</key><string>' + escapeXml(join(configDir(), 'daemon.log')) + '</string>' +
|
|
82
|
+
'<key>EnvironmentVariables</key><dict>' + env + '</dict></dict></plist>\n';
|
|
84
83
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
sends you to re-authenticate something that was never broken. Reproduced
|
|
102
|
-
by hand: `env -i HOME=… PATH=… claude -p` fails, and adding USER alone
|
|
103
|
-
fixes it. HOME is set for the same reason, though launchd does supply it. */
|
|
104
|
-
const userName = process.env.USER ?? process.env.LOGNAME ?? '';
|
|
105
|
-
const logDir = join(homedir(), '.config', 'ctrl-spc-v2');
|
|
106
|
-
mkdirSync(logDir, { recursive: true });
|
|
107
|
-
mkdirSync(dirname(plistPath()), { recursive: true });
|
|
108
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
109
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
110
|
-
<plist version="1.0"><dict>
|
|
111
|
-
<key>Label</key><string>${LABEL}</string>
|
|
112
|
-
<key>ProgramArguments</key><array>
|
|
113
|
-
<string>${node}</string><string>${entry}</string><string>start</string>
|
|
114
|
-
</array>
|
|
115
|
-
<key>RunAtLoad</key><true/>
|
|
116
|
-
<key>KeepAlive</key><true/>
|
|
117
|
-
<key>StandardOutPath</key><string>${join(logDir, 'daemon.log')}</string>
|
|
118
|
-
<key>StandardErrorPath</key><string>${join(logDir, 'daemon.log')}</string>
|
|
119
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string><key>USER</key><string>${userName}</string><key>HOME</key><string>${homedir()}</string></dict>
|
|
120
|
-
</dict></plist>
|
|
121
|
-
`;
|
|
122
|
-
writeFileSync(plistPath(), plist);
|
|
84
|
+
/** Writes future-login configuration only. The lifecycle controller owns launch. */
|
|
85
|
+
export function prepareAutostart({ defaultOn = false } = {}) {
|
|
86
|
+
if (autostartDisabled())
|
|
87
|
+
return;
|
|
88
|
+
if (!['darwin', 'win32'].includes(process.platform)) {
|
|
89
|
+
if (defaultOn)
|
|
90
|
+
return; // Existing CLI permits manually launched Linux instances.
|
|
91
|
+
throw new Error('Automatic login startup supports macOS and Windows.');
|
|
92
|
+
}
|
|
93
|
+
if (!defaultOn && !autostartEnabled())
|
|
94
|
+
return;
|
|
95
|
+
mkdirSync(configDir(), { recursive: true });
|
|
96
|
+
const path = startupPath();
|
|
97
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
98
|
+
const text = launcherContents();
|
|
99
|
+
const temp = path + '.' + randomUUID() + '.tmp';
|
|
123
100
|
try {
|
|
124
|
-
|
|
101
|
+
writeFileSync(temp, text, { mode: 0o600 });
|
|
102
|
+
renameSync(temp, path);
|
|
125
103
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
console.log('Auto-start enabled. CTRL+SPC comes online at login. Disable: cs autostart off');
|
|
129
|
-
}
|
|
130
|
-
/** Removes the LaunchAgent. */
|
|
131
|
-
function macOff() {
|
|
132
|
-
const uid = process.getuid?.() ?? 0;
|
|
133
|
-
if (existsSync(plistPath())) {
|
|
134
|
-
try {
|
|
135
|
-
execFileSync('launchctl', ['bootout', `gui/${uid}`, plistPath()], { stdio: 'ignore' });
|
|
136
|
-
}
|
|
137
|
-
catch { /* already out */ }
|
|
138
|
-
rmSync(plistPath());
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(temp, { force: true });
|
|
139
106
|
}
|
|
140
|
-
|
|
107
|
+
if (readFileSync(path, 'utf8') !== text)
|
|
108
|
+
throw new Error('The future-login launcher could not be verified. Retry before restarting this computer.');
|
|
109
|
+
}
|
|
110
|
+
export function ensureAutostart() { prepareAutostart({ defaultOn: true }); }
|
|
111
|
+
export function autostartOn() {
|
|
112
|
+
rmSync(offMarkerPath(), { force: true });
|
|
113
|
+
prepareAutostart({ defaultOn: true });
|
|
114
|
+
console.log('Auto-start enabled for the next login. Start now: cs start');
|
|
115
|
+
}
|
|
116
|
+
export function autostartOff() {
|
|
117
|
+
mkdirSync(configDir(), { recursive: true });
|
|
118
|
+
writeFileSync(offMarkerPath(), '', { mode: 0o600 });
|
|
119
|
+
if (['darwin', 'win32'].includes(process.platform))
|
|
120
|
+
rmSync(startupPath(), { force: true });
|
|
121
|
+
console.log('Auto-start disabled for future logins. Stop this service: cs stop');
|
|
141
122
|
}
|
package/dist/codex-home.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { chmodSync, copyFileSync,
|
|
1
|
+
import { chmodSync, copyFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { configDir } from './config.js';
|
|
6
|
+
export class CodexHomeFailure {
|
|
7
|
+
cause;
|
|
8
|
+
kind = 'preparation-unavailable';
|
|
9
|
+
message = 'Codex credentials or its local execution configuration could not be prepared.';
|
|
10
|
+
constructor(cause) {
|
|
11
|
+
this.cause = cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
6
14
|
/**
|
|
7
15
|
* 18c SLICE 8 — A CODEX WORKER IS ISOLATED LIKE A CLAUDE WORKER.
|
|
8
16
|
*
|
|
@@ -319,8 +327,6 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
319
327
|
const source = join(userCodexHome(), 'auth.json');
|
|
320
328
|
/* No credential, no isolated home. See the block comment above: this is the
|
|
321
329
|
exact shape that hangs, and hanging is worse than inheriting. */
|
|
322
|
-
if (!existsSync(source))
|
|
323
|
-
return null;
|
|
324
330
|
const home = codexRunHomePath(homeKey);
|
|
325
331
|
try {
|
|
326
332
|
/* FROM EMPTY. See above: a retry of the same request would otherwise start
|
|
@@ -352,10 +358,9 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
352
358
|
writeFileSync(join(home, OWNER_FILE), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
353
359
|
return home;
|
|
354
360
|
}
|
|
355
|
-
catch {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return null;
|
|
361
|
+
catch (cause) {
|
|
362
|
+
removeCodexRunHome(home);
|
|
363
|
+
return new CodexHomeFailure(cause);
|
|
359
364
|
}
|
|
360
365
|
}
|
|
361
366
|
/**
|
|
@@ -366,8 +371,6 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
366
371
|
*/
|
|
367
372
|
export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform = process.platform) {
|
|
368
373
|
const source = join(userCodexHome(), 'auth.json');
|
|
369
|
-
if (!existsSync(source))
|
|
370
|
-
return null;
|
|
371
374
|
const home = panel3CodexOwnerHomePath(ownerRunId);
|
|
372
375
|
try {
|
|
373
376
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
@@ -376,8 +379,8 @@ export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform =
|
|
|
376
379
|
writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, null, runtimePlatform, false, true), { mode: 0o600 });
|
|
377
380
|
return home;
|
|
378
381
|
}
|
|
379
|
-
catch {
|
|
380
|
-
return
|
|
382
|
+
catch (cause) {
|
|
383
|
+
return new CodexHomeFailure(cause);
|
|
381
384
|
}
|
|
382
385
|
}
|
|
383
386
|
/** Product-owned persistent state only. Windows owners use the installed Codex
|