@commonlyai/cli 0.1.40 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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,
@@ -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 = join(homedir(), '.commonly', 'logs', 'daemon');
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 out = openSync(join(logsDir, `${agentName}.log`), 'a');
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
- .action(async () => {
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
  };
@@ -8,7 +8,7 @@
8
8
  import { createInterface } from 'readline';
9
9
  import { hostname } from 'os';
10
10
  import { createClient, login as apiLogin } from '../lib/api.js';
11
- import { saveInstance } from '../lib/config.js';
11
+ import { DEFAULT_URL, resolveInstance, saveInstance } from '../lib/config.js';
12
12
  import {
13
13
  DeviceLoginCancelledError,
14
14
  DeviceLoginDeniedError,
@@ -42,16 +42,40 @@ const promptSecret = (question) => new Promise((resolve) => {
42
42
  stdin.on('data', onData);
43
43
  });
44
44
 
45
+ /**
46
+ * Resolve the login target before making the request or writing config.
47
+ *
48
+ * `--instance` accepts either a URL or a saved profile key. The API client
49
+ * resolves keys internally, but persisting the raw argument would write a key
50
+ * (for example, "default") into the URL field and break every later command.
51
+ */
52
+ export const resolveLoginTarget = ({ instanceArg, keyArg }) => {
53
+ // A missing --instance means "the active profile", just like the other
54
+ // config helpers. Resolve it before choosing the key so a login without
55
+ // flags refreshes that profile instead of silently forking `default`.
56
+ const resolved = resolveInstance(instanceArg);
57
+ const isUrl = /^https?:\/\//i.test(instanceArg || '');
58
+ const instanceUrl = (resolved?.url || (isUrl ? instanceArg : DEFAULT_URL)).replace(/\/$/, '');
59
+ const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
60
+ const isKey = instanceArg && !isUrl;
61
+ // A URL may resolve back to an existing named profile too. Prefer that key
62
+ // regardless of input shape, or a URL login can fork a second profile onto
63
+ // the same endpoint and leave the original token stale.
64
+ const configKey = keyArg || resolved?.key || (isKey ? instanceArg : (isLocal ? 'local' : 'default'));
65
+
66
+ return { instanceUrl, configKey };
67
+ };
68
+
45
69
  export const registerLogin = (program) => {
46
70
  program
47
71
  .command('login')
48
72
  .description('Authenticate to a Commonly instance')
49
- .option('--instance <url>', 'Instance URL (default: https://api.commonly.me)')
50
- .option('--key <name>', 'Config key to save as (default: "default" or "local")')
73
+ .option('--instance <url-or-key>', 'Instance URL or saved profile key (default: active profile; production on first run)')
74
+ .option('--key <name>', 'Config key to save as (default: active profile key; otherwise derived from the instance)')
51
75
  .option('--password', 'Use the legacy email/password prompt instead of device authorization')
52
76
  .addHelpText('after', `
53
77
  Examples:
54
- $ commonly login # production (default key)
78
+ $ commonly login # active profile (production on first run)
55
79
  $ commonly login --instance https://api.commonly.me --key dev # named profile
56
80
  $ commonly login --instance http://localhost:5000 # saved as "local"
57
81
 
@@ -59,12 +83,10 @@ Tokens are stored in ~/.commonly/config.json. Other commands take
59
83
  --instance <url-or-key> to target the right profile.
60
84
  `)
61
85
  .action(async (opts) => {
62
- const instanceUrl = opts.instance
63
- ? opts.instance.replace(/\/$/, '')
64
- : 'https://api.commonly.me';
65
-
66
- const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
67
- const configKey = opts.key || (isLocal ? 'local' : 'default');
86
+ const { instanceUrl, configKey } = resolveLoginTarget({
87
+ instanceArg: opts.instance,
88
+ keyArg: opts.key,
89
+ });
68
90
 
69
91
  try {
70
92
  if (!opts.password) {
@@ -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()) stopSeat(seat);
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 {
@@ -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);