@ctrl-spc/cli 1.1.4 → 1.1.6
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/dist/daemon.js +12 -0
- package/dist/flagAssembler.js +27 -0
- package/dist/launchd.js +19 -1
- package/dist/spawner.js +105 -0
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSessionClient, heartbeat, setDeviceStatus } from './devices.js';
|
|
2
2
|
import { loadMachineState } from './session.js';
|
|
3
|
+
import { makeSpawner, nodeSpawnAgent } from './spawner.js';
|
|
3
4
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
4
5
|
const SESSION_RETRY_INTERVAL_MS = 5_000;
|
|
5
6
|
export async function heartbeatOnce(session, deps) {
|
|
@@ -18,6 +19,14 @@ export async function heartbeatOnce(session, deps) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
}
|
|
22
|
+
export async function runSpawnScan(spawner, log) {
|
|
23
|
+
try {
|
|
24
|
+
await spawner.processPending();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
log(err instanceof Error ? err.message : 'Spawn scan failed');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
export async function runDaemon() {
|
|
22
31
|
const deps = {
|
|
23
32
|
createSession: createSessionClient,
|
|
@@ -41,9 +50,12 @@ export async function runDaemon() {
|
|
|
41
50
|
process.once('SIGINT', () => void shutdown());
|
|
42
51
|
process.once('SIGTERM', () => void shutdown());
|
|
43
52
|
session = await heartbeatOnce(session, deps);
|
|
53
|
+
// ponytail: per-tick rebuild; hoist the back-off map only if the heartbeat interval drops below the back-off.
|
|
44
54
|
while (true) {
|
|
45
55
|
await sleep(HEARTBEAT_INTERVAL_MS);
|
|
46
56
|
session = await heartbeatOnce(session, deps);
|
|
57
|
+
const spawner = makeSpawner({ session, spawnAgent: nodeSpawnAgent, now: () => Date.now(), log: deps.log });
|
|
58
|
+
await runSpawnScan(spawner, deps.log);
|
|
47
59
|
}
|
|
48
60
|
}
|
|
49
61
|
async function acquireSession(deps) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const READINESS_REPLY = 'Ready to get to work';
|
|
2
|
+
export const READINESS_PROMPT = `Respond with exactly this phrase and nothing else: ${READINESS_REPLY}`;
|
|
3
|
+
// Translates the launch-config columns into the agent's headless CLI argv.
|
|
4
|
+
// Null columns are omitted so the CLI falls back to its own default.
|
|
5
|
+
export function assembleCommand(req) {
|
|
6
|
+
if (req.agent_type === 'codex') {
|
|
7
|
+
const args = ['exec', READINESS_PROMPT];
|
|
8
|
+
if (req.model)
|
|
9
|
+
args.push('--model', req.model);
|
|
10
|
+
args.push('--sandbox', req.permission_mode ?? 'read-only');
|
|
11
|
+
args.push('--ask-for-approval', 'on-request');
|
|
12
|
+
if (req.effort)
|
|
13
|
+
args.push('-c', `model_reasoning_effort=${req.effort}`);
|
|
14
|
+
return { cmd: 'codex', args };
|
|
15
|
+
}
|
|
16
|
+
// Default: Claude Code.
|
|
17
|
+
const args = ['-p', READINESS_PROMPT];
|
|
18
|
+
if (req.model)
|
|
19
|
+
args.push('--model', req.model);
|
|
20
|
+
if (req.effort)
|
|
21
|
+
args.push('--effort', req.effort);
|
|
22
|
+
args.push('--permission-mode', req.permission_mode ?? 'plan');
|
|
23
|
+
if (req.allowed_tools && req.allowed_tools.length > 0) {
|
|
24
|
+
args.push('--allowedTools', req.allowed_tools.join(','));
|
|
25
|
+
}
|
|
26
|
+
return { cmd: 'claude', args };
|
|
27
|
+
}
|
package/dist/launchd.js
CHANGED
|
@@ -17,7 +17,20 @@ export function getPlistPath(home = homedir()) {
|
|
|
17
17
|
export function resolveBinPath(moduleUrl = import.meta.url) {
|
|
18
18
|
return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
|
|
19
19
|
}
|
|
20
|
-
export function
|
|
20
|
+
export function buildDaemonPath(home = homedir(), envPath = process.env.PATH ?? '') {
|
|
21
|
+
const preferred = [
|
|
22
|
+
join(home, '.local', 'bin'),
|
|
23
|
+
'/opt/homebrew/bin',
|
|
24
|
+
'/usr/local/bin',
|
|
25
|
+
'/usr/bin',
|
|
26
|
+
'/bin',
|
|
27
|
+
'/usr/sbin',
|
|
28
|
+
'/sbin',
|
|
29
|
+
'/Applications/Codex.app/Contents/Resources',
|
|
30
|
+
];
|
|
31
|
+
return [...new Set([...preferred, ...envPath.split(':').filter(Boolean)])].join(':');
|
|
32
|
+
}
|
|
33
|
+
export function buildPlist(nodePath, scriptPath, home = homedir(), daemonPath = buildDaemonPath(home)) {
|
|
21
34
|
const { daemonLogPath } = getStatePaths(home);
|
|
22
35
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
23
36
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -35,6 +48,11 @@ export function buildPlist(nodePath, scriptPath, home = homedir()) {
|
|
|
35
48
|
<true/>
|
|
36
49
|
<key>KeepAlive</key>
|
|
37
50
|
<true/>
|
|
51
|
+
<key>EnvironmentVariables</key>
|
|
52
|
+
<dict>
|
|
53
|
+
<key>PATH</key>
|
|
54
|
+
<string>${escapeXml(daemonPath)}</string>
|
|
55
|
+
</dict>
|
|
38
56
|
<key>StandardErrorPath</key>
|
|
39
57
|
<string>${escapeXml(daemonLogPath)}</string>
|
|
40
58
|
<key>StandardOutPath</key>
|
package/dist/spawner.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { assembleCommand, READINESS_REPLY } from './flagAssembler.js';
|
|
4
|
+
// agent_type -> spawn mode is derived, never stored (spec decision 8).
|
|
5
|
+
function isRemote(agentType) {
|
|
6
|
+
return agentType === 'claude-code' || agentType === 'codex';
|
|
7
|
+
}
|
|
8
|
+
export function makeSpawner(deps) {
|
|
9
|
+
const timeoutMs = deps.readinessTimeoutMs ?? 10_000;
|
|
10
|
+
const backoffMs = deps.retryBackoffMs ?? 30_000;
|
|
11
|
+
const lastAttempt = new Map(); // requestId -> timestamp; in-memory back-off
|
|
12
|
+
async function awaitReadiness(handle) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let buf = '';
|
|
15
|
+
let done = false;
|
|
16
|
+
const finish = (ok) => { if (!done) {
|
|
17
|
+
done = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve(ok);
|
|
20
|
+
} };
|
|
21
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
22
|
+
handle.stdout.on('data', (chunk) => {
|
|
23
|
+
buf += chunk.toString();
|
|
24
|
+
if (buf.includes(READINESS_REPLY))
|
|
25
|
+
finish(true);
|
|
26
|
+
});
|
|
27
|
+
handle.stdout.on('end', () => finish(buf.includes(READINESS_REPLY)));
|
|
28
|
+
handle.stdout.on('error', () => finish(false));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function resolveLocalPath(projectId) {
|
|
32
|
+
const { data } = await deps.session.client
|
|
33
|
+
.from('project_links')
|
|
34
|
+
.select('value')
|
|
35
|
+
.eq('project_id', projectId)
|
|
36
|
+
.eq('kind', 'path')
|
|
37
|
+
.limit(1);
|
|
38
|
+
return data?.[0]?.value ?? null;
|
|
39
|
+
}
|
|
40
|
+
async function fulfil(row, pid) {
|
|
41
|
+
await deps.session.client.from('sessions').insert({
|
|
42
|
+
project_id: row.project_id,
|
|
43
|
+
device_id: deps.session.machineId,
|
|
44
|
+
connection_state: 'connected',
|
|
45
|
+
awaiting_input: false,
|
|
46
|
+
agent_type: row.agent_type,
|
|
47
|
+
model: row.model,
|
|
48
|
+
effort: row.effort,
|
|
49
|
+
permission_mode: row.permission_mode,
|
|
50
|
+
allowed_tools: row.allowed_tools,
|
|
51
|
+
pid,
|
|
52
|
+
});
|
|
53
|
+
await deps.session.client.from('spawn_requests').update({ status: 'fulfilled' }).eq('id', row.id);
|
|
54
|
+
}
|
|
55
|
+
async function processOne(row) {
|
|
56
|
+
if (!isRemote(row.agent_type))
|
|
57
|
+
return; // manual types are never auto-fulfilled
|
|
58
|
+
const last = lastAttempt.get(row.id);
|
|
59
|
+
if (last !== undefined && deps.now() - last < backoffMs)
|
|
60
|
+
return;
|
|
61
|
+
lastAttempt.set(row.id, deps.now());
|
|
62
|
+
const cwd = await resolveLocalPath(row.project_id);
|
|
63
|
+
if (!cwd) {
|
|
64
|
+
deps.log(`No local path for project ${row.project_id}; skipping`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const { cmd, args } = assembleCommand(row);
|
|
68
|
+
const handle = deps.spawnAgent(cmd, args, cwd);
|
|
69
|
+
const ready = await awaitReadiness(handle);
|
|
70
|
+
if (!ready) {
|
|
71
|
+
handle.kill();
|
|
72
|
+
deps.log(`Readiness gate failed for ${row.id}; will retry`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await fulfil(row, handle.pid);
|
|
76
|
+
deps.log(`Spawned agent for project ${row.project_id} (request ${row.id})`);
|
|
77
|
+
}
|
|
78
|
+
async function processPending() {
|
|
79
|
+
const { data, error } = await deps.session.client
|
|
80
|
+
.from('spawn_requests')
|
|
81
|
+
.select('id, project_id, agent_type, model, effort, permission_mode, allowed_tools')
|
|
82
|
+
.eq('status', 'pending');
|
|
83
|
+
if (error) {
|
|
84
|
+
deps.log(`spawn_requests scan failed: ${error.message}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const row of (data ?? [])) {
|
|
88
|
+
try {
|
|
89
|
+
await processOne(row);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
deps.log(err instanceof Error ? err.message : `spawn failed for ${row.id}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { processPending };
|
|
97
|
+
}
|
|
98
|
+
// Real spawn handle backed by node:child_process, used by the daemon (not in unit tests).
|
|
99
|
+
export function nodeSpawnAgent(cmd, args, cwd) {
|
|
100
|
+
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
101
|
+
const stdout = new PassThrough();
|
|
102
|
+
child.stdout?.pipe(stdout);
|
|
103
|
+
child.on('error', (err) => stdout.destroy(err));
|
|
104
|
+
return { pid: child.pid ?? -1, stdout, kill: () => child.kill() };
|
|
105
|
+
}
|