@ctrl-spc/cli 1.3.3 → 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
  }
@@ -697,8 +698,9 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
697
698
  const intervalMs = deps.intervalMs ?? 30_000;
698
699
  let timer;
699
700
  let chain = Promise.resolve();
700
- const runReconcile = async () => {
701
- await deps.refreshWorkspaces?.();
701
+ const runReconcile = async (refreshWorkspaces) => {
702
+ if (refreshWorkspaces)
703
+ await deps.refreshWorkspaces?.();
702
704
  const projects = await loadProjects(client, userId, machine.id);
703
705
  const currentIds = new Set(projects.map((project) => project.id));
704
706
  for (const project of projects) {
@@ -722,10 +724,10 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
722
724
  }
723
725
  };
724
726
  return {
725
- reconcile() {
727
+ reconcile(options = {}) {
726
728
  // Recover the queue after a transient failed poll; the caller still
727
729
  // receives this run's rejection, but later intervals keep reconciling.
728
- chain = chain.catch(() => { }).then(runReconcile);
730
+ chain = chain.catch(() => { }).then(() => runReconcile(options.refreshWorkspaces !== false));
729
731
  return chain;
730
732
  },
731
733
  start() {
@@ -757,12 +759,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
757
759
  * tool call can flip presence back to "working" mid-shutdown) and only then
758
760
  * untracks presence, before exiting.
759
761
  */
760
- function installShutdownHandler(presence, mcpServer, dispatch) {
762
+ function installShutdownHandler(presence, mcpServer, dispatch, onShutdown) {
761
763
  let shuttingDown = false;
762
764
  const shutdown = () => {
763
765
  if (shuttingDown)
764
766
  return;
765
767
  shuttingDown = true;
768
+ onShutdown?.();
766
769
  dispatch?.stop();
767
770
  console.log('\nShutting down — closing MCP server, untracking presence...');
768
771
  mcpServer
@@ -784,6 +787,8 @@ export async function run() {
784
787
  if (retireLegacyDaemon()) {
785
788
  console.log('Removed obsolete CTRL+SPC background launcher.');
786
789
  }
790
+ if (process.argv[2] !== '__broker')
791
+ installWakeAgent();
787
792
  const cwd = process.cwd();
788
793
  const client = await getClient();
789
794
  const { data: userData, error: userError } = await client.auth.getUser();
@@ -841,7 +846,7 @@ export async function run() {
841
846
  loadProjects: loadRunnableMachineProjects,
842
847
  refreshWorkspaces: () => reconcileMachineWorkspaces(client, userId, machine),
843
848
  });
844
- await presence.reconcile();
849
+ await presence.reconcile({ refreshWorkspaces: false });
845
850
  presence.start();
846
851
  let mcpServer;
847
852
  try {
@@ -864,6 +869,14 @@ export async function run() {
864
869
  await presence.stop().catch(() => { });
865
870
  throw formatMcpStartupError(err, MCP_PORT);
866
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();
867
880
  console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
868
881
  'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
869
882
  'set_task_role_slugs, ' +
@@ -872,5 +885,5 @@ export async function run() {
872
885
  console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
873
886
  console.log('Presence: available');
874
887
  const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
875
- installShutdownHandler(presence, mcpServer, dispatch);
888
+ installShutdownHandler(presence, mcpServer, dispatch, () => clearInterval(heartbeatTimer));
876
889
  }
@@ -1143,17 +1143,18 @@ export function renderCompanionUi(token, kind, runtimePort = kind === 'local' ?
1143
1143
  const profile = snapshot.profiles.find((candidate) => candidate.kind === kind);
1144
1144
  const action = profile.state === 'ready' ? 'stop' : 'start';
1145
1145
  lastError = '';
1146
+ let succeeded = false;
1146
1147
  setBusy(true, action === 'start' ? 'Switching connection…' : 'Turning connection off…');
1147
1148
  try {
1148
- await api('/api/runtime/' + kind + '/' + action, { method: 'POST' });
1149
- await refresh(true);
1149
+ render(await api('/api/runtime/' + kind + '/' + action, { method: 'POST' }));
1150
+ succeeded = true;
1150
1151
  } catch (error) {
1151
1152
  lastError = error.message;
1152
1153
  notice.className = 'notice error';
1153
1154
  notice.textContent = lastError;
1154
1155
  } finally {
1155
1156
  setBusy(false);
1156
- await refresh(false);
1157
+ if (!succeeded) await refresh(false);
1157
1158
  }
1158
1159
  }
1159
1160
 
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,13 +212,16 @@ 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
  }
217
223
  mkdirSync(runtimeDir(), { recursive: true });
218
- const logFd = openSync(logPath(kind), 'a', 0o600);
224
+ const logFd = openSync(logPath(kind), 'w', 0o600);
219
225
  let child;
220
226
  try {
221
227
  child = spawn(process.execPath, [profile.entryPath, '__broker'], {
@@ -236,11 +242,16 @@ export async function startRuntime(kind) {
236
242
  if (!child.pid)
237
243
  throw new Error(`${profile.label} did not start.`);
238
244
  writePrivateFile(pidPath(kind), String(child.pid));
245
+ let childExited = false;
246
+ child.once('exit', () => { childExited = true; });
239
247
  const ready = await waitFor(async () => {
248
+ if (childExited)
249
+ return true;
240
250
  const health = await probeBroker(profile.port);
241
- return health?.runtime?.kind === kind;
251
+ return brokerMatchesRuntime(health, kind, getMachineIdentity().id);
242
252
  }, 30_000, 250);
243
- if (!ready) {
253
+ const health = await probeBroker(profile.port);
254
+ if (!ready || !brokerMatchesRuntime(health, kind, getMachineIdentity().id)) {
244
255
  rmSync(pidPath(kind), { force: true });
245
256
  const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
246
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.3",
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"