@ctrl-spc/cli 1.3.4 → 1.3.5

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.
@@ -9,6 +9,7 @@ import { ask, select } from '../prompt.js';
9
9
  import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
10
10
  import { upsertProjectLink } from './link.js';
11
11
  import { registerMachineWorkspace } from './run.js';
12
+ import { installWakeAgent } from '../wake.js';
12
13
  function setGrouping(flags, grouping) {
13
14
  if (flags.grouping) {
14
15
  throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
@@ -448,6 +449,9 @@ export async function init(argv = [], deps = {}) {
448
449
  return;
449
450
  }
450
451
  const client = await getClient();
452
+ if (installWakeAgent()) {
453
+ console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
454
+ }
451
455
  const { data: userData, error: userError } = await client.auth.getUser();
452
456
  if (userError || !userData.user) {
453
457
  console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
@@ -4,6 +4,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
4
4
  import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
5
5
  import { writeSession } from '../config.js';
6
6
  import { getClient } from '../supabase.js';
7
+ import { installWakeAgent } from '../wake.js';
7
8
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
8
9
  /** POST /callback bodies larger than this are rejected before JSON parsing. */
9
10
  const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
@@ -16,6 +17,9 @@ export async function login() {
16
17
  const outcome = await runLoginServer();
17
18
  if (outcome.ok) {
18
19
  console.log(`Signed in as ${outcome.email}`);
20
+ if (installWakeAgent()) {
21
+ console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
22
+ }
19
23
  process.exitCode = 0;
20
24
  }
21
25
  else {
@@ -1,12 +1,41 @@
1
- import { clearSession } from '../config.js';
1
+ import { clearSession, getMachineIdentity } from '../config.js';
2
+ import { getClient } from '../supabase.js';
3
+ import { removeWakeAgent } from '../wake.js';
4
+ async function defaultNullHeartbeats() {
5
+ const client = await getClient();
6
+ const { error } = await client
7
+ .from('machines')
8
+ .update({ waker_seen_at: null, broker_seen_at: null })
9
+ .eq('id', getMachineIdentity().id);
10
+ if (error)
11
+ throw error;
12
+ }
2
13
  /**
3
14
  * `ctrl-spc logout`: deletes the stored session. Local-only — the refresh
4
15
  * token is discarded, not revoked server-side (Supabase revokes it on next
5
16
  * rotation attempt; fine for v1's single-session CLI).
6
17
  */
7
- export function logout() {
8
- if (clearSession()) {
9
- console.log('Logged out — stored session removed.');
18
+ export async function logout(deps = {}) {
19
+ try {
20
+ await (deps.nullHeartbeats ?? defaultNullHeartbeats)();
21
+ }
22
+ catch {
23
+ // Offline or already signed out. The freshness window removes stale UI.
24
+ }
25
+ let wakeRemovalFailed = false;
26
+ try {
27
+ ;
28
+ (deps.removeWake ?? removeWakeAgent)();
29
+ }
30
+ catch {
31
+ // Clearing the authenticated session is the security boundary. A stale
32
+ // plist without a session can only wake, fail authentication, and exit.
33
+ wakeRemovalFailed = true;
34
+ }
35
+ if ((deps.clear ?? clearSession)()) {
36
+ console.log(wakeRemovalFailed
37
+ ? 'Logged out — stored session removed. The background start check could not be removed, but it no longer has access to your account.'
38
+ : 'Logged out — stored session removed and remote start disabled.');
10
39
  }
11
40
  else {
12
41
  console.log('No stored session — already logged out.');
@@ -13,6 +13,7 @@ import { startMcpServer } from '../mcp.js';
13
13
  import { getMachineIdentity } from '../config.js';
14
14
  import { discoverRepositoryTopology } from '../topology.js';
15
15
  import { createAgentDispatchController } from '../dispatch.js';
16
+ import { installWakeAgent, stampMachineHeartbeat } from '../wake.js';
16
17
  function mcpUrl() {
17
18
  return `http://localhost:${MCP_PORT}/mcp`;
18
19
  }
@@ -758,12 +759,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
758
759
  * tool call can flip presence back to "working" mid-shutdown) and only then
759
760
  * untracks presence, before exiting.
760
761
  */
761
- function installShutdownHandler(presence, mcpServer, dispatch) {
762
+ function installShutdownHandler(presence, mcpServer, dispatch, onShutdown) {
762
763
  let shuttingDown = false;
763
764
  const shutdown = () => {
764
765
  if (shuttingDown)
765
766
  return;
766
767
  shuttingDown = true;
768
+ onShutdown?.();
767
769
  dispatch?.stop();
768
770
  console.log('\nShutting down — closing MCP server, untracking presence...');
769
771
  mcpServer
@@ -785,6 +787,8 @@ export async function run() {
785
787
  if (retireLegacyDaemon()) {
786
788
  console.log('Removed obsolete CTRL+SPC background launcher.');
787
789
  }
790
+ if (process.argv[2] !== '__broker')
791
+ installWakeAgent();
788
792
  const cwd = process.cwd();
789
793
  const client = await getClient();
790
794
  const { data: userData, error: userError } = await client.auth.getUser();
@@ -865,6 +869,14 @@ export async function run() {
865
869
  await presence.stop().catch(() => { });
866
870
  throw formatMcpStartupError(err, MCP_PORT);
867
871
  }
872
+ // A broker heartbeat means the local server is genuinely accepting work.
873
+ // Stamp only after startMcpServer succeeds; otherwise a port/startup failure
874
+ // would make the web app show this computer as online for three minutes.
875
+ await stampMachineHeartbeat(client, machine.id, true).catch(() => { });
876
+ const heartbeatTimer = setInterval(() => {
877
+ void stampMachineHeartbeat(client, machine.id, true).catch(() => { });
878
+ }, 60_000);
879
+ heartbeatTimer.unref();
868
880
  console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
869
881
  'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
870
882
  'set_task_role_slugs, ' +
@@ -873,5 +885,5 @@ export async function run() {
873
885
  console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
874
886
  console.log('Presence: available');
875
887
  const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
876
- installShutdownHandler(presence, mcpServer, dispatch);
888
+ installShutdownHandler(presence, mcpServer, dispatch, () => clearInterval(heartbeatTimer));
877
889
  }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { link } from './commands/link.js';
6
6
  import { fetchRepository } from './commands/fetch.js';
7
7
  import { run } from './commands/run.js';
8
8
  import { openCompanion, serveCompanion } from './companion.js';
9
+ import { installWakeAgent, removeWakeAgent, wakeCheck } from './wake.js';
9
10
  export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
10
11
 
11
12
  Usage:
@@ -18,6 +19,8 @@ Usage:
18
19
  ctrl-spc fetch Clone and link one unavailable project repository
19
20
  [repository-id] [--project <project-id>] [--destination <path>] [--yes]
20
21
  ctrl-spc open Open the local connection manager
22
+ ctrl-spc wake Enable/disable starting this computer's agents from the web
23
+ on|off
21
24
  ctrl-spc Run the local agent process (presence + MCP server)
22
25
  ctrl-spc --help Show this help text
23
26
  `;
@@ -28,7 +31,7 @@ export async function main(argv = process.argv.slice(2)) {
28
31
  await login();
29
32
  return;
30
33
  case 'logout':
31
- logout();
34
+ await logout();
32
35
  return;
33
36
  case 'init':
34
37
  await init(argv.slice(1));
@@ -42,6 +45,25 @@ export async function main(argv = process.argv.slice(2)) {
42
45
  case 'open':
43
46
  await openCompanion();
44
47
  return;
48
+ case 'wake':
49
+ if (argv[1] === 'off') {
50
+ console.log(removeWakeAgent()
51
+ ? 'Remote start disabled on this computer.'
52
+ : 'Remote start was not enabled.');
53
+ }
54
+ else if (argv[1] === 'on') {
55
+ console.log(installWakeAgent()
56
+ ? 'Remote start enabled — this computer checks in every 60s. Disable: ctrl-spc wake off'
57
+ : 'Remote start requires macOS.');
58
+ }
59
+ else {
60
+ console.error('Usage: ctrl-spc wake on|off');
61
+ process.exitCode = 1;
62
+ }
63
+ return;
64
+ case '__wake-check':
65
+ await wakeCheck();
66
+ return;
45
67
  case '__broker':
46
68
  await run();
47
69
  return;
@@ -2,7 +2,10 @@ import { execFileSync, spawn } from 'node:child_process';
2
2
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { configDir } from './config.js';
5
+ import { configDir, getMachineIdentity } from './config.js';
6
+ export function brokerMatchesRuntime(health, kind, machineId) {
7
+ return health?.runtime?.kind === kind && health.machine_id === machineId;
8
+ }
6
9
  const CURRENT_PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
7
10
  const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
8
11
  export const LOCAL_RUNTIME_PORT = Number(process.env.CTRL_SPC_LOCAL_PORT) || 4572;
@@ -209,8 +212,11 @@ export async function startRuntime(kind) {
209
212
  'Stop it from its terminal before switching connections.');
210
213
  }
211
214
  const currentHealth = await probeBroker(profile.port);
212
- if (currentHealth?.runtime?.kind === kind)
213
- return;
215
+ if (currentHealth?.runtime?.kind === kind) {
216
+ if (brokerMatchesRuntime(currentHealth, kind, getMachineIdentity().id))
217
+ return;
218
+ throw new Error('Another CTRL+SPC connection is already running for a different computer identity. Stop it and try again.');
219
+ }
214
220
  if (currentHealth) {
215
221
  throw new Error(`Port ${profile.port} is already used by an unlabeled process. Turn it off before continuing.`);
216
222
  }
@@ -242,10 +248,10 @@ export async function startRuntime(kind) {
242
248
  if (childExited)
243
249
  return true;
244
250
  const health = await probeBroker(profile.port);
245
- return health?.runtime?.kind === kind;
251
+ return brokerMatchesRuntime(health, kind, getMachineIdentity().id);
246
252
  }, 30_000, 250);
247
253
  const health = await probeBroker(profile.port);
248
- if (!ready || health?.runtime?.kind !== kind) {
254
+ if (!ready || !brokerMatchesRuntime(health, kind, getMachineIdentity().id)) {
249
255
  rmSync(pidPath(kind), { force: true });
250
256
  const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
251
257
  throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
package/dist/wake.js ADDED
@@ -0,0 +1,237 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getClient } from './supabase.js';
7
+ import { getMachineIdentity } from './config.js';
8
+ import { currentCompanionKind, runtimeStatuses, startRuntime, } from './runtime-control.js';
9
+ const WAKE_LABEL = 'com.ctrl-spc.wake';
10
+ const WAKE_INTERVAL_SECONDS = 60;
11
+ const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
12
+ export const WAKE_REQUEST_FRESH_SECONDS = 150;
13
+ const FAILED_CLEANUP_MINUTES = 10;
14
+ export function wakeAgentPlistPath() {
15
+ return join(homedir(), 'Library', 'LaunchAgents', `${WAKE_LABEL}.plist`);
16
+ }
17
+ function xmlEscape(value) {
18
+ return value
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&apos;');
24
+ }
25
+ export function renderWakePlist(nodePath, entryPath, pathEnv) {
26
+ return `<?xml version="1.0" encoding="UTF-8"?>
27
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
+ <plist version="1.0">
29
+ <dict>
30
+ <key>Label</key><string>${WAKE_LABEL}</string>
31
+ <key>ProgramArguments</key>
32
+ <array>
33
+ <string>${xmlEscape(nodePath)}</string>
34
+ <string>${xmlEscape(entryPath)}</string>
35
+ <string>__wake-check</string>
36
+ </array>
37
+ <key>StartInterval</key><integer>${WAKE_INTERVAL_SECONDS}</integer>
38
+ <key>RunAtLoad</key><true/>
39
+ <key>ProcessType</key><string>Background</string>
40
+ <key>EnvironmentVariables</key>
41
+ <dict>
42
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
43
+ </dict>
44
+ </dict>
45
+ </plist>
46
+ `;
47
+ }
48
+ function defaultLaunchctl(args) {
49
+ execFileSync('launchctl', args, { stdio: 'ignore' });
50
+ }
51
+ function defaultWrite(path, contents) {
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, contents, { mode: 0o644 });
54
+ chmodSync(path, 0o644);
55
+ }
56
+ function defaultRead(path) {
57
+ try {
58
+ return readFileSync(path, 'utf8');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function installWakeAgent(deps = {}) {
65
+ if (process.env.CTRL_SPC_NO_WAKE_AGENT === '1')
66
+ return false;
67
+ if (process.platform !== 'darwin' && !deps.plistPath)
68
+ return false;
69
+ const plistPath = deps.plistPath ?? wakeAgentPlistPath();
70
+ const contents = renderWakePlist(deps.nodePath ?? process.execPath, deps.entryPath ?? CURRENT_ENTRY_PATH, deps.pathEnv ?? process.env.PATH ?? '/usr/bin:/bin');
71
+ if ((deps.read ?? defaultRead)(plistPath) === contents)
72
+ return true;
73
+ (deps.write ?? defaultWrite)(plistPath, contents);
74
+ const uid = deps.uid ?? process.getuid?.();
75
+ if (uid === undefined)
76
+ return true;
77
+ const launchctl = deps.launchctl ?? defaultLaunchctl;
78
+ try {
79
+ launchctl(['bootout', `gui/${uid}`, plistPath]);
80
+ }
81
+ catch {
82
+ // The job was not loaded yet.
83
+ }
84
+ try {
85
+ launchctl(['bootstrap', `gui/${uid}`, plistPath]);
86
+ }
87
+ catch (error) {
88
+ // Do not leave a byte-identical plist behind after a failed bootstrap:
89
+ // installWakeAgent intentionally skips launchctl for identical files, so
90
+ // keeping it would make every later retry report success without loading
91
+ // the job.
92
+ try {
93
+ ;
94
+ (deps.remove ?? rmSync)(plistPath);
95
+ }
96
+ catch {
97
+ // Preserve the launchctl error, which is the actionable failure.
98
+ }
99
+ throw error;
100
+ }
101
+ return true;
102
+ }
103
+ export function removeWakeAgent(deps = {}) {
104
+ if (process.platform !== 'darwin' && !deps.plistPath)
105
+ return false;
106
+ const plistPath = deps.plistPath ?? wakeAgentPlistPath();
107
+ if (!(deps.exists ?? existsSync)(plistPath))
108
+ return false;
109
+ const uid = deps.uid ?? process.getuid?.();
110
+ if (uid !== undefined) {
111
+ try {
112
+ ;
113
+ (deps.launchctl ?? defaultLaunchctl)(['bootout', `gui/${uid}`, plistPath]);
114
+ }
115
+ catch {
116
+ // An unloaded job is already stopped; the file still needs removal.
117
+ }
118
+ }
119
+ ;
120
+ (deps.remove ?? rmSync)(plistPath);
121
+ return true;
122
+ }
123
+ export async function stampMachineHeartbeat(client, machineId, brokerRunning) {
124
+ const now = new Date().toISOString();
125
+ const patch = { waker_seen_at: now };
126
+ if (brokerRunning)
127
+ patch.broker_seen_at = now;
128
+ const { error } = await client.from('machines').update(patch).eq('id', machineId);
129
+ if (error)
130
+ throw error;
131
+ }
132
+ function assertNoError(error) {
133
+ if (error)
134
+ throw error;
135
+ }
136
+ function supabaseWakeDb(client, machineId) {
137
+ const table = () => client.from('machine_wake_requests');
138
+ const freshCutoff = () => new Date(Date.now() - WAKE_REQUEST_FRESH_SECONDS * 1000).toISOString();
139
+ return {
140
+ stampHeartbeat: (brokerRunning) => stampMachineHeartbeat(client, machineId, brokerRunning),
141
+ async claimFreshPending() {
142
+ const { data, error } = await table()
143
+ .select('id')
144
+ .eq('machine_id', machineId)
145
+ .eq('status', 'pending')
146
+ .gte('requested_at', freshCutoff())
147
+ .order('requested_at', { ascending: false })
148
+ .limit(1)
149
+ .maybeSingle();
150
+ assertNoError(error);
151
+ return data ?? null;
152
+ },
153
+ async failStalePending() {
154
+ const { error } = await table().update({
155
+ status: 'failed',
156
+ error: 'Request expired before this machine checked in.',
157
+ updated_at: new Date().toISOString(),
158
+ })
159
+ .eq('machine_id', machineId)
160
+ .eq('status', 'pending')
161
+ .lt('requested_at', freshCutoff());
162
+ assertNoError(error);
163
+ },
164
+ async resolvePending(id) {
165
+ const { error } = await table()
166
+ .delete()
167
+ .eq('id', id)
168
+ .eq('machine_id', machineId)
169
+ .eq('status', 'pending');
170
+ assertNoError(error);
171
+ },
172
+ async failRequest(id, message) {
173
+ const { error } = await table()
174
+ .update({ status: 'failed', error: message, updated_at: new Date().toISOString() })
175
+ .eq('id', id);
176
+ assertNoError(error);
177
+ },
178
+ async cleanupFailed() {
179
+ const cutoff = new Date(Date.now() - FAILED_CLEANUP_MINUTES * 60 * 1000).toISOString();
180
+ const { error } = await table()
181
+ .delete()
182
+ .eq('machine_id', machineId)
183
+ .eq('status', 'failed')
184
+ .lt('updated_at', cutoff);
185
+ assertNoError(error);
186
+ },
187
+ };
188
+ }
189
+ async function defaultGetDb() {
190
+ const client = await getClient();
191
+ return supabaseWakeDb(client, getMachineIdentity().id);
192
+ }
193
+ async function defaultBrokerRunning() {
194
+ const statuses = await runtimeStatuses();
195
+ const machineId = getMachineIdentity().id;
196
+ return statuses.some((status) => status.running && status.health?.machine_id === machineId);
197
+ }
198
+ export async function wakeCheck(deps = {}) {
199
+ try {
200
+ if (await (deps.brokerRunning ?? defaultBrokerRunning)())
201
+ return 'broker_running';
202
+ }
203
+ catch {
204
+ // A failed probe is treated as not running so the poller can recover it.
205
+ }
206
+ let db;
207
+ try {
208
+ db = await (deps.getDb ?? defaultGetDb)();
209
+ }
210
+ catch {
211
+ return 'no_session';
212
+ }
213
+ try {
214
+ await db.stampHeartbeat(false);
215
+ await db.failStalePending();
216
+ const pending = await db.claimFreshPending();
217
+ if (!pending) {
218
+ await db.cleanupFailed();
219
+ return 'idle';
220
+ }
221
+ try {
222
+ await (deps.start ?? startRuntime)((deps.runtimeKind ?? currentCompanionKind)());
223
+ await db.resolvePending(pending.id);
224
+ await db.stampHeartbeat(true);
225
+ await db.cleanupFailed();
226
+ return 'started';
227
+ }
228
+ catch (error) {
229
+ await db.failRequest(pending.id, error instanceof Error ? error.message : String(error));
230
+ await db.cleanupFailed();
231
+ return 'start_failed';
232
+ }
233
+ }
234
+ catch {
235
+ return 'idle';
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "CTRL+SPC CLI — per-machine agent for browser login, project linking, presence, and the local MCP server.",
5
5
  "engines": {
6
6
  "node": ">=22"