@ctrl-spc/cli 1.1.12 → 1.1.14

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
@@ -3,6 +3,7 @@ import { loadMachineState } from './session.js';
3
3
  import { makeSpawner, nodeSpawnAgent } from './spawner.js';
4
4
  import { makeControlRequestProcessor } from './controlRequests.js';
5
5
  export const HEARTBEAT_INTERVAL_MS = 30_000;
6
+ export const WORK_SCAN_INTERVAL_MS = 2_000;
6
7
  const SESSION_RETRY_INTERVAL_MS = 5_000;
7
8
  export async function heartbeatOnce(session, deps) {
8
9
  try {
@@ -36,6 +37,58 @@ export async function runControlScan(processor, log) {
36
37
  log(err instanceof Error ? err.message : 'Control request scan failed');
37
38
  }
38
39
  }
40
+ export async function markRecoveredSessionsDisconnected(session, now, log) {
41
+ const { data, error } = await session.client
42
+ .from('sessions')
43
+ .select('id, project_id')
44
+ .eq('device_id', session.machineId)
45
+ .eq('connection_state', 'connected')
46
+ .is('ended_at', null);
47
+ if (error) {
48
+ log(`Session recovery scan failed: ${error.message}`);
49
+ return;
50
+ }
51
+ const rows = (data ?? []);
52
+ if (rows.length === 0)
53
+ return;
54
+ const endedAt = new Date(now()).toISOString();
55
+ const ids = rows.map((row) => row.id);
56
+ const { error: updateError } = await session.client
57
+ .from('sessions')
58
+ .update({
59
+ connection_state: 'disconnected',
60
+ ended_at: endedAt,
61
+ exit_code: null,
62
+ stop_reason: 'daemon_restarted',
63
+ })
64
+ .in('id', ids);
65
+ if (updateError) {
66
+ log(`Session recovery update failed: ${updateError.message}`);
67
+ return;
68
+ }
69
+ const messages = rows.map((row) => ({
70
+ project_id: row.project_id,
71
+ session_id: row.id,
72
+ role: 'system',
73
+ kind: 'system',
74
+ content: 'This session was no longer owned by the current machine helper, so Control Space marked it disconnected. Start a fresh agent to continue.',
75
+ }));
76
+ const { error: messageError } = await session.client.from('agent_messages').insert(messages);
77
+ if (messageError)
78
+ log(`Session recovery message insert failed: ${messageError.message}`);
79
+ }
80
+ export async function touchOwnedSessions(session, ownedSessions, now, log) {
81
+ const ids = [...ownedSessions.keys()];
82
+ if (ids.length === 0)
83
+ return;
84
+ const { error } = await session.client
85
+ .from('sessions')
86
+ .update({ last_seen_at: new Date(now()).toISOString() })
87
+ .in('id', ids)
88
+ .is('ended_at', null);
89
+ if (error)
90
+ log(`Session heartbeat failed: ${error.message}`);
91
+ }
39
92
  export async function runDaemon() {
40
93
  const deps = {
41
94
  createSession: createSessionClient,
@@ -60,13 +113,20 @@ export async function runDaemon() {
60
113
  process.once('SIGINT', () => void shutdown());
61
114
  process.once('SIGTERM', () => void shutdown());
62
115
  session = await heartbeatOnce(session, deps);
116
+ await markRecoveredSessionsDisconnected(session, () => Date.now(), deps.log);
117
+ let lastHeartbeatAt = Date.now();
63
118
  let spawner = makeSpawner({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
64
119
  let controls = makeControlRequestProcessor({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
65
120
  await runSpawnScan(spawner, deps.log);
66
121
  await runControlScan(controls, deps.log);
67
122
  while (true) {
68
- await sleep(HEARTBEAT_INTERVAL_MS);
69
- session = await heartbeatOnce(session, deps);
123
+ await sleep(WORK_SCAN_INTERVAL_MS);
124
+ const now = Date.now();
125
+ if (now - lastHeartbeatAt >= HEARTBEAT_INTERVAL_MS) {
126
+ session = await heartbeatOnce(session, deps);
127
+ await touchOwnedSessions(session, ownedSessions, () => now, deps.log);
128
+ lastHeartbeatAt = now;
129
+ }
70
130
  spawner = makeSpawner({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
71
131
  controls = makeControlRequestProcessor({ session, spawnAgent: nodeSpawnAgent, ownedSessions, now: () => Date.now(), log: deps.log });
72
132
  await runSpawnScan(spawner, deps.log);
package/dist/spawner.js CHANGED
@@ -57,6 +57,7 @@ export function makeSpawner(deps) {
57
57
  permission_mode: row.permission_mode,
58
58
  allowed_tools: row.allowed_tools,
59
59
  pid,
60
+ last_seen_at: new Date(deps.now()).toISOString(),
60
61
  }).select('id').single();
61
62
  if (error)
62
63
  throw new Error(`Session insert failed: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [