@ctrl-spc/cli 1.1.10 → 1.1.12
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/controlRequests.js +154 -0
- package/dist/daemon.js +17 -2
- package/dist/flagAssembler.js +6 -4
- package/dist/spawner.js +22 -4
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { assembleCommand, PING_PROMPT } from './flagAssembler.js';
|
|
2
|
+
const STOP_NOT_OWNED_MESSAGE = 'This machine can only stop agent processes it started and still owns.';
|
|
3
|
+
export function makeControlRequestProcessor(deps) {
|
|
4
|
+
const timeoutMs = deps.pingTimeoutMs ?? 60_000;
|
|
5
|
+
async function resolveLocalPath(projectId) {
|
|
6
|
+
const { data } = await deps.session.client
|
|
7
|
+
.from('project_links')
|
|
8
|
+
.select('value')
|
|
9
|
+
.eq('project_id', projectId)
|
|
10
|
+
.eq('kind', 'path')
|
|
11
|
+
.limit(1);
|
|
12
|
+
return data?.[0]?.value ?? null;
|
|
13
|
+
}
|
|
14
|
+
async function mark(row, patch) {
|
|
15
|
+
await deps.session.client.from('agent_control_requests').update(patch).eq('id', row.id);
|
|
16
|
+
}
|
|
17
|
+
function completedPatch(status, errorMessage) {
|
|
18
|
+
return {
|
|
19
|
+
status,
|
|
20
|
+
...(errorMessage ? { error_message: errorMessage } : {}),
|
|
21
|
+
completed_at: new Date(deps.now()).toISOString(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function collectStdout(handle) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let stdout = '';
|
|
27
|
+
let settled = false;
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
handle.kill();
|
|
30
|
+
finish(() => reject(new Error('Provider timed out before replying.')));
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
const finish = (settle) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
settle();
|
|
38
|
+
};
|
|
39
|
+
handle.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
40
|
+
handle.stdout.on('end', () => finish(() => resolve(stdout)));
|
|
41
|
+
handle.stdout.on('error', (err) => finish(() => reject(err)));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function findSession(row) {
|
|
45
|
+
let query = deps.session.client
|
|
46
|
+
.from('sessions')
|
|
47
|
+
.select('id, project_id, agent_type, model, effort, permission_mode, allowed_tools')
|
|
48
|
+
.eq('project_id', row.project_id)
|
|
49
|
+
.is('ended_at', null);
|
|
50
|
+
if (row.session_id)
|
|
51
|
+
query = query.eq('id', row.session_id);
|
|
52
|
+
const { data, error } = await query.order('created_at', { ascending: false }).limit(1);
|
|
53
|
+
if (error)
|
|
54
|
+
throw new Error(`Session lookup failed: ${error.message}`);
|
|
55
|
+
return ((data ?? [])[0] ?? null);
|
|
56
|
+
}
|
|
57
|
+
async function insertMessage(row, sessionId, role, kind, content) {
|
|
58
|
+
const { data, error } = await deps.session.client.from('agent_messages').insert({
|
|
59
|
+
project_id: row.project_id,
|
|
60
|
+
session_id: sessionId,
|
|
61
|
+
role,
|
|
62
|
+
kind,
|
|
63
|
+
content,
|
|
64
|
+
}).select('id').single();
|
|
65
|
+
if (error)
|
|
66
|
+
throw new Error(`Message insert failed: ${error.message}`);
|
|
67
|
+
return data?.id ?? null;
|
|
68
|
+
}
|
|
69
|
+
async function processPing(row) {
|
|
70
|
+
const session = await findSession(row);
|
|
71
|
+
if (!session) {
|
|
72
|
+
await mark(row, completedPatch('failed', 'No active agent session was found.'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const cwd = await resolveLocalPath(row.project_id);
|
|
76
|
+
if (!cwd) {
|
|
77
|
+
await mark(row, completedPatch('failed', 'This project is not linked to a local folder on this machine.'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const { cmd, args } = assembleCommand(session, PING_PROMPT);
|
|
81
|
+
const handle = deps.spawnAgent(cmd, args, cwd);
|
|
82
|
+
const stdout = await collectStdout(handle);
|
|
83
|
+
const responseId = await insertMessage(row, session.id, 'agent', 'ping', cleanAgentText(stdout));
|
|
84
|
+
await mark(row, { ...completedPatch('succeeded'), response_message_id: responseId });
|
|
85
|
+
}
|
|
86
|
+
async function processStop(row) {
|
|
87
|
+
const owned = row.session_id
|
|
88
|
+
? deps.ownedSessions.get(row.session_id)
|
|
89
|
+
: [...deps.ownedSessions.values()].find((entry) => entry.projectId === row.project_id);
|
|
90
|
+
if (!owned) {
|
|
91
|
+
const session = await findSession(row);
|
|
92
|
+
if (session) {
|
|
93
|
+
const endedAt = new Date(deps.now()).toISOString();
|
|
94
|
+
await deps.session.client.from('sessions').update({
|
|
95
|
+
connection_state: 'disconnected',
|
|
96
|
+
ended_at: endedAt,
|
|
97
|
+
exit_code: null,
|
|
98
|
+
stop_reason: 'daemon_restarted',
|
|
99
|
+
}).eq('id', session.id);
|
|
100
|
+
await insertMessage(row, session.id, 'system', 'system', 'This agent was no longer owned by the current machine helper, so Control Space marked it disconnected. Start a fresh agent to continue.');
|
|
101
|
+
await mark(row, { status: 'succeeded', completed_at: endedAt });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await mark(row, completedPatch('failed', STOP_NOT_OWNED_MESSAGE));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
owned.handle.kill();
|
|
108
|
+
deps.ownedSessions.delete(owned.sessionId);
|
|
109
|
+
const endedAt = new Date(deps.now()).toISOString();
|
|
110
|
+
await deps.session.client.from('sessions').update({
|
|
111
|
+
connection_state: 'disconnected',
|
|
112
|
+
ended_at: endedAt,
|
|
113
|
+
exit_code: null,
|
|
114
|
+
stop_reason: 'stopped_by_request',
|
|
115
|
+
}).eq('id', owned.sessionId);
|
|
116
|
+
await insertMessage(row, owned.sessionId, 'system', 'system', 'Agent stopped.');
|
|
117
|
+
await mark(row, { status: 'succeeded', completed_at: endedAt });
|
|
118
|
+
}
|
|
119
|
+
async function processOne(row) {
|
|
120
|
+
await mark(row, { status: 'processing' });
|
|
121
|
+
if (row.action === 'ping') {
|
|
122
|
+
await processPing(row);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (row.action === 'stop') {
|
|
126
|
+
await processStop(row);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await mark(row, completedPatch('failed', 'This control request is not supported by this version of the daemon.'));
|
|
130
|
+
}
|
|
131
|
+
async function processPending() {
|
|
132
|
+
const { data, error } = await deps.session.client
|
|
133
|
+
.from('agent_control_requests')
|
|
134
|
+
.select('id, project_id, session_id, action')
|
|
135
|
+
.eq('status', 'pending');
|
|
136
|
+
if (error) {
|
|
137
|
+
deps.log(`agent_control_requests scan failed: ${error.message}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const row of (data ?? [])) {
|
|
141
|
+
try {
|
|
142
|
+
await processOne(row);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
deps.log(err instanceof Error ? err.message : `control request failed for ${row.id}`);
|
|
146
|
+
await mark(row, completedPatch('failed', err instanceof Error ? err.message : 'Control request failed.'));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { processPending };
|
|
151
|
+
}
|
|
152
|
+
function cleanAgentText(value) {
|
|
153
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, 4000);
|
|
154
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSessionClient, heartbeat, setDeviceStatus } from './devices.js';
|
|
2
2
|
import { loadMachineState } from './session.js';
|
|
3
3
|
import { makeSpawner, nodeSpawnAgent } from './spawner.js';
|
|
4
|
+
import { makeControlRequestProcessor } from './controlRequests.js';
|
|
4
5
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
5
6
|
const SESSION_RETRY_INTERVAL_MS = 5_000;
|
|
6
7
|
export async function heartbeatOnce(session, deps) {
|
|
@@ -27,6 +28,14 @@ export async function runSpawnScan(spawner, log) {
|
|
|
27
28
|
log(err instanceof Error ? err.message : 'Spawn scan failed');
|
|
28
29
|
}
|
|
29
30
|
}
|
|
31
|
+
export async function runControlScan(processor, log) {
|
|
32
|
+
try {
|
|
33
|
+
await processor.processPending();
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
log(err instanceof Error ? err.message : 'Control request scan failed');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
30
39
|
export async function runDaemon() {
|
|
31
40
|
const deps = {
|
|
32
41
|
createSession: createSessionClient,
|
|
@@ -35,6 +44,7 @@ export async function runDaemon() {
|
|
|
35
44
|
};
|
|
36
45
|
let session = await acquireSession(deps);
|
|
37
46
|
let shuttingDown = false;
|
|
47
|
+
const ownedSessions = new Map();
|
|
38
48
|
const shutdown = async () => {
|
|
39
49
|
if (shuttingDown)
|
|
40
50
|
return;
|
|
@@ -50,12 +60,17 @@ export async function runDaemon() {
|
|
|
50
60
|
process.once('SIGINT', () => void shutdown());
|
|
51
61
|
process.once('SIGTERM', () => void shutdown());
|
|
52
62
|
session = await heartbeatOnce(session, deps);
|
|
53
|
-
|
|
63
|
+
let spawner = makeSpawner({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
|
|
64
|
+
let controls = makeControlRequestProcessor({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
|
|
65
|
+
await runSpawnScan(spawner, deps.log);
|
|
66
|
+
await runControlScan(controls, deps.log);
|
|
54
67
|
while (true) {
|
|
55
68
|
await sleep(HEARTBEAT_INTERVAL_MS);
|
|
56
69
|
session = await heartbeatOnce(session, deps);
|
|
57
|
-
|
|
70
|
+
spawner = makeSpawner({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
|
|
71
|
+
controls = makeControlRequestProcessor({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
|
|
58
72
|
await runSpawnScan(spawner, deps.log);
|
|
73
|
+
await runControlScan(controls, deps.log);
|
|
59
74
|
}
|
|
60
75
|
}
|
|
61
76
|
async function acquireSession(deps) {
|
package/dist/flagAssembler.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
export const READINESS_REPLY = 'Ready to get to work';
|
|
2
2
|
export const READINESS_PROMPT = `Respond with exactly this phrase and nothing else: ${READINESS_REPLY}`;
|
|
3
|
+
export const PING_PROMPT = 'Reply with one short sentence confirming you are still responsive to Control Space.';
|
|
3
4
|
// Translates the launch-config columns into the agent's headless CLI argv.
|
|
4
5
|
// Null columns are omitted so the CLI falls back to its own default.
|
|
5
|
-
export function assembleCommand(req) {
|
|
6
|
+
export function assembleCommand(req, prompt = READINESS_PROMPT) {
|
|
6
7
|
if (req.agent_type === 'codex') {
|
|
7
|
-
const args = ['
|
|
8
|
+
const args = ['--ask-for-approval', 'on-request', 'exec'];
|
|
8
9
|
if (req.model)
|
|
9
10
|
args.push('--model', req.model);
|
|
10
11
|
args.push('--sandbox', req.permission_mode ?? 'read-only');
|
|
11
|
-
args.push('--ask-for-approval', 'on-request');
|
|
12
12
|
if (req.effort)
|
|
13
13
|
args.push('-c', `model_reasoning_effort=${req.effort}`);
|
|
14
|
+
args.push('--color', 'never');
|
|
15
|
+
args.push(prompt);
|
|
14
16
|
return { cmd: 'codex', args };
|
|
15
17
|
}
|
|
16
18
|
// Default: Claude Code.
|
|
17
|
-
const args = ['-p',
|
|
19
|
+
const args = ['-p', prompt];
|
|
18
20
|
if (req.model)
|
|
19
21
|
args.push('--model', req.model);
|
|
20
22
|
if (req.effort)
|
package/dist/spawner.js
CHANGED
|
@@ -42,8 +42,11 @@ export function makeSpawner(deps) {
|
|
|
42
42
|
.limit(1);
|
|
43
43
|
return data?.[0]?.value ?? null;
|
|
44
44
|
}
|
|
45
|
-
async function
|
|
46
|
-
await deps.session.client.from('
|
|
45
|
+
async function markRequest(row, status) {
|
|
46
|
+
await deps.session.client.from('spawn_requests').update({ status }).eq('id', row.id);
|
|
47
|
+
}
|
|
48
|
+
async function fulfil(row, pid, stdout, handle) {
|
|
49
|
+
const { data, error } = await deps.session.client.from('sessions').insert({
|
|
47
50
|
project_id: row.project_id,
|
|
48
51
|
device_id: deps.session.machineId,
|
|
49
52
|
connection_state: 'connected',
|
|
@@ -54,8 +57,21 @@ export function makeSpawner(deps) {
|
|
|
54
57
|
permission_mode: row.permission_mode,
|
|
55
58
|
allowed_tools: row.allowed_tools,
|
|
56
59
|
pid,
|
|
60
|
+
}).select('id').single();
|
|
61
|
+
if (error)
|
|
62
|
+
throw new Error(`Session insert failed: ${error.message}`);
|
|
63
|
+
const sessionId = data.id;
|
|
64
|
+
const { error: messageError } = await deps.session.client.from('agent_messages').insert({
|
|
65
|
+
project_id: row.project_id,
|
|
66
|
+
session_id: sessionId,
|
|
67
|
+
role: 'agent',
|
|
68
|
+
kind: 'ready',
|
|
69
|
+
content: stdout,
|
|
57
70
|
});
|
|
58
|
-
|
|
71
|
+
if (messageError)
|
|
72
|
+
throw new Error(`Ready message insert failed: ${messageError.message}`);
|
|
73
|
+
deps.ownedSessions?.set(sessionId, { projectId: row.project_id, sessionId, handle });
|
|
74
|
+
await markRequest(row, 'fulfilled');
|
|
59
75
|
}
|
|
60
76
|
async function processOne(row) {
|
|
61
77
|
if (!isRemote(row.agent_type))
|
|
@@ -69,15 +85,17 @@ export function makeSpawner(deps) {
|
|
|
69
85
|
deps.log(`No local path for project ${row.project_id}; skipping`);
|
|
70
86
|
return;
|
|
71
87
|
}
|
|
88
|
+
await markRequest(row, 'processing');
|
|
72
89
|
const { cmd, args } = assembleCommand(row);
|
|
73
90
|
const handle = deps.spawnAgent(cmd, args, cwd);
|
|
74
91
|
const result = await awaitReadiness(handle);
|
|
75
92
|
if (!result.ready) {
|
|
76
93
|
handle.kill();
|
|
77
94
|
deps.log(`Readiness gate failed for ${row.id}; will retry. stdout=${quoteSnippet(result.stdout)} stderr=${quoteSnippet(result.stderr)}`);
|
|
95
|
+
await markRequest(row, 'failed');
|
|
78
96
|
return;
|
|
79
97
|
}
|
|
80
|
-
await fulfil(row, handle.pid);
|
|
98
|
+
await fulfil(row, handle.pid, result.stdout, handle);
|
|
81
99
|
deps.log(`Spawned agent for project ${row.project_id} (request ${row.id})`);
|
|
82
100
|
}
|
|
83
101
|
async function processPending() {
|