@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.
@@ -0,0 +1,548 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomUUID, timingSafeEqual, createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync, openSync, closeSync, mkdirSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
+ import { configDir, getMachineIdentity, lifecycleToken, readLifecycleToken, readMcpToken, readSession, machineHostname } from './config.js';
10
+ import { entryPath, ensureAutostart, prepareAutostart, autostartDisabled, autostartEnabled, loadedStartupJob, unloadStoppedStartupJob } from './autostart.js';
11
+ import { CLI_VERSION } from './package-version.js';
12
+ import { TOOLS_SERVER_PORT } from './env.js';
13
+ import { claimLifecycleOperation, publishRuntime, readMigration, readOperation, readRuntime, removeOperation, removeRuntime, updateOperation, updateRuntime, writeMigration } from './daemon-lock.js';
14
+ import { inspectProcess, processIdentityMatches, osBootIdentity, loopbackListenerPid, terminateOwnedRoot } from './win-shell.js';
15
+ import { initializeOwnedWork, snapshotOwnedWork, interruptOwnedWork, markOwnedWorkInterrupted, ownedWorkInstanceNonces } from './daemon-processes.js';
16
+ import { startPresence, stopPresence, liveClient, suspendPresenceWork, resumePresenceWork, presenceCloudState } from './presence.js';
17
+ import { NotLoggedIn } from './supabase.js';
18
+ import { launchWindowsService, windowsJobExecutable } from './windows-job.js';
19
+ const MAX_COMMAND_MS = 30_000;
20
+ const installationCheckpoint = join(dirname(dirname(fileURLToPath(import.meta.url))), '.lifecycle-install.json');
21
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+ function milliseconds(deadline) {
23
+ const left = deadline - Date.now();
24
+ if (left <= 0)
25
+ throw new Error('Service operation did not finish within 30 seconds. Run cs status before retrying.');
26
+ return left;
27
+ }
28
+ function assertOperationOwned(id) {
29
+ if (readOperation()?.id !== id)
30
+ throw new Error('Another service command now owns recovery. Run cs status before retrying.');
31
+ }
32
+ async function bounded(promise, deadline) {
33
+ let timer;
34
+ try {
35
+ return await Promise.race([promise, new Promise((_, reject) => {
36
+ timer = setTimeout(() => reject(new Error('Service operation did not finish within 30 seconds. Run cs status before retrying.')), milliseconds(deadline));
37
+ })]);
38
+ }
39
+ finally {
40
+ if (timer)
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ function sessionFingerprint() {
45
+ const session = readSession();
46
+ return session ? createHash('sha256').update(JSON.stringify(session)).digest('hex') : 'none';
47
+ }
48
+ function pendingExecution(work) {
49
+ const claims = new Set((work.claimPending ?? []).map((item) => item.id));
50
+ return work.pending.filter((item) => !claims.has(item.id));
51
+ }
52
+ async function control(record, action, deadline, body) {
53
+ const token = readLifecycleToken();
54
+ if (!token)
55
+ throw new Error('The local service control credential is unavailable. No process was changed.');
56
+ const res = await fetch('http://127.0.0.1:' + record.port + '/' + action, {
57
+ method: action === 'status' ? 'GET' : 'POST',
58
+ headers: { authorization: 'Bearer ' + token, ...(body ? { 'content-type': 'application/json' } : {}) },
59
+ body: body ? JSON.stringify(body) : undefined,
60
+ signal: AbortSignal.timeout(Math.min(milliseconds(deadline), action === 'status' ? 10_000 : MAX_COMMAND_MS)),
61
+ });
62
+ const data = await res.json();
63
+ if (!res.ok)
64
+ throw new Error(typeof data?.error === 'string' ? data.error : 'Local service control failed.');
65
+ const status = data;
66
+ if (!status || status.nonce !== record.nonce || status.pid !== record.process.pid || status.version !== record.version || typeof status.local !== 'string' || typeof status.cloud !== 'string' || !status.work || !Array.isArray(status.work.active) || !Array.isArray(status.work.unknown) || !Array.isArray(status.work.pending)) {
67
+ throw new Error('The local service returned an invalid identity. No replacement will start.');
68
+ }
69
+ return status;
70
+ }
71
+ export async function inspectLocalRuntime(deadline = Date.now() + 10_000) {
72
+ const record = readRuntime();
73
+ if (!record)
74
+ return null;
75
+ const actual = await inspectProcess(record.process.pid, deadline);
76
+ if (!actual || !processIdentityMatches(record.process, actual))
77
+ return null;
78
+ try {
79
+ return { record, status: await control(record, 'status', deadline) };
80
+ }
81
+ catch {
82
+ return { record, status: null };
83
+ } // Verified local owner is alive; readiness remains unknown.
84
+ }
85
+ async function verifiedLegacyOwner(deadline) {
86
+ const token = readMcpToken();
87
+ if (!token)
88
+ return false;
89
+ const listener = await loopbackListenerPid(TOOLS_SERVER_PORT, deadline);
90
+ if (!listener)
91
+ return false;
92
+ const owner = await inspectProcess(listener, deadline);
93
+ const caller = await inspectProcess(process.pid, deadline);
94
+ if (!owner || !caller || owner.owner !== caller.owner)
95
+ throw new Error('The tools listener belongs to an unverified user. No process was changed.');
96
+ const client = new Client({ name: 'cs-lifecycle-inspection', version: CLI_VERSION });
97
+ try {
98
+ await bounded(client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:' + TOOLS_SERVER_PORT + '/mcp?token=' + encodeURIComponent(token)), {
99
+ requestInit: { signal: AbortSignal.timeout(Math.min(2500, milliseconds(deadline))) },
100
+ })), deadline);
101
+ return true;
102
+ }
103
+ catch {
104
+ throw new Error('The existing tools listener cannot be verified. No process was changed. Check this instance with cs status.');
105
+ }
106
+ finally {
107
+ await client.close().catch(() => { });
108
+ }
109
+ }
110
+ export async function writeInstallationCheckpoint() {
111
+ const boot = await osBootIdentity();
112
+ writeFileSync(installationCheckpoint, JSON.stringify({ schema: 1, boot, entry: entryPath(), version: CLI_VERSION }), { mode: 0o600 });
113
+ }
114
+ function checkpointBoot() {
115
+ try {
116
+ const data = JSON.parse(readFileSync(installationCheckpoint, 'utf8'));
117
+ const record = data;
118
+ return record.schema === 1 && record.entry === entryPath() && record.version === CLI_VERSION && typeof record.boot === 'string' ? record.boot : null;
119
+ }
120
+ catch (error) {
121
+ if (error.code === 'ENOENT' || error instanceof SyntaxError)
122
+ return null;
123
+ throw error;
124
+ }
125
+ }
126
+ function upgradeMessage() {
127
+ return 'Computer restart required — one-time upgrade on ' + machineHostname() + '.\n' +
128
+ 'The older service may still be running work. No replacement was started.\n' +
129
+ 'Save your work, then restart this computer. After signing back in, run cs status.\n' +
130
+ (autostartDisabled() ? 'Automatic startup is off; run cs start first.\n' : '') +
131
+ 'Saved files and conversations remain. Open interrupted cards and send a message to continue.\n' +
132
+ 'Later service stop and restart will not require a computer restart.';
133
+ }
134
+ /** Returns true when the ledger must perform its first post-upgrade reconciliation. */
135
+ async function prepareLegacyUpgrade(deadline) {
136
+ const prior = readMigration();
137
+ if (prior?.completed) {
138
+ // A later invocation of an obsolete install can reintroduce an untracked
139
+ // owner even after migration. Never treat its responding tools as ours.
140
+ const boot = await osBootIdentity(deadline);
141
+ if (await verifiedLegacyOwner(deadline)) {
142
+ // The old service can disappear while its agents survive. Persist this
143
+ // boot's hold before reporting it, so its later absence cannot admit work.
144
+ writeMigration({ ...prior, boot, prepared: false, completed: false });
145
+ throw new Error('An older service is running again on ' + machineHostname() + '. No replacement was started. Correct its obsolete startup entry, then restart this computer and run cs start from the current installation.');
146
+ }
147
+ return false;
148
+ }
149
+ const runtime = readRuntime();
150
+ const evidence = prior || (!runtime && (existsSync(join(configDir(), 'daemon.pid')) || readMcpToken() || autostartEnabled()));
151
+ if (!evidence)
152
+ return false;
153
+ const machine = getMachineIdentity();
154
+ const boot = await osBootIdentity(deadline);
155
+ const checkpoint = prior?.boot ?? checkpointBoot() ?? boot;
156
+ const record = prior ?? { schema: 1, boot: checkpoint, installation: entryPath(), prepared: false, completed: false, machineId: machine.id, accountId: null };
157
+ if (record.machineId !== machine.id)
158
+ throw new Error('The pending upgrade belongs to a different local instance.');
159
+ // Preserve the previous checkpoint while preparing the invoking installation.
160
+ prepareAutostart();
161
+ record.prepared = true;
162
+ record.installation = entryPath();
163
+ writeMigration(record);
164
+ if (checkpoint === boot)
165
+ throw new Error(upgradeMessage());
166
+ if (await verifiedLegacyOwner(deadline)) {
167
+ writeMigration({ ...record, boot, completed: false });
168
+ throw new Error('An older service started again on ' + machine.name + ' after the computer restart. Its obsolete launcher must be corrected before retrying; no replacement was started.');
169
+ }
170
+ return true;
171
+ }
172
+ /** Explicit cs open against an older resident companion uses the same upgrade. */
173
+ export async function checkLegacyUpgrade() {
174
+ const deadline = Date.now() + MAX_COMMAND_MS;
175
+ const operation = await claimLifecycleOperation('start', deadline);
176
+ try {
177
+ await prepareLegacyUpgrade(deadline);
178
+ }
179
+ finally {
180
+ removeOperation(operation.id);
181
+ }
182
+ }
183
+ let runtime = null;
184
+ let shutdownPromise = null;
185
+ let connecting = null;
186
+ let rejectedSession = null;
187
+ let signalController = null;
188
+ async function runtimeStatus(deadline = Date.now() + 8000) {
189
+ if (!runtime)
190
+ throw new Error('This process does not own the local service.');
191
+ return { nonce: runtime.record.nonce, pid: process.pid, version: CLI_VERSION, local: runtime.record.state, cloud: presenceCloudState(), work: await snapshotOwnedWork(deadline) };
192
+ }
193
+ async function connectCloud() {
194
+ if (!runtime || runtime.record.state === 'stopping' || connecting || liveClient())
195
+ return;
196
+ if (rejectedSession === sessionFingerprint())
197
+ return;
198
+ connecting = (async () => {
199
+ try {
200
+ await startPresence();
201
+ rejectedSession = null;
202
+ }
203
+ catch (error) {
204
+ if (error instanceof NotLoggedIn)
205
+ rejectedSession = sessionFingerprint();
206
+ console.warn(error instanceof NotLoggedIn ? 'Cloud sign-in is required. Local service controls remain available.' : 'Cloud connection is unavailable. Local service controls remain available.');
207
+ }
208
+ })();
209
+ try {
210
+ await connecting;
211
+ }
212
+ finally {
213
+ connecting = null;
214
+ }
215
+ }
216
+ export function localRuntimeOwned() { return runtime !== null; }
217
+ async function shutdown(force, operationId, deadline) {
218
+ if (shutdownPromise)
219
+ return shutdownPromise;
220
+ shutdownPromise = (async () => {
221
+ if (!runtime)
222
+ throw new Error('This process does not own the service.');
223
+ const current = runtime;
224
+ current.record.state = 'stopping';
225
+ updateRuntime(current.record);
226
+ await bounded(suspendPresenceWork({ waitForClaims: !force }), deadline);
227
+ const work = await snapshotOwnedWork(deadline);
228
+ assertOperationOwned(operationId);
229
+ if (work.unknown.length || (!force && (work.active.length || work.pending.length))) {
230
+ current.record.state = 'running';
231
+ updateRuntime(current.record);
232
+ resumePresenceWork();
233
+ const names = [...work.active, ...work.pending, ...work.unknown].map((w) => (w.cardId ?? w.workId ?? 'Card identity unavailable') + ' — ' + (w.harness ?? 'harness unknown')).join('\n ');
234
+ throw new Error('Not stopped: owned work is ' + (work.unknown.length ? 'not fully verified' : 'still running') + '.\n ' + names + '\nWait for it to finish' + (work.unknown.length ? ' and run cs status.' : ', or interrupt it explicitly with cs stop --force or cs restart --force. Saved work will need continuation.'));
235
+ }
236
+ if (force)
237
+ await interruptOwnedWork(operationId, deadline);
238
+ const after = await snapshotOwnedWork(deadline);
239
+ if (after.active.length || (force ? pendingExecution(after) : after.pending).length || after.unknown.length)
240
+ throw new Error('Owned execution remains. No replacement can start.');
241
+ assertOperationOwned(operationId);
242
+ clearInterval(current.timer);
243
+ // Once local execution is proven closed, cloud acknowledgements and open
244
+ // Companion requests cannot veto stopping this process. Deferred claim
245
+ // journals and the instance interruption intent survive the local exit.
246
+ let cleanupConfirmed = true;
247
+ try {
248
+ await bounded(stopPresence(), Math.min(deadline, Date.now() + 5000));
249
+ }
250
+ catch {
251
+ cleanupConfirmed = false;
252
+ }
253
+ if (current.onStop) {
254
+ try {
255
+ await bounded(current.onStop(), Math.min(deadline, Date.now() + 1000));
256
+ }
257
+ catch {
258
+ cleanupConfirmed = false;
259
+ }
260
+ }
261
+ if (!cleanupConfirmed)
262
+ console.warn('Local execution ended. Cloud or Companion cleanup could not be acknowledged; remote status will update after reconnecting or its freshness window.');
263
+ const result = await runtimeStatus(deadline);
264
+ result.local = 'stopped';
265
+ if (force)
266
+ result.interruptedWork = [...work.active, ...work.pending];
267
+ removeRuntime(current.record.nonce);
268
+ return result;
269
+ })();
270
+ try {
271
+ return await shutdownPromise;
272
+ }
273
+ catch (error) {
274
+ if (runtime && runtime.record.state === 'stopping') {
275
+ runtime.record.state = 'degraded';
276
+ updateRuntime(runtime.record);
277
+ }
278
+ if (!force && error instanceof Error && /deadline|30 seconds/.test(error.message)) {
279
+ throw new Error(error.message + ' To explicitly interrupt owned work, use cs stop --force or cs restart --force.', { cause: error });
280
+ }
281
+ throw error;
282
+ }
283
+ finally {
284
+ shutdownPromise = null;
285
+ }
286
+ }
287
+ async function readBody(req) {
288
+ const chunks = [];
289
+ let bytes = 0;
290
+ for await (const chunk of req) {
291
+ bytes += chunk.length;
292
+ if (bytes > 2048)
293
+ throw new Error('Local control request is too large.');
294
+ chunks.push(chunk);
295
+ }
296
+ const value = JSON.parse(Buffer.concat(chunks).toString());
297
+ if (!value || typeof value !== 'object' || Array.isArray(value))
298
+ throw new Error('Invalid local control request.');
299
+ return value;
300
+ }
301
+ /** CLI and Companion share this owner, which is available before cloud sign-in. */
302
+ export async function startLocalRuntime({ onStop } = {}) {
303
+ if (runtime)
304
+ return true;
305
+ const deadline = Date.now() + MAX_COMMAND_MS;
306
+ const existing = await inspectLocalRuntime(deadline);
307
+ if (existing)
308
+ return false;
309
+ const handover = process.env.CTRL_SPC_LIFECYCLE_HANDOVER;
310
+ let operation;
311
+ if (handover) {
312
+ const reserved = readOperation();
313
+ if (!reserved || reserved.successor !== handover || reserved.entry !== entryPath() || !process.argv.includes('--lifecycle-handover=' + handover))
314
+ throw new Error('The service handover is not owned by this installation.');
315
+ operation = reserved;
316
+ }
317
+ else
318
+ operation = await claimLifecycleOperation('start', deadline);
319
+ let published = false;
320
+ try {
321
+ const old = readRuntime();
322
+ if (old) {
323
+ const live = await inspectProcess(old.process.pid, deadline);
324
+ if (live && processIdentityMatches(old.process, live))
325
+ return false;
326
+ removeRuntime(old.nonce);
327
+ }
328
+ const legacyUpgrade = await prepareLegacyUpgrade(deadline);
329
+ const machine = getMachineIdentity();
330
+ const nonce = handover ?? randomUUID();
331
+ initializeOwnedWork({ instanceNonce: nonce, accountId: null, machineId: machine.id, legacyUpgrade });
332
+ const remainingWork = await snapshotOwnedWork(deadline);
333
+ if (remainingWork.active.length || remainingWork.pending.length || remainingWork.unknown.length)
334
+ throw new Error('A prior owned assignment still needs recovery. Run cs status before starting another service.');
335
+ ensureAutostart();
336
+ const token = lifecycleToken();
337
+ const self = await inspectProcess(process.pid, deadline);
338
+ if (!self)
339
+ throw new Error('The service could not verify its own process identity.');
340
+ const server = createServer((req, res) => {
341
+ void (async () => {
342
+ const auth = Buffer.from(req.headers.authorization ?? '');
343
+ const expected = Buffer.from('Bearer ' + token);
344
+ const port = server.address().port;
345
+ const valid = !req.headers.origin && req.headers.host === '127.0.0.1:' + port && auth.length === expected.length && timingSafeEqual(auth, expected);
346
+ res.setHeader('content-type', 'application/json');
347
+ res.setHeader('cache-control', 'no-store');
348
+ if (!valid) {
349
+ res.writeHead(403);
350
+ res.end(JSON.stringify({ error: 'Local control is not authorized.' }));
351
+ return;
352
+ }
353
+ if (req.url === '/status' && req.method === 'GET') {
354
+ res.end(JSON.stringify(await runtimeStatus()));
355
+ return;
356
+ }
357
+ if (req.url !== '/stop' || req.method !== 'POST' || req.headers['content-type'] !== 'application/json') {
358
+ res.writeHead(405);
359
+ res.end(JSON.stringify({ error: 'Unsupported local control action.' }));
360
+ return;
361
+ }
362
+ const body = await readBody(req);
363
+ const op = readOperation();
364
+ if (typeof body.force !== 'boolean' || typeof body.deadline !== 'number' || !op || op.id !== body.operationId)
365
+ throw new Error('Invalid lifecycle operation.');
366
+ const result = await shutdown(body.force, op.id, Math.min(body.deadline, Date.now() + MAX_COMMAND_MS));
367
+ const exitStoppedOwner = () => { server.close(); process.exit(0); };
368
+ res.once('finish', exitStoppedOwner);
369
+ res.once('close', exitStoppedOwner);
370
+ // A cancelled controller may have closed the socket during cleanup.
371
+ // Ownership is already released; that socket must not keep us alive.
372
+ setTimeout(exitStoppedOwner, 250).unref();
373
+ res.end(JSON.stringify(result));
374
+ })().catch((error) => {
375
+ if (!res.headersSent)
376
+ res.writeHead(409);
377
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Local control failed.' }));
378
+ });
379
+ });
380
+ server.requestTimeout = 5000;
381
+ server.headersTimeout = 5000;
382
+ await bounded(new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }), deadline);
383
+ const record = { schema: 1, nonce, config: resolve(configDir()), machineId: machine.id, process: self, entry: entryPath(), version: CLI_VERSION, port: server.address().port, state: 'running' };
384
+ try {
385
+ publishRuntime(record);
386
+ }
387
+ catch (error) {
388
+ server.close();
389
+ throw error;
390
+ }
391
+ published = true;
392
+ const timer = setInterval(() => void connectCloud(), 3000);
393
+ runtime = { record, server, timer, onStop };
394
+ const upgrade = readMigration();
395
+ if (upgrade && legacyUpgrade)
396
+ writeMigration({ ...upgrade, completed: true });
397
+ if (!upgrade)
398
+ writeMigration({ schema: 1, boot: await osBootIdentity(deadline), installation: entryPath(), prepared: true, completed: true, machineId: machine.id, accountId: null });
399
+ if (!handover)
400
+ removeOperation(operation.id);
401
+ void connectCloud();
402
+ return true;
403
+ }
404
+ finally {
405
+ if (!handover && !published && !operation.successor)
406
+ removeOperation(operation.id);
407
+ }
408
+ }
409
+ export async function stopLocalOwnerForSignal() {
410
+ if (signalController)
411
+ return;
412
+ // An external controller can unload our verified launchd job after draining,
413
+ // including an old unconditional KeepAlive job still loaded at this login.
414
+ signalController = spawn(process.execPath, [entryPath(), 'stop'], {
415
+ windowsHide: true, stdio: ['ignore', 'inherit', 'inherit'], env: { ...process.env, CTRL_SPC_LIFECYCLE_HANDOVER: undefined },
416
+ });
417
+ const controller = signalController;
418
+ try {
419
+ await new Promise((resolve, reject) => { controller.once('error', reject); controller.once('exit', () => resolve()); });
420
+ }
421
+ finally {
422
+ signalController = null;
423
+ }
424
+ }
425
+ async function waitForExit(record, deadline) {
426
+ while (Date.now() < deadline) {
427
+ const actual = await inspectProcess(record.process.pid, deadline);
428
+ if (!actual || !processIdentityMatches(record.process, actual))
429
+ return;
430
+ await wait(50);
431
+ }
432
+ throw new Error('The previous service has not exited. No replacement was started.');
433
+ }
434
+ export async function runLifecycleCommand(action, force = false) {
435
+ const deadline = Date.now() + MAX_COMMAND_MS;
436
+ const operation = await claimLifecycleOperation(action, deadline);
437
+ let affected = [];
438
+ try {
439
+ const current = await inspectLocalRuntime(deadline);
440
+ assertOperationOwned(operation.id);
441
+ if (current) {
442
+ const job = await loadedStartupJob(current.record.process.pid, deadline);
443
+ if (!current.status) {
444
+ if (!force)
445
+ throw new Error('The verified service is not answering local control. Run cs restart --force to interrupt verified owned work and recover this service.');
446
+ initializeOwnedWork({ instanceNonce: current.record.nonce, accountId: null, machineId: current.record.machineId });
447
+ const work = await snapshotOwnedWork(deadline);
448
+ affected = [...work.active, ...work.pending];
449
+ if (work.unknown.length || pendingExecution(work).length)
450
+ throw new Error('Some owned execution could not be verified. No replacement was started. Run cs status to inspect the pending work.');
451
+ assertOperationOwned(operation.id);
452
+ await markOwnedWorkInterrupted(operation.id);
453
+ assertOperationOwned(operation.id);
454
+ await terminateOwnedRoot(current.record.process, deadline);
455
+ await waitForExit(current.record, deadline);
456
+ assertOperationOwned(operation.id);
457
+ await interruptOwnedWork(operation.id, deadline);
458
+ removeRuntime(current.record.nonce);
459
+ }
460
+ else {
461
+ const stopped = await control(current.record, 'stop', deadline, { force, operationId: operation.id, deadline });
462
+ affected = stopped.interruptedWork ?? [...current.status.work.active, ...current.status.work.pending];
463
+ }
464
+ assertOperationOwned(operation.id);
465
+ await unloadStoppedStartupJob(job, deadline);
466
+ await waitForExit(current.record, deadline);
467
+ }
468
+ else {
469
+ await prepareLegacyUpgrade(deadline);
470
+ const stale = readRuntime();
471
+ if (stale)
472
+ removeRuntime(stale.nonce);
473
+ const orphanScope = ownedWorkInstanceNonces();
474
+ initializeOwnedWork({ instanceNonce: randomUUID(), accountId: null, machineId: getMachineIdentity().id });
475
+ const remaining = await snapshotOwnedWork(deadline);
476
+ affected = [...remaining.active, ...remaining.pending];
477
+ if (remaining.unknown.length || (!force && (remaining.active.length || remaining.pending.length)))
478
+ throw new Error('Prior owned execution needs recovery. Wait, or use the explicit force command when ownership is verified.');
479
+ assertOperationOwned(operation.id);
480
+ if (force)
481
+ await interruptOwnedWork(operation.id, deadline, orphanScope);
482
+ }
483
+ if (force && affected.length) {
484
+ console.log('Interrupted work on ' + machineHostname() + ':');
485
+ for (const item of affected)
486
+ console.log(' ' + (item.cardId ?? item.workId ?? 'Card identity unavailable') + ' — ' + (item.harness ?? 'harness unknown'));
487
+ console.log('Open each interrupted card and send a new message to continue from saved progress.');
488
+ }
489
+ if (action === 'stop') {
490
+ console.log('CTRL+SPC is ' + (!current && !affected.length ? 'already stopped' : 'stopped') + ' on ' + machineHostname() + '. Start again: cs start. Future-login preference is unchanged.');
491
+ return;
492
+ }
493
+ assertOperationOwned(operation.id);
494
+ prepareAutostart();
495
+ operation.successor = randomUUID();
496
+ operation.entry = entryPath();
497
+ updateOperation(operation);
498
+ mkdirSync(configDir(), { recursive: true });
499
+ let child;
500
+ if (process.platform === 'win32') {
501
+ const executable = windowsJobExecutable(deadline);
502
+ assertOperationOwned(operation.id);
503
+ operation.successorPid = await launchWindowsService(executable, entryPath(), operation.successor, join(configDir(), 'daemon.log'), deadline);
504
+ updateOperation(operation);
505
+ }
506
+ else {
507
+ const log = openSync(join(configDir(), 'daemon.log'), 'a', 0o600);
508
+ try {
509
+ child = spawn(process.execPath, [entryPath(), 'start', '--lifecycle-handover=' + operation.successor], {
510
+ detached: true, windowsHide: true, stdio: ['ignore', log, log],
511
+ env: { ...process.env, CTRL_SPC_LIFECYCLE_HANDOVER: operation.successor },
512
+ });
513
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
514
+ operation.successorPid = child.pid;
515
+ updateOperation(operation);
516
+ child.unref();
517
+ }
518
+ finally {
519
+ closeSync(log);
520
+ }
521
+ }
522
+ while (Date.now() < deadline) {
523
+ const next = await inspectLocalRuntime(deadline);
524
+ if (next?.status && next.record.nonce === operation.successor && next.record.version === CLI_VERSION) {
525
+ console.log('Restarted CTRL+SPC on ' + machineHostname() + '.\nRunning version: ' + CLI_VERSION + ' — verified\nCloud connection: ' + next.status.cloud + '\nThe service stays running when this terminal closes.\nInterrupted cards need a new message to continue.');
526
+ if (next.status.cloud === 'sign-in-required')
527
+ console.log('Sign in on ' + machineHostname() + ': cs login. Local restart succeeded.');
528
+ else if (next.status.cloud !== 'online')
529
+ console.log('Run cs status to check cloud readiness and installed harnesses. If the connection remains unavailable, check this computer’s network.');
530
+ removeOperation(operation.id);
531
+ return;
532
+ }
533
+ if ((child && child.exitCode !== null) || (!child && !await inspectProcess(operation.successorPid, deadline)))
534
+ throw new Error('The installed replacement exited before verification. Check this instance with cs status; saved work remains.');
535
+ await wait(100);
536
+ }
537
+ throw new Error('The installed replacement could not be verified within 30 seconds. Run cs status; no second replacement was started.');
538
+ }
539
+ finally {
540
+ if (!operation.successor)
541
+ removeOperation(operation.id);
542
+ }
543
+ }
544
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--installation-checkpoint') {
545
+ writeInstallationCheckpoint().catch(() => {
546
+ console.warn('CTRL+SPC could not record this installation’s boot checkpoint. Its first command will prepare the one-time upgrade if needed.');
547
+ });
548
+ }