@commonlyai/cli 0.1.41 → 0.1.42
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/package.json +1 -1
- package/src/commands/agent.js +1 -0
- package/src/commands/daemon.js +120 -6
- package/src/lib/daemon-logs.js +25 -0
- package/src/lib/daemon-service.js +39 -1
- package/src/lib/daemon-state.js +81 -0
- package/src/lib/daemon-supervisor.js +45 -1
- package/src/lib/session-store.js +12 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -2474,6 +2474,7 @@ Docs:
|
|
|
2474
2474
|
// back on 2026-08-18, and that only worked because the processes were
|
|
2475
2475
|
// still alive — after the next restart that route is gone too.
|
|
2476
2476
|
console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
|
|
2477
|
+
console.log(`${stamp()} [${name}] foreground mode; to background and keep it across logins, run: commonly daemon install`);
|
|
2477
2478
|
|
|
2478
2479
|
const { stop } = performRun({
|
|
2479
2480
|
instanceUrl: record.instanceUrl,
|
package/src/commands/daemon.js
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
import { hostname, homedir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import {
|
|
12
|
-
existsSync, mkdirSync, openSync, rmSync, writeFileSync,
|
|
12
|
+
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch, writeFileSync,
|
|
13
13
|
} from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
15
14
|
import { createClient } from '../lib/api.js';
|
|
16
15
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
17
16
|
import { loadDaemonRecord, removeDaemonRecord, saveDaemonRecord } from '../lib/daemon-store.js';
|
|
@@ -21,11 +20,19 @@ import {
|
|
|
21
20
|
DEFAULT_POLL_MS,
|
|
22
21
|
} from '../lib/daemon-supervisor.js';
|
|
23
22
|
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
23
|
+
import { getLastTurn } from '../lib/session-store.js';
|
|
24
24
|
import { getAdapter } from '../lib/adapters/index.js';
|
|
25
25
|
import {
|
|
26
26
|
installDaemonService,
|
|
27
27
|
uninstallDaemonService,
|
|
28
|
+
startDaemonService,
|
|
29
|
+
stopDaemonService,
|
|
30
|
+
restartDaemonService,
|
|
31
|
+
daemonLogPath,
|
|
32
|
+
servicePaths,
|
|
28
33
|
} from '../lib/daemon-service.js';
|
|
34
|
+
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
35
|
+
import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
|
|
29
36
|
|
|
30
37
|
const requireDaemonRecord = () => {
|
|
31
38
|
const record = loadDaemonRecord();
|
|
@@ -132,7 +139,9 @@ The daemon token is scoped to this machine and stored in a 0600 file under
|
|
|
132
139
|
Examples:
|
|
133
140
|
$ commonly daemon register --name "Sam's MacBook"
|
|
134
141
|
$ commonly daemon heartbeat
|
|
135
|
-
$ commonly daemon status
|
|
142
|
+
$ commonly daemon status --verbose
|
|
143
|
+
$ commonly daemon logs --seat my-agent -f
|
|
144
|
+
$ commonly daemon start|stop|restart
|
|
136
145
|
$ commonly daemon unregister
|
|
137
146
|
`);
|
|
138
147
|
|
|
@@ -212,6 +221,8 @@ Examples:
|
|
|
212
221
|
const serviceDeps = () => ({
|
|
213
222
|
writeFile: (file, content) => writeFileSync(file, content, 'utf8'),
|
|
214
223
|
mkdirp: (dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); },
|
|
224
|
+
chmod: (path, mode) => chmodSync(path, mode),
|
|
225
|
+
ensureFile: (path) => { const fd = openSync(path, 'a', 0o600); closeSync(fd); },
|
|
215
226
|
existsFile: (file) => existsSync(file),
|
|
216
227
|
removeFile: (file) => rmSync(file),
|
|
217
228
|
execCmd: (argv) => new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -224,6 +235,16 @@ Examples:
|
|
|
224
235
|
log: (line) => console.log(line),
|
|
225
236
|
});
|
|
226
237
|
|
|
238
|
+
const runServiceAction = async (action, actionFn) => {
|
|
239
|
+
const deps = serviceDeps();
|
|
240
|
+
const target = servicePaths(process.platform, homedir());
|
|
241
|
+
if (!deps.existsFile(target.file)) {
|
|
242
|
+
throw new Error(`No installed daemon service found. Run: commonly daemon install`);
|
|
243
|
+
}
|
|
244
|
+
await actionFn({ platform: process.platform, home: homedir(), execCmd: deps.execCmd });
|
|
245
|
+
console.log(`Daemon ${action} requested (${target.kind}).`);
|
|
246
|
+
};
|
|
247
|
+
|
|
227
248
|
daemon
|
|
228
249
|
.command('install')
|
|
229
250
|
.description('Register the daemon as a login service (launchd/systemd) so it survives reboots')
|
|
@@ -251,18 +272,56 @@ Examples:
|
|
|
251
272
|
}
|
|
252
273
|
});
|
|
253
274
|
|
|
275
|
+
daemon
|
|
276
|
+
.command('start')
|
|
277
|
+
.description('Start the installed daemon service and return to the terminal')
|
|
278
|
+
.action(async () => {
|
|
279
|
+
try {
|
|
280
|
+
await runServiceAction('start', startDaemonService);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error(`Daemon start failed: ${error.message}`);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
daemon
|
|
288
|
+
.command('stop')
|
|
289
|
+
.description('Stop the installed daemon service')
|
|
290
|
+
.action(async () => {
|
|
291
|
+
try {
|
|
292
|
+
await runServiceAction('stop', stopDaemonService);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error(`Daemon stop failed: ${error.message}`);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
daemon
|
|
300
|
+
.command('restart')
|
|
301
|
+
.description('Restart the installed daemon service and return to the terminal')
|
|
302
|
+
.action(async () => {
|
|
303
|
+
try {
|
|
304
|
+
await runServiceAction('restart', restartDaemonService);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error(`Daemon restart failed: ${error.message}`);
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
254
311
|
// ── run (ADR-026 Phase 2, slice 2) ────────────────────────────────────────
|
|
255
312
|
daemon
|
|
256
313
|
.command('run')
|
|
257
314
|
.description('Run the resident supervisor: adopt requested agents, keep bound agents running, report per-agent state')
|
|
258
315
|
.option('--poll <ms>', 'Work-list poll interval in ms', String(DEFAULT_POLL_MS))
|
|
259
316
|
.option('--heartbeat <ms>', 'Heartbeat interval in ms', String(DEFAULT_HEARTBEAT_MS))
|
|
317
|
+
.option('--foreground', 'Keep the supervisor attached to this terminal (default for direct invocation)')
|
|
260
318
|
.action(async (opts) => {
|
|
261
319
|
try {
|
|
262
320
|
const record = requireDaemonRecord();
|
|
263
321
|
const client = createClient({ instance: record.instanceUrl, token: record.daemonToken });
|
|
264
|
-
const logsDir =
|
|
322
|
+
const logsDir = daemonLogsDir();
|
|
265
323
|
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
324
|
+
chmodSync(logsDir, 0o700);
|
|
266
325
|
const stampLog = (line) => console.log(`${new Date().toISOString()} ${line}`);
|
|
267
326
|
|
|
268
327
|
const supervisor = createDaemonSupervisor({
|
|
@@ -272,7 +331,9 @@ Examples:
|
|
|
272
331
|
// ordinary `commonly agent run <name>` — the daemon is its
|
|
273
332
|
// supervisor, never its replacement (D6).
|
|
274
333
|
spawnChild: (agentName) => {
|
|
275
|
-
const
|
|
334
|
+
const seatLog = daemonSeatLogPath(agentName);
|
|
335
|
+
const out = openSync(seatLog, 'a', 0o600);
|
|
336
|
+
chmodSync(seatLog, 0o600);
|
|
276
337
|
return spawn(process.execPath, [process.argv[1], 'agent', 'run', agentName], {
|
|
277
338
|
stdio: ['ignore', out, out],
|
|
278
339
|
});
|
|
@@ -281,6 +342,15 @@ Examples:
|
|
|
281
342
|
saveToken: saveAgentToken,
|
|
282
343
|
resolveAdapter: (runtime) => resolveAdapterForRuntime(runtime),
|
|
283
344
|
log: stampLog,
|
|
345
|
+
persistState: (seats) => saveDaemonState({
|
|
346
|
+
machineName: record.machineName,
|
|
347
|
+
machineDbId: record.machineDbId,
|
|
348
|
+
updatedAt: new Date().toISOString(),
|
|
349
|
+
seats: seats.map((seat) => ({
|
|
350
|
+
...seat,
|
|
351
|
+
lastTurnAt: getLastTurn(seat.agentName) || seat.lastTurnAt,
|
|
352
|
+
})),
|
|
353
|
+
}),
|
|
284
354
|
});
|
|
285
355
|
|
|
286
356
|
stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
|
|
@@ -306,7 +376,8 @@ Examples:
|
|
|
306
376
|
daemon
|
|
307
377
|
.command('status')
|
|
308
378
|
.description('Show the server-derived liveness of this machine')
|
|
309
|
-
.
|
|
379
|
+
.option('--verbose', 'Include locally supervised seat state and process details')
|
|
380
|
+
.action(async (opts) => {
|
|
310
381
|
try {
|
|
311
382
|
const record = requireDaemonRecord();
|
|
312
383
|
const machine = await getDaemonMachineStatus({
|
|
@@ -318,9 +389,52 @@ Examples:
|
|
|
318
389
|
}
|
|
319
390
|
const lastSeen = machine.lastSeenAt ? new Date(machine.lastSeenAt).toLocaleString() : 'never';
|
|
320
391
|
console.log(`${machine.name}: ${machine.status} (last heartbeat: ${lastSeen})`);
|
|
392
|
+
if (opts.verbose) {
|
|
393
|
+
const local = loadDaemonState();
|
|
394
|
+
if (!local) {
|
|
395
|
+
console.log('Local supervisor state: unavailable (daemon has not written state yet).');
|
|
396
|
+
} else if (!local.seats.length) {
|
|
397
|
+
console.log('Local supervisor state: no supervised seats.');
|
|
398
|
+
} else {
|
|
399
|
+
console.log('Local supervised seats:');
|
|
400
|
+
for (const seat of local.seats) {
|
|
401
|
+
const model = seat.model || 'default model';
|
|
402
|
+
const effort = seat.effort ? `/${seat.effort}` : '';
|
|
403
|
+
const error = seat.lastError ? ` error=${seat.lastError}` : '';
|
|
404
|
+
console.log(` ${seat.agentName}: ${seat.state} adapter=${seat.adapter || 'unknown'} model=${model}${effort} pid=${seat.pid || '-'} lastTurn=${seat.lastTurnAt || 'never'}${error}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
321
408
|
} catch (error) {
|
|
322
409
|
console.error(`Daemon status failed: ${error.message}`);
|
|
323
410
|
process.exitCode = 1;
|
|
324
411
|
}
|
|
325
412
|
});
|
|
413
|
+
|
|
414
|
+
daemon
|
|
415
|
+
.command('logs')
|
|
416
|
+
.description('Show daemon or per-seat logs')
|
|
417
|
+
.option('--seat <name>', 'Show one supervised seat log instead of the daemon log')
|
|
418
|
+
.option('-f, --follow', 'Continue printing appended log output')
|
|
419
|
+
.option('--lines <n>', 'Number of trailing lines to show', '50')
|
|
420
|
+
.action(async (opts) => {
|
|
421
|
+
const path = opts.seat ? daemonSeatLogPath(opts.seat) : daemonLogPath();
|
|
422
|
+
const printTail = () => {
|
|
423
|
+
const output = readLogTail(path, opts.lines);
|
|
424
|
+
if (output === null) {
|
|
425
|
+
console.error(`No log found at ${path}`);
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (output) console.log(output);
|
|
429
|
+
return true;
|
|
430
|
+
};
|
|
431
|
+
if (!printTail() || !opts.follow) return;
|
|
432
|
+
const watcher = watch(path, () => printTail());
|
|
433
|
+
const close = () => {
|
|
434
|
+
watcher.close();
|
|
435
|
+
process.exit(0);
|
|
436
|
+
};
|
|
437
|
+
process.once('SIGINT', close);
|
|
438
|
+
process.once('SIGTERM', close);
|
|
439
|
+
});
|
|
326
440
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const daemonLogsDir = (home = homedir()) => join(home, '.commonly', 'logs', 'daemon');
|
|
6
|
+
export const daemonSeatLogPath = (agentName, home = homedir()) => {
|
|
7
|
+
const safeName = String(agentName || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
8
|
+
return join(daemonLogsDir(home), `${safeName}.log`);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const tailLines = (text, count = 50) => {
|
|
12
|
+
const lines = String(text).split(/\r?\n/);
|
|
13
|
+
// A trailing newline is a separator, not an empty log line.
|
|
14
|
+
if (lines.at(-1) === '') lines.pop();
|
|
15
|
+
return lines.slice(-Math.max(0, Number(count) || 0)).join('\n');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const readLogTail = (path, count = 50) => {
|
|
19
|
+
try {
|
|
20
|
+
return tailLines(readFileSync(path, 'utf8'), count);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code === 'ENOENT') return null;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -55,6 +55,7 @@ export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml
|
|
|
55
55
|
\t\t<string>${xmlEscape(cliPath)}</string>
|
|
56
56
|
\t\t<string>daemon</string>
|
|
57
57
|
\t\t<string>run</string>
|
|
58
|
+
\t\t<string>--foreground</string>
|
|
58
59
|
\t</array>
|
|
59
60
|
\t<key>RunAtLoad</key>
|
|
60
61
|
\t<true/>
|
|
@@ -73,7 +74,7 @@ Description=Commonly local agent daemon (ADR-026)
|
|
|
73
74
|
After=network-online.target
|
|
74
75
|
|
|
75
76
|
[Service]
|
|
76
|
-
ExecStart=${nodePath} ${cliPath} daemon run
|
|
77
|
+
ExecStart=${nodePath} ${cliPath} daemon run --foreground
|
|
77
78
|
Restart=always
|
|
78
79
|
RestartSec=5
|
|
79
80
|
Environment=PATH=${childPath(nodePath)}
|
|
@@ -90,11 +91,18 @@ export const installDaemonService = async ({
|
|
|
90
91
|
writeFile,
|
|
91
92
|
mkdirp,
|
|
92
93
|
execCmd, // async (argv: string[]) => void — throws on failure
|
|
94
|
+
chmod = () => {},
|
|
95
|
+
ensureFile,
|
|
93
96
|
log = () => {},
|
|
94
97
|
}) => {
|
|
95
98
|
const target = servicePaths(platform, home);
|
|
96
99
|
mkdirp(dirname(target.file));
|
|
97
100
|
mkdirp(dirname(daemonLogPath(home)));
|
|
101
|
+
chmod(dirname(daemonLogPath(home)), 0o700);
|
|
102
|
+
if (ensureFile) {
|
|
103
|
+
ensureFile(daemonLogPath(home));
|
|
104
|
+
chmod(daemonLogPath(home), 0o600);
|
|
105
|
+
}
|
|
98
106
|
|
|
99
107
|
if (target.kind === 'launchd') {
|
|
100
108
|
writeFile(target.file, launchdPlist({ nodePath, cliPath, home }));
|
|
@@ -133,3 +141,33 @@ export const uninstallDaemonService = async ({
|
|
|
133
141
|
log(`Removed ${target.kind} service (${target.file}).`);
|
|
134
142
|
return target;
|
|
135
143
|
};
|
|
144
|
+
|
|
145
|
+
const serviceAction = async ({
|
|
146
|
+
action,
|
|
147
|
+
platform = process.platform,
|
|
148
|
+
home = homedir(),
|
|
149
|
+
execCmd,
|
|
150
|
+
}) => {
|
|
151
|
+
const target = servicePaths(platform, home);
|
|
152
|
+
if (target.kind === 'launchd') {
|
|
153
|
+
if (action === 'start') {
|
|
154
|
+
// `install` already loads the plist. A second `start` should be
|
|
155
|
+
// idempotent rather than failing with "already loaded".
|
|
156
|
+
await execCmd(['launchctl', 'load', '-w', target.file]).catch(async () => {
|
|
157
|
+
await execCmd(['launchctl', 'start', LAUNCHD_LABEL]);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
else if (action === 'stop') await execCmd(['launchctl', 'unload', '-w', target.file]);
|
|
161
|
+
else {
|
|
162
|
+
await execCmd(['launchctl', 'unload', '-w', target.file]).catch(() => {});
|
|
163
|
+
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
await execCmd(['systemctl', '--user', action, SYSTEMD_UNIT]);
|
|
167
|
+
}
|
|
168
|
+
return target;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const startDaemonService = (options) => serviceAction({ ...options, action: 'start' });
|
|
172
|
+
export const stopDaemonService = (options) => serviceAction({ ...options, action: 'stop' });
|
|
173
|
+
export const restartDaemonService = (options) => serviceAction({ ...options, action: 'restart' });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
// The state file is deliberately next to the daemon credential, but it never
|
|
13
|
+
// contains one. Keeping both under the already-private daemon directory makes
|
|
14
|
+
// it difficult for a future status field to accidentally become world-readable.
|
|
15
|
+
export const daemonStateDir = (home = homedir()) => join(home, '.commonly', 'daemon');
|
|
16
|
+
export const daemonStatePath = (home = homedir()) => join(daemonStateDir(home), 'state.json');
|
|
17
|
+
|
|
18
|
+
const fixedLastError = (value) => {
|
|
19
|
+
if (!value) return null;
|
|
20
|
+
const match = typeof value === 'string'
|
|
21
|
+
? value.match(/^child exited with code (-?\d+|null)$/)
|
|
22
|
+
: null;
|
|
23
|
+
return match ? `child exited with code ${match[1]}` : 'child process error';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const seatState = (seat = {}) => ({
|
|
27
|
+
agentName: seat.agentName,
|
|
28
|
+
instanceId: seat.instanceId,
|
|
29
|
+
state: seat.state,
|
|
30
|
+
restarts: seat.restarts,
|
|
31
|
+
adapter: seat.adapter || null,
|
|
32
|
+
model: seat.model || null,
|
|
33
|
+
effort: seat.effort || null,
|
|
34
|
+
pid: Number.isInteger(seat.pid) ? seat.pid : null,
|
|
35
|
+
lastTurnAt: seat.lastTurnAt || null,
|
|
36
|
+
lastError: fixedLastError(seat.lastError),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Pick fields rather than serializing arbitrary supervisor objects. This is a
|
|
40
|
+
// security boundary: no token, environment, or child-process metadata can be
|
|
41
|
+
// persisted merely because a future caller adds it to a runtime row.
|
|
42
|
+
export const publicDaemonState = (state = {}) => ({
|
|
43
|
+
machineName: state.machineName || null,
|
|
44
|
+
machineDbId: state.machineDbId || null,
|
|
45
|
+
updatedAt: state.updatedAt || new Date().toISOString(),
|
|
46
|
+
seats: Array.isArray(state.seats) ? state.seats.map(seatState) : [],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const saveDaemonState = (state, { home = homedir() } = {}) => {
|
|
50
|
+
const dir = daemonStateDir(home);
|
|
51
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
chmodSync(dir, 0o700);
|
|
53
|
+
const path = daemonStatePath(home);
|
|
54
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
55
|
+
writeFileSync(temp, `${JSON.stringify(publicDaemonState(state), null, 2)}\n`, {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
mode: 0o600,
|
|
58
|
+
});
|
|
59
|
+
chmodSync(temp, 0o600);
|
|
60
|
+
renameSync(temp, path);
|
|
61
|
+
chmodSync(path, 0o600);
|
|
62
|
+
return path;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const loadDaemonState = ({ home = homedir() } = {}) => {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(daemonStatePath(home), 'utf8'));
|
|
68
|
+
return publicDaemonState(parsed);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.code === 'ENOENT') return null;
|
|
71
|
+
throw new Error(`Could not read daemon state: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const removeDaemonState = ({ home = homedir() } = {}) => {
|
|
76
|
+
try {
|
|
77
|
+
unlinkSync(daemonStatePath(home));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
@@ -49,6 +49,7 @@ export const createDaemonSupervisor = ({
|
|
|
49
49
|
loadToken, // (agentName) => token record | null
|
|
50
50
|
saveToken, // (agentName, record) => void
|
|
51
51
|
resolveAdapter, // async (runtime) => adapter name for THIS machine
|
|
52
|
+
persistState = () => {}, // (agentStates) => void; must not persist secrets
|
|
52
53
|
log = () => {},
|
|
53
54
|
setTimeoutFn = setTimeout,
|
|
54
55
|
clearTimeoutFn = clearTimeout,
|
|
@@ -57,25 +58,48 @@ export const createDaemonSupervisor = ({
|
|
|
57
58
|
const seats = new Map();
|
|
58
59
|
let stopped = false;
|
|
59
60
|
|
|
61
|
+
const persist = () => {
|
|
62
|
+
try {
|
|
63
|
+
persistState(agentStates());
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// A diagnostic state file must never take the daemon down. The log is
|
|
66
|
+
// still useful, and the next state transition retries the write.
|
|
67
|
+
log(`could not persist local daemon state: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
60
71
|
const agentStates = () => Array.from(seats.values()).map((s) => ({
|
|
61
72
|
agentName: s.agentName,
|
|
62
73
|
instanceId: s.instanceId,
|
|
63
74
|
state: s.state,
|
|
64
75
|
restarts: s.restarts,
|
|
76
|
+
adapter: s.adapter || null,
|
|
77
|
+
model: s.model || null,
|
|
78
|
+
effort: s.effort || null,
|
|
79
|
+
pid: Number.isInteger(s.pid) ? s.pid : null,
|
|
80
|
+
lastTurnAt: s.lastTurnAt || null,
|
|
81
|
+
lastError: s.lastError || null,
|
|
65
82
|
}));
|
|
66
83
|
|
|
67
84
|
const startChild = (seat) => {
|
|
68
85
|
if (stopped || !seat.desired || seat.child) return;
|
|
69
86
|
seat.child = spawnChild(seat.agentName);
|
|
70
87
|
seat.state = 'running';
|
|
88
|
+
seat.pid = Number.isInteger(seat.child?.pid) ? seat.child.pid : null;
|
|
89
|
+
seat.lastTurnAt = new Date().toISOString();
|
|
90
|
+
seat.lastError = null;
|
|
91
|
+
persist();
|
|
71
92
|
log(`[${seat.agentName}] supervising (restarts so far: ${seat.restarts})`);
|
|
72
93
|
seat.child.on('exit', (code) => {
|
|
73
94
|
seat.child = null;
|
|
95
|
+
seat.pid = null;
|
|
74
96
|
if (stopped || !seat.desired) {
|
|
75
97
|
seat.state = 'stopped';
|
|
98
|
+
persist();
|
|
76
99
|
return;
|
|
77
100
|
}
|
|
78
101
|
seat.state = code === 0 ? 'stopped' : 'crashed';
|
|
102
|
+
seat.lastError = code === 0 ? null : `child exited with code ${code}`;
|
|
79
103
|
seat.restarts += 1;
|
|
80
104
|
const delay = backoffMs(seat.restarts - 1);
|
|
81
105
|
log(`[${seat.agentName}] exited (code ${code}) — respawn in ${Math.round(delay / 1000)}s`);
|
|
@@ -83,6 +107,7 @@ export const createDaemonSupervisor = ({
|
|
|
83
107
|
seat.backoffTimer = null;
|
|
84
108
|
startChild(seat);
|
|
85
109
|
}, delay);
|
|
110
|
+
persist();
|
|
86
111
|
});
|
|
87
112
|
};
|
|
88
113
|
|
|
@@ -97,6 +122,8 @@ export const createDaemonSupervisor = ({
|
|
|
97
122
|
seat.child.kill('SIGTERM');
|
|
98
123
|
} else {
|
|
99
124
|
seat.state = 'stopped';
|
|
125
|
+
seat.pid = null;
|
|
126
|
+
persist();
|
|
100
127
|
}
|
|
101
128
|
};
|
|
102
129
|
|
|
@@ -280,10 +307,20 @@ export const createDaemonSupervisor = ({
|
|
|
280
307
|
restarts: 0,
|
|
281
308
|
backoffTimer: null,
|
|
282
309
|
desired: true,
|
|
310
|
+
adapter: null,
|
|
311
|
+
model: null,
|
|
312
|
+
effort: null,
|
|
313
|
+
pid: null,
|
|
314
|
+
lastTurnAt: null,
|
|
315
|
+
lastError: null,
|
|
283
316
|
};
|
|
284
317
|
seats.set(key, seat);
|
|
285
318
|
}
|
|
286
319
|
seat.desired = true;
|
|
320
|
+
const localToken = loadToken(row.agentName);
|
|
321
|
+
seat.adapter = row.runtime?.adapter || localToken?.adapter || seat.adapter || null;
|
|
322
|
+
seat.model = row.runtime?.model || localToken?.environment?.model || seat.model || null;
|
|
323
|
+
seat.effort = row.runtime?.effort || localToken?.environment?.effort || seat.effort || null;
|
|
287
324
|
// eslint-disable-next-line no-await-in-loop
|
|
288
325
|
const ready = await ensureToken(row);
|
|
289
326
|
if (ready === 'changed' && seat.child) {
|
|
@@ -293,11 +330,13 @@ export const createDaemonSupervisor = ({
|
|
|
293
330
|
} else if (ready && !seat.child && !seat.backoffTimer) {
|
|
294
331
|
startChild(seat);
|
|
295
332
|
}
|
|
333
|
+
persist();
|
|
296
334
|
}
|
|
297
335
|
|
|
298
336
|
for (const [key, seat] of seats) {
|
|
299
337
|
if (!desiredKeys.has(key) && seat.desired) stopSeat(seat);
|
|
300
338
|
}
|
|
339
|
+
persist();
|
|
301
340
|
};
|
|
302
341
|
|
|
303
342
|
const heartbeat = async () => {
|
|
@@ -311,7 +350,12 @@ export const createDaemonSupervisor = ({
|
|
|
311
350
|
|
|
312
351
|
const stop = () => {
|
|
313
352
|
stopped = true;
|
|
314
|
-
for (const seat of seats.values())
|
|
353
|
+
for (const seat of seats.values()) {
|
|
354
|
+
stopSeat(seat);
|
|
355
|
+
seat.state = 'stopped';
|
|
356
|
+
seat.pid = null;
|
|
357
|
+
}
|
|
358
|
+
persist();
|
|
315
359
|
};
|
|
316
360
|
|
|
317
361
|
return {
|
package/src/lib/session-store.js
CHANGED
|
@@ -87,6 +87,18 @@ export const getSession = (agentName, podId) => {
|
|
|
87
87
|
return readAgent(agentName)[podId]?.sessionId || null;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
// Read-only diagnostic projection used by the daemon operator surface. A
|
|
91
|
+
// wrapper may serve several pods, so return the newest completed turn.
|
|
92
|
+
export const getLastTurn = (agentName) => {
|
|
93
|
+
if (!agentName) return null;
|
|
94
|
+
const state = readAgent(agentName);
|
|
95
|
+
return Object.values(state)
|
|
96
|
+
.map((entry) => entry?.lastTurn)
|
|
97
|
+
.filter((value) => typeof value === 'string')
|
|
98
|
+
.sort()
|
|
99
|
+
.at(-1) || null;
|
|
100
|
+
};
|
|
101
|
+
|
|
90
102
|
export const setSession = (agentName, podId, sessionId) => {
|
|
91
103
|
if (!agentName || !podId) return;
|
|
92
104
|
const state = readAgent(agentName);
|