@ctrl-spc/cs 0.7.13 → 0.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -3
- package/dist/autostart.js +103 -122
- package/dist/companion-ui.js +34 -6
- package/dist/companion.js +59 -150
- package/dist/config.js +52 -1
- package/dist/daemon-lifecycle.js +548 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +756 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/index.js +70 -74
- package/dist/login.js +5 -3
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +620 -428
- package/dist/panel3/checkout.js +100 -8
- package/dist/panel3/prompt.js +16 -8
- package/dist/panel3/run.js +891 -541
- package/dist/panel3/spawn.js +59 -11
- package/dist/panel3/tools.js +28 -5
- package/dist/presence.js +183 -24
- package/dist/supabase.js +43 -9
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/panel3/spawn.js
CHANGED
|
@@ -123,7 +123,7 @@ import { spawn as spawnChild } from 'node:child_process';
|
|
|
123
123
|
import { randomUUID } from 'node:crypto';
|
|
124
124
|
import { agentPath } from '../agents.js';
|
|
125
125
|
import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
|
|
126
|
-
import { windowsSafeSpawn } from '../win-shell.js';
|
|
126
|
+
import { windowsSafeSpawn, spawnOwnedProcess, releaseOwnedProcess } from '../win-shell.js';
|
|
127
127
|
import { modelChoiceRules } from './prompt.js';
|
|
128
128
|
const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
|
|
129
129
|
/**
|
|
@@ -405,11 +405,16 @@ function tail(text, chars = 500) {
|
|
|
405
405
|
* process still alive" after the daemon that started it has been killed. So the
|
|
406
406
|
* caller gets the pid immediately, writes it, and then waits.
|
|
407
407
|
*/
|
|
408
|
-
export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}) {
|
|
408
|
+
export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}, lifecycle) {
|
|
409
|
+
if (lifecycle?.interrupted())
|
|
410
|
+
return { pid: null, session: Promise.resolve(null),
|
|
411
|
+
answered: Promise.resolve({ ok: false, reason: 'Work was interrupted by the service command.' }),
|
|
412
|
+
interrupted: lifecycle.interrupted, completed: lifecycle.complete };
|
|
409
413
|
const failed = (reason) => ({
|
|
410
414
|
pid: null,
|
|
411
415
|
session: Promise.resolve(null),
|
|
412
416
|
answered: Promise.resolve({ ok: false, reason }),
|
|
417
|
+
...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}),
|
|
413
418
|
});
|
|
414
419
|
let agent;
|
|
415
420
|
try {
|
|
@@ -450,10 +455,12 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
|
|
|
450
455
|
const { args, shell } = windowsSafeSpawn(bin, ARGS);
|
|
451
456
|
let child;
|
|
452
457
|
try {
|
|
453
|
-
|
|
458
|
+
lifecycle?.beforeSpawn();
|
|
459
|
+
const options = {
|
|
454
460
|
// The repo-wide invariant for ANY child process (MEMORY: "Windows silence
|
|
455
461
|
// decision"). A user must never see a console flash.
|
|
456
462
|
windowsHide: true,
|
|
463
|
+
detached: process.platform !== 'win32',
|
|
457
464
|
shell,
|
|
458
465
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
459
466
|
// NEVER the daemon's inherited cwd, which under a launchd login item is
|
|
@@ -465,7 +472,10 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
|
|
|
465
472
|
said. Claude is spawned with the environment untouched, exactly as
|
|
466
473
|
before. */
|
|
467
474
|
...(home ? { env: { ...process.env, CODEX_HOME: home } } : {}),
|
|
468
|
-
}
|
|
475
|
+
};
|
|
476
|
+
child = lifecycle
|
|
477
|
+
? spawnOwnedProcess(bin, args, options, lifecycle.id)
|
|
478
|
+
: spawnChild(bin, args, options);
|
|
469
479
|
}
|
|
470
480
|
catch (err) {
|
|
471
481
|
// NOT `err.message`, which names the binary's absolute path. See
|
|
@@ -488,6 +498,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
|
|
|
488
498
|
else if (agent === 'claude') {
|
|
489
499
|
observeSession(launchedOwnerSession?.resumeSessionId ?? launchedOwnerSession?.freshSessionId ?? null);
|
|
490
500
|
}
|
|
501
|
+
let registration = Promise.resolve();
|
|
491
502
|
const answered = new Promise((resolve) => {
|
|
492
503
|
let stdout = '';
|
|
493
504
|
let stderr = '';
|
|
@@ -512,11 +523,32 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
|
|
|
512
523
|
/* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
|
|
513
524
|
the primary reclaim and the startup sweep the backstop; a home left
|
|
514
525
|
behind holds a copy of the user's codex credential. */
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
526
|
+
void (async () => {
|
|
527
|
+
await registration.catch(() => { });
|
|
528
|
+
if (lifecycle && child.pid) {
|
|
529
|
+
// A wrapper's close is not tree exit. Keep the run owned until the
|
|
530
|
+
// actual descendants end; lifecycle commands have their own deadline.
|
|
531
|
+
let warned = false;
|
|
532
|
+
while (true) {
|
|
533
|
+
try {
|
|
534
|
+
await lifecycle.exited();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
if (!warned) {
|
|
539
|
+
console.warn(`Owned agent execution has not ended: ${error.message}`);
|
|
540
|
+
warned = true;
|
|
541
|
+
}
|
|
542
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (home && !ownerSession)
|
|
547
|
+
removeCodexRunHome(home);
|
|
548
|
+
if (!sessionSettled)
|
|
549
|
+
observeSession(null);
|
|
550
|
+
resolve(answer);
|
|
551
|
+
})();
|
|
520
552
|
};
|
|
521
553
|
child.stdout?.on('data', (d) => {
|
|
522
554
|
stdout = collect(stdout, String(d));
|
|
@@ -582,7 +614,23 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
|
|
|
582
614
|
handler the EPIPE is an unhandled stream error and takes the daemon down
|
|
583
615
|
with it. */
|
|
584
616
|
child.stdin?.on('error', () => { });
|
|
585
|
-
|
|
617
|
+
if (lifecycle) {
|
|
618
|
+
registration = lifecycle.register(child, agent);
|
|
619
|
+
void registration.then(() => {
|
|
620
|
+
if (lifecycle.interrupted())
|
|
621
|
+
return;
|
|
622
|
+
releaseOwnedProcess(child);
|
|
623
|
+
child.stdin?.end(prompt);
|
|
624
|
+
}).catch((error) => {
|
|
625
|
+
// No prompt was delivered. The held native handle may be cancelled
|
|
626
|
+
// even when durable ownership registration itself failed.
|
|
627
|
+
child.stdin?.destroy();
|
|
628
|
+
child.kill('SIGKILL');
|
|
629
|
+
finish({ ok: false, reason: `The agent could not be safely registered: ${error.message}` });
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
else
|
|
633
|
+
child.stdin?.end(prompt);
|
|
586
634
|
});
|
|
587
|
-
return { pid: child.pid ?? null, session, answered };
|
|
635
|
+
return { pid: child.pid ?? null, session, answered, ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}) };
|
|
588
636
|
}
|
package/dist/panel3/tools.js
CHANGED
|
@@ -126,7 +126,7 @@ import { z } from 'zod';
|
|
|
126
126
|
import { returned } from './client.js';
|
|
127
127
|
import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
|
|
128
128
|
import { rememberSecret, redactArgs } from './secrets.js';
|
|
129
|
-
import { workBrief } from './prompt.js';
|
|
129
|
+
import { workBrief, landingOutcomeContext } from './prompt.js';
|
|
130
130
|
import { listCodexModels } from './codex-models.js';
|
|
131
131
|
/* The harness this daemon spawns with, which is the process this server runs
|
|
132
132
|
in. It is what `panel3_runs.harness` is written from at spawn, so reading it
|
|
@@ -2030,6 +2030,26 @@ const TOOLS = [
|
|
|
2030
2030
|
+ 'Nobody will be asked again.';
|
|
2031
2031
|
},
|
|
2032
2032
|
},
|
|
2033
|
+
{
|
|
2034
|
+
name: 'recover_landing',
|
|
2035
|
+
levels: [2],
|
|
2036
|
+
description: 'Recover a merge the person already approved. The product manages Git metadata; you resolve '
|
|
2037
|
+
+ 'and verify ordinary files in this card’s working copy. Use prepare to bring the current base '
|
|
2038
|
+
+ 'into the card branch, including after a restart or a Git permission failure. Resolve conflicts '
|
|
2039
|
+
+ 'and run checks, record write_report(work_complete=true), then use finish to retry landing. '
|
|
2040
|
+
+ 'No new approval is needed. Never change the sandbox or ask the person to reopen with Git access. '
|
|
2041
|
+
+ 'After landing succeeds, record completion again and reply. If recovery fails, report the exact remaining action.',
|
|
2042
|
+
input: { action: z.enum(['prepare', 'finish']) },
|
|
2043
|
+
handler: async (caller, args) => {
|
|
2044
|
+
if (!caller.isOwner || caller.level !== 2 || !caller.processToken) {
|
|
2045
|
+
throw new Error('Only the current conversation owner can recover its approved merge.');
|
|
2046
|
+
}
|
|
2047
|
+
if (!caller.recoverLanding)
|
|
2048
|
+
throw new Error('Merge recovery is unavailable on this companion. Update and restart the companion on this card’s machine, then retry in this card.');
|
|
2049
|
+
const { action } = args;
|
|
2050
|
+
return landingOutcomeContext(await caller.recoverLanding(caller.runId, caller.processToken, action)).join('\n');
|
|
2051
|
+
},
|
|
2052
|
+
},
|
|
2033
2053
|
/**
|
|
2034
2054
|
* ═══ THE AGENT ASKS FOR THE OFFER; THE PRODUCT WRITES IT. ═══
|
|
2035
2055
|
*
|
|
@@ -2051,7 +2071,8 @@ const TOOLS = [
|
|
|
2051
2071
|
levels: [2],
|
|
2052
2072
|
description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
|
|
2053
2073
|
+ 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
|
|
2054
|
-
+ 'the main branch.
|
|
2074
|
+
+ 'the main branch. If the person already approved a merge that needs recovery, use recover_landing instead of asking again. '
|
|
2075
|
+
+ 'First record verified completion with write_report(work_complete=true). '
|
|
2055
2076
|
+ 'Use say for progress; this tool cannot announce work you intend to do. You do not write the question or the answers and you never merge anything: '
|
|
2056
2077
|
+ 'the product composes both, and it performs the merge itself if they choose to put the work '
|
|
2057
2078
|
+ 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
|
|
@@ -2824,7 +2845,7 @@ const TOOLS = [
|
|
|
2824
2845
|
await whileRunning(client.rpc('panel3_request_completion', {
|
|
2825
2846
|
p_run_id: runId, p_process_token: processToken, p_summary: report,
|
|
2826
2847
|
}), runId, 'declare completion of');
|
|
2827
|
-
return 'Completion recorded. If the finished work needs
|
|
2848
|
+
return 'Completion recorded. If an already-approved merge is awaiting recovery, call recover_landing(action="finish"); if the finished work needs its first ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
|
|
2828
2849
|
}
|
|
2829
2850
|
await whileRunning(processToken === undefined
|
|
2830
2851
|
? client.from('panel3_runs').update({ report }).eq('id', runId)
|
|
@@ -3341,6 +3362,8 @@ export function toolShape(name) {
|
|
|
3341
3362
|
function toolAvailable(tool, level, isOwner) {
|
|
3342
3363
|
if (isOwner && tool.name === 'escalate')
|
|
3343
3364
|
return false;
|
|
3365
|
+
if (tool.name === 'recover_landing' && !isOwner)
|
|
3366
|
+
return false;
|
|
3344
3367
|
return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
|
|
3345
3368
|
}
|
|
3346
3369
|
function toolNamed(name) {
|
|
@@ -3431,7 +3454,7 @@ export async function processActivationIsCurrent(client, runId, processToken) {
|
|
|
3431
3454
|
* below goes through the signed-in user's own token, so guessing a run id would
|
|
3432
3455
|
* still only ever reach that user's own record.
|
|
3433
3456
|
*/
|
|
3434
|
-
export async function startToolsServer(client, dispatch) {
|
|
3457
|
+
export async function startToolsServer(client, dispatch, recoverLanding) {
|
|
3435
3458
|
const { data, error } = await client.auth.getUser();
|
|
3436
3459
|
if (error)
|
|
3437
3460
|
throw new Error(`could not start the tools server: ${error.message}`);
|
|
@@ -3550,7 +3573,7 @@ export async function startToolsServer(client, dispatch) {
|
|
|
3550
3573
|
};
|
|
3551
3574
|
const server = buildServer({
|
|
3552
3575
|
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
3553
|
-
processToken, isOwner, dispatch,
|
|
3576
|
+
processToken, isOwner, dispatch, recoverLanding,
|
|
3554
3577
|
});
|
|
3555
3578
|
await server.connect(transport);
|
|
3556
3579
|
await transport.handleRequest(req, res, body);
|
package/dist/presence.js
CHANGED
|
@@ -1,17 +1,81 @@
|
|
|
1
1
|
import { platform } from 'node:os';
|
|
2
|
-
import { getClient } from './supabase.js';
|
|
2
|
+
import { getClient, disposeClient, confirmedSessionRejection, NotLoggedIn } from './supabase.js';
|
|
3
3
|
import { getMachineIdentity, mcpToken, readSession, supersededMachineIds, clearSupersededMachineIds } from './config.js';
|
|
4
4
|
import { detectAgents } from './agents.js';
|
|
5
5
|
import { installSkills } from './skills.js';
|
|
6
6
|
import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
|
|
7
7
|
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS, ORCHESTRATOR_POLL_INTERVAL_MS } from './env.js';
|
|
8
8
|
import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
|
|
9
|
-
import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
|
|
9
|
+
import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, reconcileInterruptedWork, refreshLegacyTodoHolds, takeRunMessages, liveAgents, } from './orchestrator.js';
|
|
10
10
|
/* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
|
|
11
11
|
import { sweepStrandedCodexHomes } from './codex-home.js';
|
|
12
|
-
import {
|
|
12
|
+
import { hasOwnedWorkContext, ownedWorkAllowed, beginOwnedClaim, suspendOwnedWorkAdmission, resumeOwnedWorkAdmission, setOwnedWorkAccount, reserveOwnedWork, interruptionReceipts, acknowledgeInterruption, failedPanelPreparations, acknowledgePreparationFailure, legacyPanelSnapshotNeeded, completeLegacyPanelSnapshot, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims, pendingPanelAttempt, ownedPanelAttemptHeld, ownedLocalPanelOwnerHeld, stopOwnedPanelAttempt } from './daemon-processes.js';
|
|
13
13
|
import { startPanel } from './panel3/run.js';
|
|
14
14
|
let presence = null;
|
|
15
|
+
let cloudState = 'connecting';
|
|
16
|
+
let suspended = false;
|
|
17
|
+
let presenceGeneration = 0;
|
|
18
|
+
export function presenceCloudState() { return cloudState; }
|
|
19
|
+
export async function suspendPresenceWork(options = {}) {
|
|
20
|
+
suspended = true;
|
|
21
|
+
await suspendOwnedWorkAdmission(options);
|
|
22
|
+
}
|
|
23
|
+
export function resumePresenceWork() { suspended = false; resumeOwnedWorkAdmission(); }
|
|
24
|
+
function mayClaim() { return !suspended && cloudState === 'online' && ownedWorkAllowed(); }
|
|
25
|
+
function panelWorkLifecycle(accountId) {
|
|
26
|
+
const allowed = () => mayClaim() && presence?.userId === accountId;
|
|
27
|
+
return {
|
|
28
|
+
allowed,
|
|
29
|
+
beginClaim: () => {
|
|
30
|
+
if (!allowed())
|
|
31
|
+
throw new Error('This machine is not accepting new work.');
|
|
32
|
+
return beginOwnedClaim();
|
|
33
|
+
},
|
|
34
|
+
prepare: (runId) => {
|
|
35
|
+
if (presence?.userId !== accountId)
|
|
36
|
+
throw new Error('This execution belongs to the previous signed-in account.');
|
|
37
|
+
const reservation = reserveOwnedWork(null, null, runId);
|
|
38
|
+
return {
|
|
39
|
+
setAttempt: (attempt) => reservation.setReference({ surface: 'panel', ...attempt }),
|
|
40
|
+
deferFailure: reservation.deferFailure,
|
|
41
|
+
execution: {
|
|
42
|
+
id: reservation.id,
|
|
43
|
+
register: async (child, harness) => {
|
|
44
|
+
await reservation.register(child, harness);
|
|
45
|
+
},
|
|
46
|
+
interrupted: reservation.interrupted,
|
|
47
|
+
exited: reservation.exited,
|
|
48
|
+
complete: reservation.complete,
|
|
49
|
+
beforeSpawn: reservation.beforeSpawn,
|
|
50
|
+
waitForPromptAdmission: reservation.waitForPromptAdmission,
|
|
51
|
+
},
|
|
52
|
+
finish: reservation.finishPreparation,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
claimAttempt: pendingPanelAttempt,
|
|
56
|
+
heldLocalOwner: ownedLocalPanelOwnerHeld,
|
|
57
|
+
heldAttempt: (attempt) => presence?.userId !== accountId || ownedPanelAttemptHeld(attempt),
|
|
58
|
+
stopHeldAttempt: async (attempt) => {
|
|
59
|
+
if (presence?.userId !== accountId)
|
|
60
|
+
throw new Error('This execution belongs to the previous signed-in account.');
|
|
61
|
+
await stopOwnedPanelAttempt(attempt);
|
|
62
|
+
},
|
|
63
|
+
beginRpc: (action) => beginRpcClaim('panel', action),
|
|
64
|
+
deferRpc: deferRpcClaim,
|
|
65
|
+
pendingRpcs: () => pendingRpcClaims('panel'),
|
|
66
|
+
recordRpc: (id, attempts, interrupted) => recordRpcClaims(id, attempts.map((attempt) => ({ surface: 'panel', ...attempt })), interrupted),
|
|
67
|
+
finishRpcs: finishRpcClaims,
|
|
68
|
+
failedPreparations: failedPanelPreparations,
|
|
69
|
+
acknowledgePreparationFailure,
|
|
70
|
+
receipts: () => interruptionReceipts('panel').flatMap((receipt) => receipt.reference.surface === 'panel'
|
|
71
|
+
? [{ id: receipt.id, operationId: receipt.operationId, interruptedAt: receipt.interruptedAt,
|
|
72
|
+
pid: receipt.process?.pid ?? null, attempt: receipt.reference }] : []),
|
|
73
|
+
acknowledge: acknowledgeInterruption,
|
|
74
|
+
legacyRequired: legacyPanelSnapshotNeeded,
|
|
75
|
+
recordLegacy: (attempt) => recordExitedInterruption({ surface: 'panel', ...attempt }),
|
|
76
|
+
legacyComplete: completeLegacyPanelSnapshot,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
15
79
|
/** In-flight guard: startPresence yields to the event loop (network setSession)
|
|
16
80
|
* before `presence` is assigned, so a plain `if (presence)` check lets two
|
|
17
81
|
* concurrent callers both build interval pairs — the first pair then leaks and
|
|
@@ -97,12 +161,70 @@ export function bindSignedOutWatcher(client) {
|
|
|
97
161
|
if (event !== 'SIGNED_OUT')
|
|
98
162
|
return;
|
|
99
163
|
console.error('Session expired and could not be refreshed. Run `cs login` to sign in again.');
|
|
100
|
-
|
|
164
|
+
cloudState = 'sign-in-required';
|
|
165
|
+
void suspendOwnedWorkAdmission();
|
|
166
|
+
void stopToolsServer().catch((error) => console.warn(`Agent tools could not be closed: ${String(error)}`));
|
|
101
167
|
});
|
|
102
168
|
return data.subscription;
|
|
103
169
|
}
|
|
170
|
+
/** A new sign-in can succeed before recovery or local tools do. Retry that
|
|
171
|
+
* unfinished readiness on this same client; never rebuild a refresh writer just
|
|
172
|
+
* because a recovery RPC was temporarily unavailable. */
|
|
173
|
+
async function restorePresenceWork(p) {
|
|
174
|
+
const { data, error } = await p.client.auth.getUser();
|
|
175
|
+
if (error)
|
|
176
|
+
throw error;
|
|
177
|
+
const currentUserId = data.user?.id;
|
|
178
|
+
if (!currentUserId)
|
|
179
|
+
throw new NotLoggedIn();
|
|
180
|
+
if (presence !== p)
|
|
181
|
+
throw new Error('Cloud recovery was cancelled by the service command.');
|
|
182
|
+
if (currentUserId !== p.userId) {
|
|
183
|
+
console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
|
|
184
|
+
await p.panel?.stop();
|
|
185
|
+
p.userId = currentUserId;
|
|
186
|
+
p.panel = null;
|
|
187
|
+
}
|
|
188
|
+
if (hasOwnedWorkContext())
|
|
189
|
+
setOwnedWorkAccount(currentUserId);
|
|
190
|
+
await reconcileInterruptedWork(p.client, p.identity.id);
|
|
191
|
+
if (presence !== p)
|
|
192
|
+
throw new Error('Cloud recovery was cancelled by the service command.');
|
|
193
|
+
if (!p.panel)
|
|
194
|
+
p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
|
|
195
|
+
try {
|
|
196
|
+
await p.panel.ready;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
await p.panel.stop();
|
|
200
|
+
p.panel = null;
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
await startToolsServer({ client: p.client, userId: p.userId, machineId: p.identity.id });
|
|
204
|
+
if (presence !== p)
|
|
205
|
+
throw new Error('Cloud recovery was cancelled by the service command.');
|
|
206
|
+
p.readinessPending = false;
|
|
207
|
+
cloudState = 'online';
|
|
208
|
+
if (!suspended)
|
|
209
|
+
resumeOwnedWorkAdmission();
|
|
210
|
+
}
|
|
104
211
|
async function heartbeat(p) {
|
|
212
|
+
if (p.heartbeating)
|
|
213
|
+
return;
|
|
214
|
+
p.heartbeating = true;
|
|
105
215
|
try {
|
|
216
|
+
await heartbeatInner(p);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
p.heartbeating = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function heartbeatInner(p) {
|
|
223
|
+
if (cloudState === 'sign-in-required' && !shouldRebuildClient(p.builtFromRefreshToken, readSession()?.refresh_token))
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
if (p.readinessPending)
|
|
227
|
+
await restorePresenceWork(p);
|
|
106
228
|
const { error } = await p.client
|
|
107
229
|
.from('cliv2_agents')
|
|
108
230
|
.upsert(buildPresenceHeartbeatPayload({
|
|
@@ -114,8 +236,10 @@ async function heartbeat(p) {
|
|
|
114
236
|
}), { onConflict: 'user_id,machine_id' });
|
|
115
237
|
if (error)
|
|
116
238
|
throw error;
|
|
239
|
+
cloudState = 'online';
|
|
117
240
|
}
|
|
118
241
|
catch (err) {
|
|
242
|
+
cloudState = confirmedSessionRejection(err) ? 'sign-in-required' : 'offline';
|
|
119
243
|
// A failure here used to rebuild the client from disk unconditionally, and
|
|
120
244
|
// that was the production incident: `getClient` -> `setSession` decodes
|
|
121
245
|
// whatever refresh token is on disk and hands it to auth-js, which refreshes
|
|
@@ -139,8 +263,14 @@ async function heartbeat(p) {
|
|
|
139
263
|
// rebuild (proposed twice already in earlier drafts of this fix) would make
|
|
140
264
|
// the account-reconciliation below unreachable and a concurrent `cs login`
|
|
141
265
|
// unrecoverable without restarting the daemon.
|
|
142
|
-
|
|
266
|
+
await suspendOwnedWorkAdmission();
|
|
267
|
+
p.readinessPending = true;
|
|
143
268
|
p.authSubscription.unsubscribe();
|
|
269
|
+
// Tool transports close over their startup client/account. Replacing
|
|
270
|
+
// only the session heartbeat client would leave new work on old auth.
|
|
271
|
+
await stopToolsServer();
|
|
272
|
+
await disposeClient(p.client);
|
|
273
|
+
const rebuilt = await getClient();
|
|
144
274
|
p.authSubscription = bindSignedOutWatcher(rebuilt);
|
|
145
275
|
p.client = rebuilt;
|
|
146
276
|
// shouldRebuildClient already ruled out onDisk being null/undefined to
|
|
@@ -181,15 +311,12 @@ async function heartbeat(p) {
|
|
|
181
311
|
rebuilt client rather than held against it. Warned rather than silent:
|
|
182
312
|
a daemon that starts heartbeating as a different user has had the
|
|
183
313
|
machine change owner underneath it, and that is worth a line. */
|
|
184
|
-
|
|
185
|
-
const currentUserId = data.user?.id;
|
|
186
|
-
if (currentUserId && currentUserId !== p.userId) {
|
|
187
|
-
console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
|
|
188
|
-
p.userId = currentUserId;
|
|
189
|
-
}
|
|
314
|
+
await restorePresenceWork(p);
|
|
190
315
|
}
|
|
191
316
|
}
|
|
192
|
-
catch {
|
|
317
|
+
catch (error) {
|
|
318
|
+
cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
|
|
319
|
+
}
|
|
193
320
|
}
|
|
194
321
|
// Re-attempt agent registration each heartbeat for any detected agent still
|
|
195
322
|
// idle or failed, while the tools server is up. Registration otherwise fires
|
|
@@ -248,6 +375,8 @@ async function cleanupSupersededRows(p) {
|
|
|
248
375
|
}
|
|
249
376
|
}
|
|
250
377
|
async function pollCommands(p) {
|
|
378
|
+
if (cloudState !== 'online')
|
|
379
|
+
return;
|
|
251
380
|
if (p.pollingCommands)
|
|
252
381
|
return;
|
|
253
382
|
p.pollingCommands = true;
|
|
@@ -314,6 +443,8 @@ async function pollCommands(p) {
|
|
|
314
443
|
* daemon. Same reasoning as the heartbeat's per-tick `detectAgents()`.
|
|
315
444
|
*/
|
|
316
445
|
async function pollOrchestrator(p) {
|
|
446
|
+
if (!mayClaim())
|
|
447
|
+
return;
|
|
317
448
|
/* !Cleanup Phase 0 (I5c) — WHICH CTRL+SPC SERVER THE WORKER MAY REACH.
|
|
318
449
|
Read here, per tick, for the same reason `liveAgents()` is: the tools server
|
|
319
450
|
can come up after the first tick, and a snapshot taken at `startPresence`
|
|
@@ -343,13 +474,16 @@ export async function startPresence() {
|
|
|
343
474
|
}
|
|
344
475
|
if (starting)
|
|
345
476
|
return starting;
|
|
477
|
+
const generation = presenceGeneration;
|
|
346
478
|
starting = (async () => {
|
|
347
|
-
|
|
348
|
-
if (lock.held)
|
|
349
|
-
throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
|
|
479
|
+
cloudState = 'connecting';
|
|
350
480
|
const identity = getMachineIdentity();
|
|
351
481
|
const agents = detectAgents();
|
|
352
482
|
const client = await getClient();
|
|
483
|
+
if (generation !== presenceGeneration) {
|
|
484
|
+
await disposeClient(client);
|
|
485
|
+
throw new Error('Cloud startup was cancelled by the service command.');
|
|
486
|
+
}
|
|
353
487
|
/* ═══ A DEAD SESSION STOPS THE LOOPS. IT DOES NOT SLOW THEM DOWN. ═══
|
|
354
488
|
The refresh token can die while the daemon runs (a re-login elsewhere
|
|
355
489
|
rotates it out from under this process), and until this existed nothing
|
|
@@ -374,10 +508,20 @@ export async function startPresence() {
|
|
|
374
508
|
binding and the heartbeat's post-rebuild rebind are the same code and
|
|
375
509
|
can never drift out of sync with each other. */
|
|
376
510
|
const authSubscription = bindSignedOutWatcher(client);
|
|
377
|
-
const { data } = await client.auth.getUser();
|
|
511
|
+
const { data, error: identityError } = await client.auth.getUser();
|
|
378
512
|
const userId = data.user?.id;
|
|
379
|
-
if (!userId)
|
|
380
|
-
|
|
513
|
+
if (identityError || !userId) {
|
|
514
|
+
authSubscription.unsubscribe();
|
|
515
|
+
await disposeClient(client);
|
|
516
|
+
throw identityError ?? new NotLoggedIn();
|
|
517
|
+
}
|
|
518
|
+
if (generation !== presenceGeneration) {
|
|
519
|
+
authSubscription.unsubscribe();
|
|
520
|
+
await disposeClient(client);
|
|
521
|
+
throw new Error('Cloud startup was cancelled by the service command.');
|
|
522
|
+
}
|
|
523
|
+
if (hasOwnedWorkContext())
|
|
524
|
+
setOwnedWorkAccount(userId);
|
|
381
525
|
const p = {
|
|
382
526
|
client,
|
|
383
527
|
panel: null,
|
|
@@ -404,6 +548,14 @@ export async function startPresence() {
|
|
|
404
548
|
self-heals, and the worker's unique index would refuse the retry. Scoped
|
|
405
549
|
to this machine's own id: if this daemon is starting, nothing it started
|
|
406
550
|
is still running. */
|
|
551
|
+
await reconcileInterruptedWork(p.client, identity.id);
|
|
552
|
+
if (presence !== p)
|
|
553
|
+
throw new Error('Cloud startup was cancelled by the service command.');
|
|
554
|
+
if (!p.panel)
|
|
555
|
+
p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
|
|
556
|
+
await p.panel.ready;
|
|
557
|
+
if (presence !== p)
|
|
558
|
+
throw new Error('Cloud startup was cancelled by the service command.');
|
|
407
559
|
await recoverStrandedWorkers(p.client, identity.id);
|
|
408
560
|
/* 18c SLICE 8 — AND RECLAIM WHAT A PREVIOUS LIFE LEFT ON DISK. A codex worker
|
|
409
561
|
runs against a per-run `$CODEX_HOME` seeded with a COPY OF THE USER'S
|
|
@@ -429,6 +581,8 @@ export async function startPresence() {
|
|
|
429
581
|
argument, including why an age-based scope was measured and rejected, is
|
|
430
582
|
in `sweepStrandedCodexHomes`. Do not re-widen this to the root. */
|
|
431
583
|
sweepStrandedCodexHomes();
|
|
584
|
+
if (presence !== p)
|
|
585
|
+
throw new Error('Cloud startup was cancelled by the service command.');
|
|
432
586
|
p.hb = setInterval(() => {
|
|
433
587
|
/* ═══ 18k SLICE 7 — TAKE THE MESSAGES, AWAITED AFTER THE HEARTBEAT. ═══
|
|
434
588
|
|
|
@@ -463,9 +617,13 @@ export async function startPresence() {
|
|
|
463
617
|
leave every answer for the rest of the session with no tools. Null
|
|
464
618
|
while it is down, and `takeRunMessages` then answers nothing rather
|
|
465
619
|
than starting an agent that could read none of the work. */
|
|
620
|
+
if (!mayClaim())
|
|
621
|
+
return;
|
|
622
|
+
await reconcileInterruptedWork(p.client, p.identity.id);
|
|
623
|
+
await refreshLegacyTodoHolds(p.client, p.identity.id);
|
|
466
624
|
const server = toolsServerStatus();
|
|
467
625
|
void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null);
|
|
468
|
-
})();
|
|
626
|
+
})().catch((error) => { cloudState = confirmedSessionRejection(error) ? 'sign-in-required' : 'offline'; console.warn(`Cloud work is paused: ${String(error)}`); });
|
|
469
627
|
/* !Cleanup Phase 6b (I43) — THE LIVENESS WATCH, on the heartbeat rather
|
|
470
628
|
than the orchestrator tick.
|
|
471
629
|
|
|
@@ -477,7 +635,8 @@ export async function startPresence() {
|
|
|
477
635
|
Scoped to this machine's own workers and gated on BOTH a stale
|
|
478
636
|
`updated_at` and a genuinely dead process, so an agent inside a long
|
|
479
637
|
tool call is never reaped for being quiet. */
|
|
480
|
-
|
|
638
|
+
if (mayClaim())
|
|
639
|
+
void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
|
|
481
640
|
}, HEARTBEAT_INTERVAL_MS);
|
|
482
641
|
p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
|
|
483
642
|
p.ot = setInterval(() => void pollOrchestrator(p), ORCHESTRATOR_POLL_INTERVAL_MS);
|
|
@@ -495,7 +654,7 @@ export async function startPresence() {
|
|
|
495
654
|
// best-effort: a tools-server or registration failure must never break the
|
|
496
655
|
// presence heartbeat above, so it's caught and warned like everything here.
|
|
497
656
|
try {
|
|
498
|
-
await startToolsServer({ client, userId, machineId: identity.id });
|
|
657
|
+
await startToolsServer({ client: p.client, userId: p.userId, machineId: identity.id });
|
|
499
658
|
/* ═══ AND THE TOOLS SERVER IS POINTED AT WHATEVER CLIENT IS LIVE BY NOW,
|
|
500
659
|
WHICH IS NOT NECESSARILY THE ONE IT WAS JUST STARTED WITH. ═══
|
|
501
660
|
`startToolsServer` sets `toolsClient = deps.client`, and `deps.client`
|
|
@@ -528,7 +687,6 @@ export async function startPresence() {
|
|
|
528
687
|
}
|
|
529
688
|
// Both cs open and cs start answer cards through this one lifecycle.
|
|
530
689
|
// Read the owner's client on every use, including after refresh or logout.
|
|
531
|
-
p.panel = startPanel(() => p.client);
|
|
532
690
|
return { machineName: identity.name, agents };
|
|
533
691
|
})();
|
|
534
692
|
try {
|
|
@@ -536,7 +694,7 @@ export async function startPresence() {
|
|
|
536
694
|
}
|
|
537
695
|
catch (error) {
|
|
538
696
|
await stopPresence();
|
|
539
|
-
|
|
697
|
+
cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
|
|
540
698
|
throw error;
|
|
541
699
|
}
|
|
542
700
|
finally {
|
|
@@ -561,6 +719,7 @@ export async function startPresence() {
|
|
|
561
719
|
export async function stopPresence({ unregister = false } = {}) {
|
|
562
720
|
if (stopping)
|
|
563
721
|
return stopping;
|
|
722
|
+
presenceGeneration++;
|
|
564
723
|
const p = presence;
|
|
565
724
|
if (!p)
|
|
566
725
|
return;
|
|
@@ -588,6 +747,7 @@ export async function stopPresence({ unregister = false } = {}) {
|
|
|
588
747
|
session, one that is signed in and healthy. */
|
|
589
748
|
p.authSubscription.unsubscribe();
|
|
590
749
|
await p.panel?.stop();
|
|
750
|
+
await disposeClient(p.client);
|
|
591
751
|
// On logout (unregister), scrub the ctrl-spc entry from each detected agent's
|
|
592
752
|
// config so a logged-out machine leaves no dead server that would read "failed
|
|
593
753
|
// to connect" on the next agent run. Fire-and-forget best-effort — never blocks
|
|
@@ -614,6 +774,5 @@ export async function stopPresence({ unregister = false } = {}) {
|
|
|
614
774
|
}
|
|
615
775
|
finally {
|
|
616
776
|
stopping = null;
|
|
617
|
-
releaseDaemonLock();
|
|
618
777
|
}
|
|
619
778
|
}
|
package/dist/supabase.js
CHANGED
|
@@ -7,6 +7,23 @@ export class NotLoggedIn extends Error {
|
|
|
7
7
|
this.name = 'NotLoggedIn';
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
+
/** A rejected credential is different from a request that never reached Auth. */
|
|
11
|
+
export function confirmedSessionRejection(error) {
|
|
12
|
+
if (!error || typeof error !== 'object')
|
|
13
|
+
return false;
|
|
14
|
+
const value = error;
|
|
15
|
+
return value.status === 401 || [
|
|
16
|
+
'refresh_token_not_found', 'refresh_token_already_used', 'session_not_found',
|
|
17
|
+
'session_expired', 'bad_jwt', 'user_not_found', 'invalid_credentials',
|
|
18
|
+
].includes(value.code ?? '');
|
|
19
|
+
}
|
|
20
|
+
const sessionWriters = new WeakMap();
|
|
21
|
+
/** Dispose the sole refresh writer before replacing it or abandoning startup. */
|
|
22
|
+
export async function disposeClient(client) {
|
|
23
|
+
sessionWriters.get(client)?.unsubscribe();
|
|
24
|
+
sessionWriters.delete(client);
|
|
25
|
+
await client.auth.stopAutoRefresh();
|
|
26
|
+
}
|
|
10
27
|
/**
|
|
11
28
|
* Sign in with email + password and persist the session to disk (shared with
|
|
12
29
|
* the terminal CLI via session.json). Used by the companion GUI's sign-in form,
|
|
@@ -34,12 +51,17 @@ export async function signIn(email, password) {
|
|
|
34
51
|
*/
|
|
35
52
|
const RETRY_DELAYS_MS = [250, 1000, 3000];
|
|
36
53
|
const retryingFetch = async (input, init) => {
|
|
54
|
+
const supplied = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
55
|
+
const deadline = AbortSignal.timeout(20_000);
|
|
56
|
+
const signal = supplied ? AbortSignal.any([supplied, deadline]) : deadline;
|
|
37
57
|
let lastErr;
|
|
38
58
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
39
59
|
try {
|
|
40
|
-
return await fetch(input, init);
|
|
60
|
+
return await fetch(input, { ...init, signal });
|
|
41
61
|
}
|
|
42
62
|
catch (err) {
|
|
63
|
+
if (signal.aborted)
|
|
64
|
+
throw err;
|
|
43
65
|
if (!(err instanceof TypeError))
|
|
44
66
|
throw err;
|
|
45
67
|
lastErr = err;
|
|
@@ -94,7 +116,7 @@ export async function getClient({ refreshing = true } = {}) {
|
|
|
94
116
|
auth: { persistSession: false, autoRefreshToken: true },
|
|
95
117
|
global: { fetch: retryingFetch },
|
|
96
118
|
});
|
|
97
|
-
client.auth.onAuthStateChange((_event, session) => {
|
|
119
|
+
const writer = client.auth.onAuthStateChange((_event, session) => {
|
|
98
120
|
// Update-only: a token refresh must never recreate session.json after
|
|
99
121
|
// `cs logout` deleted it, or the logged-out machine would re-authorize
|
|
100
122
|
// itself up to an hour later.
|
|
@@ -102,14 +124,26 @@ export async function getClient({ refreshing = true } = {}) {
|
|
|
102
124
|
writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
|
|
103
125
|
}
|
|
104
126
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
sessionWriters.set(client, writer.data.subscription);
|
|
128
|
+
try {
|
|
129
|
+
const { data, error } = await client.auth.setSession({
|
|
130
|
+
access_token: stored.access_token,
|
|
131
|
+
refresh_token: stored.refresh_token,
|
|
132
|
+
});
|
|
133
|
+
if (error) {
|
|
134
|
+
if (confirmedSessionRejection(error)) {
|
|
135
|
+
throw new NotLoggedIn(`Stored sign-in was rejected (${error.message}). Run \`cs login\` again.`);
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Cloud sign-in could not be checked: ${error.message}`, { cause: error });
|
|
138
|
+
}
|
|
139
|
+
if (!data.session)
|
|
140
|
+
throw new Error('Cloud sign-in did not return a session. Try again when the connection is available.');
|
|
141
|
+
return client;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
await disposeClient(client);
|
|
145
|
+
throw error;
|
|
111
146
|
}
|
|
112
|
-
return client;
|
|
113
147
|
}
|
|
114
148
|
/** Whether a stored access token's own `exp` claim has already passed. A token
|
|
115
149
|
* this process cannot parse is treated as unusable rather than trusted: the
|