@ctrl-spc/cli 1.1.3 → 1.1.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.
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
@@ -77,6 +77,9 @@ export function bootoutArgs(uid, plistPath) {
77
77
  export function kickstartArgs(uid) {
78
78
  return ['kickstart', '-k', `gui/${uid}/${PLIST_LABEL}`];
79
79
  }
80
+ export function bootstrapArgs(uid, plistPath) {
81
+ return ['bootstrap', `gui/${uid}`, plistPath];
82
+ }
80
83
  export async function stopDaemon(home = homedir(), exec = defaultExec) {
81
84
  const uid = process.getuid?.();
82
85
  if (uid === undefined)
@@ -92,11 +95,18 @@ export async function restartDaemon(home = homedir(), exec = defaultExec) {
92
95
  const uid = process.getuid?.();
93
96
  if (uid === undefined)
94
97
  throw new Error(RESTART_FAILED_MESSAGE);
98
+ const plistPath = getPlistPath(home);
95
99
  try {
96
100
  exec('launchctl', kickstartArgs(uid));
97
101
  }
98
102
  catch {
99
- throw new Error(RESTART_FAILED_MESSAGE);
103
+ try {
104
+ exec('launchctl', bootstrapArgs(uid, plistPath));
105
+ exec('launchctl', kickstartArgs(uid));
106
+ }
107
+ catch {
108
+ throw new Error(RESTART_FAILED_MESSAGE);
109
+ }
100
110
  }
101
111
  }
102
112
  function escapeXml(value) {
@@ -0,0 +1,101 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { assembleCommand, READINESS_REPLY } from './flagAssembler.js';
3
+ // agent_type -> spawn mode is derived, never stored (spec decision 8).
4
+ function isRemote(agentType) {
5
+ return agentType === 'claude-code' || agentType === 'codex';
6
+ }
7
+ export function makeSpawner(deps) {
8
+ const timeoutMs = deps.readinessTimeoutMs ?? 10_000;
9
+ const backoffMs = deps.retryBackoffMs ?? 30_000;
10
+ const lastAttempt = new Map(); // requestId -> timestamp; in-memory back-off
11
+ async function awaitReadiness(handle) {
12
+ return new Promise((resolve) => {
13
+ let buf = '';
14
+ let done = false;
15
+ const finish = (ok) => { if (!done) {
16
+ done = true;
17
+ clearTimeout(timer);
18
+ resolve(ok);
19
+ } };
20
+ const timer = setTimeout(() => finish(false), timeoutMs);
21
+ handle.stdout.on('data', (chunk) => {
22
+ buf += chunk.toString();
23
+ if (buf.includes(READINESS_REPLY))
24
+ finish(true);
25
+ });
26
+ handle.stdout.on('end', () => finish(buf.includes(READINESS_REPLY)));
27
+ handle.stdout.on('error', () => finish(false));
28
+ });
29
+ }
30
+ async function resolveLocalPath(projectId) {
31
+ const { data } = await deps.session.client
32
+ .from('project_links')
33
+ .select('value')
34
+ .eq('project_id', projectId)
35
+ .eq('kind', 'path')
36
+ .limit(1);
37
+ return data?.[0]?.value ?? null;
38
+ }
39
+ async function fulfil(row, pid) {
40
+ await deps.session.client.from('sessions').insert({
41
+ project_id: row.project_id,
42
+ device_id: deps.session.machineId,
43
+ connection_state: 'connected',
44
+ awaiting_input: false,
45
+ agent_type: row.agent_type,
46
+ model: row.model,
47
+ effort: row.effort,
48
+ permission_mode: row.permission_mode,
49
+ allowed_tools: row.allowed_tools,
50
+ pid,
51
+ });
52
+ await deps.session.client.from('spawn_requests').update({ status: 'fulfilled' }).eq('id', row.id);
53
+ }
54
+ async function processOne(row) {
55
+ if (!isRemote(row.agent_type))
56
+ return; // manual types are never auto-fulfilled
57
+ const last = lastAttempt.get(row.id);
58
+ if (last !== undefined && deps.now() - last < backoffMs)
59
+ return;
60
+ lastAttempt.set(row.id, deps.now());
61
+ const cwd = await resolveLocalPath(row.project_id);
62
+ if (!cwd) {
63
+ deps.log(`No local path for project ${row.project_id}; skipping`);
64
+ return;
65
+ }
66
+ const { cmd, args } = assembleCommand(row);
67
+ const handle = deps.spawnAgent(cmd, args, cwd);
68
+ const ready = await awaitReadiness(handle);
69
+ if (!ready) {
70
+ handle.kill();
71
+ deps.log(`Readiness gate failed for ${row.id}; will retry`);
72
+ return;
73
+ }
74
+ await fulfil(row, handle.pid);
75
+ deps.log(`Spawned agent for project ${row.project_id} (request ${row.id})`);
76
+ }
77
+ async function processPending() {
78
+ const { data, error } = await deps.session.client
79
+ .from('spawn_requests')
80
+ .select('id, project_id, agent_type, model, effort, permission_mode, allowed_tools')
81
+ .eq('status', 'pending');
82
+ if (error) {
83
+ deps.log(`spawn_requests scan failed: ${error.message}`);
84
+ return;
85
+ }
86
+ for (const row of (data ?? [])) {
87
+ try {
88
+ await processOne(row);
89
+ }
90
+ catch (err) {
91
+ deps.log(err instanceof Error ? err.message : `spawn failed for ${row.id}`);
92
+ }
93
+ }
94
+ }
95
+ return { processPending };
96
+ }
97
+ // Real spawn handle backed by node:child_process, used by the daemon (not in unit tests).
98
+ export function nodeSpawnAgent(cmd, args, cwd) {
99
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
100
+ return { pid: child.pid ?? -1, stdout: child.stdout, kill: () => child.kill() };
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [