@commonlyai/cli 0.1.215 → 0.1.217
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
package/src/commands/daemon.js
CHANGED
|
@@ -38,6 +38,12 @@ import {
|
|
|
38
38
|
} from '../lib/daemon-service.js';
|
|
39
39
|
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
40
40
|
import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
|
|
41
|
+
import {
|
|
42
|
+
CLEAR_SCREEN, renderDaemonWatch, shouldRedraw, WATCH_INTERVAL_MS,
|
|
43
|
+
} from '../lib/daemon-watch.js';
|
|
44
|
+
import {
|
|
45
|
+
createReachCache, loadPodReach, readSessionLastTurn,
|
|
46
|
+
} from '../lib/daemon-agent-states.js';
|
|
41
47
|
import { installedCliVersion, startUpdateNotice, UPDATE_CHECK_OPT_OUT_ENV } from '../lib/update-check.js';
|
|
42
48
|
|
|
43
49
|
// One supervised seat, as `commonly daemon status --verbose` names it. Two of these
|
|
@@ -58,6 +64,90 @@ export const formatSeatLine = (seat = {}) => {
|
|
|
58
64
|
return ` ${seat.agentName}: ${seat.state} instance=${seat.instanceId || '-'} adapter=${seat.adapter || 'unknown'} model=${model}${effort} pid=${seat.pid || '-'} restarts=${restarts} lastTurn=${seat.lastTurnAt || 'never'}${error}`;
|
|
59
65
|
};
|
|
60
66
|
|
|
67
|
+
// `daemon status --watch`: one frame, then a redraw every 2 s on a terminal. Off a
|
|
68
|
+
// terminal, or with NO_COLOR set, it prints one frame and returns. A failed
|
|
69
|
+
// fetch is shown in the frame, so the loop keeps running through a brief outage.
|
|
70
|
+
const watchDaemonStatus = async () => {
|
|
71
|
+
const redraw = shouldRedraw({ isTTY: process.stdout.isTTY });
|
|
72
|
+
// Each agent's pod comes from its token record. The verdict for a pod is read
|
|
73
|
+
// through the operator's login and cached for 10 s, so the 2 s frame does not
|
|
74
|
+
// re-read it. A failed read is thrown, so the next frame retries.
|
|
75
|
+
const reachFor = createReachCache({
|
|
76
|
+
load: (podId) => loadPodReach({
|
|
77
|
+
client: createClient({ instance: requireDaemonRecord().instanceUrl }),
|
|
78
|
+
podId,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
const frame = async () => {
|
|
82
|
+
try {
|
|
83
|
+
const record = requireDaemonRecord();
|
|
84
|
+
// A failed machine read still shows the local seats and logs, with the
|
|
85
|
+
// machine marked unreachable, so the operator can see what is running here.
|
|
86
|
+
let machine;
|
|
87
|
+
let machineNote = '';
|
|
88
|
+
try {
|
|
89
|
+
machine = await getDaemonMachineStatus({
|
|
90
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
91
|
+
record,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
machine = { name: record.machineName, status: 'unreachable', lastSeenAt: null };
|
|
95
|
+
machineNote = `Machine status unavailable (${error.message}): showing local seats only.`;
|
|
96
|
+
}
|
|
97
|
+
const seats = (loadDaemonState()?.seats || []).map((seat) => ({
|
|
98
|
+
...seat,
|
|
99
|
+
// The supervisor stamps spawn time; the session store holds real turns.
|
|
100
|
+
lastTurnAt: readSessionLastTurn(seat.agentName),
|
|
101
|
+
}));
|
|
102
|
+
const logTails = {};
|
|
103
|
+
for (const seat of seats) {
|
|
104
|
+
const tail = readLogTail(daemonSeatLogPath(seat.agentName), 3);
|
|
105
|
+
logTails[seat.agentName] = tail ? tail.split('\n') : [];
|
|
106
|
+
}
|
|
107
|
+
let states = null;
|
|
108
|
+
let stateNote = '';
|
|
109
|
+
if (!getToken(record.instanceUrl)) {
|
|
110
|
+
stateNote = `Not logged in: showing local process state only. Run: commonly login --instance ${record.instanceUrl}`;
|
|
111
|
+
} else {
|
|
112
|
+
const podIds = [...new Set(seats.map((seat) => loadAgentToken(seat.agentName)?.podId).filter(Boolean))];
|
|
113
|
+
try {
|
|
114
|
+
states = await reachFor(podIds);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
stateNote = `Agent states unavailable (${error.message}): showing local process state only`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return renderDaemonWatch({
|
|
120
|
+
machine, seats, logTails, states, stateNote: [machineNote, stateNote].filter(Boolean).join(' '), now: Date.now(), width: process.stdout.columns || 80, color: redraw,
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return daemonStatusErrorMessage(error);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
if (!redraw) {
|
|
127
|
+
console.log(await frame());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let busy = false;
|
|
131
|
+
const tick = async () => {
|
|
132
|
+
if (busy) return;
|
|
133
|
+
busy = true;
|
|
134
|
+
try {
|
|
135
|
+
process.stdout.write(`${CLEAR_SCREEN}${await frame()}\n`);
|
|
136
|
+
} finally {
|
|
137
|
+
busy = false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await tick();
|
|
141
|
+
const timer = setInterval(tick, WATCH_INTERVAL_MS);
|
|
142
|
+
const stop = () => {
|
|
143
|
+
clearInterval(timer);
|
|
144
|
+
process.stdout.write('\n');
|
|
145
|
+
process.exit(0);
|
|
146
|
+
};
|
|
147
|
+
process.once('SIGINT', stop);
|
|
148
|
+
process.once('SIGTERM', stop);
|
|
149
|
+
};
|
|
150
|
+
|
|
61
151
|
const requireDaemonRecord = () => {
|
|
62
152
|
const record = loadDaemonRecord();
|
|
63
153
|
if (!record) {
|
|
@@ -571,7 +661,12 @@ Examples:
|
|
|
571
661
|
.command('status')
|
|
572
662
|
.description('Show the server-derived liveness of this machine')
|
|
573
663
|
.option('--verbose', 'Include locally supervised seat state and process details')
|
|
664
|
+
.option('--watch', 'Redraw the machine and agent view every 2 s until Ctrl-C')
|
|
574
665
|
.action(async (opts) => {
|
|
666
|
+
if (opts.watch) {
|
|
667
|
+
await watchDaemonStatus();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
575
670
|
try {
|
|
576
671
|
const record = requireDaemonRecord();
|
|
577
672
|
const machine = await getDaemonMachineStatus({
|
|
@@ -494,10 +494,65 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
494
494
|
|
|
495
495
|
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
496
496
|
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
497
|
+
// Stream-json so each model event is a sign of life for the watchdog. The
|
|
498
|
+
// partial messages put thinking deltas on the stream too, so a long think is
|
|
499
|
+
// visible while it runs. A hung provider writes only `system` retry events,
|
|
500
|
+
// and the watchdog does not count those.
|
|
501
|
+
const STREAM_ARGS = ['--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
502
|
+
|
|
503
|
+
const parseEvent = (line) => {
|
|
504
|
+
try {
|
|
505
|
+
const event = JSON.parse(line);
|
|
506
|
+
return event && typeof event === 'object' ? event : null;
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// The reply is the `result` event's text. A turn that reaches no result event
|
|
513
|
+
// (killed mid-stream, exit 0) gets the text of its assistant events, never the
|
|
514
|
+
// raw NDJSON. Output with no JSON lines at all is plain text and returned as it
|
|
515
|
+
// arrived. A result flagged `is_error` is a failed turn, whatever its exit code,
|
|
516
|
+
// so it is raised here, outside the parse guard, and never returned as a reply.
|
|
517
|
+
export const claudeResultText = (stdout, { credentials = [] } = {}) => {
|
|
518
|
+
const text = String(stdout || '');
|
|
519
|
+
const events = text.split('\n').map(parseEvent);
|
|
520
|
+
if (!events.some(Boolean)) return text.trim();
|
|
521
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
522
|
+
if (events[i]?.type !== 'result') continue;
|
|
523
|
+
const reply = String(events[i].result ?? '').trim();
|
|
524
|
+
if (events[i].is_error === true) throw adapterFailure('claude', reply, { credentials });
|
|
525
|
+
return reply;
|
|
526
|
+
}
|
|
527
|
+
return events
|
|
528
|
+
.filter((event) => event?.type === 'assistant')
|
|
529
|
+
.flatMap((event) => event.message?.content || [])
|
|
530
|
+
.filter((block) => block?.type === 'text')
|
|
531
|
+
.map((block) => block.text)
|
|
532
|
+
.join('')
|
|
533
|
+
.trim();
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// The failure tail for a non-zero exit. Stream events are mostly system init
|
|
537
|
+
// and status lines, which would push the terminal error past the 2000-char cut,
|
|
538
|
+
// so keep the result text, or the lines that are not system or stream events.
|
|
539
|
+
export const claudeFailureTail = (stdout) => {
|
|
540
|
+
const text = String(stdout || '');
|
|
541
|
+
const lines = text.split('\n');
|
|
542
|
+
const events = lines.map(parseEvent);
|
|
543
|
+
if (!events.some(Boolean)) return text.trim();
|
|
544
|
+
const result = [...events].reverse().find((event) => event?.type === 'result');
|
|
545
|
+
if (String(result?.result ?? '').trim()) return String(result.result).trim();
|
|
546
|
+
return lines
|
|
547
|
+
.filter((_, i) => !['system', 'stream_event'].includes(events[i]?.type))
|
|
548
|
+
.join('\n')
|
|
549
|
+
.trim();
|
|
550
|
+
};
|
|
551
|
+
|
|
497
552
|
const runClaude = ({
|
|
498
553
|
cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
499
554
|
transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
|
|
500
|
-
processTable = readProcessTable, survivorIo,
|
|
555
|
+
processTable = readProcessTable, survivorIo, modelStream = true,
|
|
501
556
|
}) => new Promise((resolve, reject) => {
|
|
502
557
|
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
503
558
|
cwd,
|
|
@@ -529,6 +584,7 @@ const runClaude = ({
|
|
|
529
584
|
proc,
|
|
530
585
|
windowMs: firstOutputMs,
|
|
531
586
|
transcriptPath,
|
|
587
|
+
modelStream,
|
|
532
588
|
lockProbe,
|
|
533
589
|
onStall: (reason) => {
|
|
534
590
|
stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
|
|
@@ -584,7 +640,7 @@ const runClaude = ({
|
|
|
584
640
|
// reduced to one named field. The status, when the tail names one, is
|
|
585
641
|
// attached as `error.status` so a refusal classifies by field rather than
|
|
586
642
|
// by regex.
|
|
587
|
-
const detail = [stderr.trim(), stdout
|
|
643
|
+
const detail = [stderr.trim(), claudeFailureTail(stdout)].filter(Boolean).join(' | ');
|
|
588
644
|
return reject(adapterFailure('claude', detail, {
|
|
589
645
|
credentials,
|
|
590
646
|
exitCode: code,
|
|
@@ -1161,7 +1217,7 @@ export default {
|
|
|
1161
1217
|
...(seatModel ? ['--model', seatModel] : []),
|
|
1162
1218
|
...(ctx.environment?.effort ? ['--effort', String(ctx.environment.effort)] : []),
|
|
1163
1219
|
];
|
|
1164
|
-
const baseArgs = ['-p', fullPrompt,
|
|
1220
|
+
const baseArgs = ['-p', fullPrompt, ...STREAM_ARGS, sessionFlag, sessionId, ...modelArgs];
|
|
1165
1221
|
|
|
1166
1222
|
let mountedSkills = [];
|
|
1167
1223
|
let skillMount = { mounted: [], conflicted: [] };
|
|
@@ -1274,7 +1330,7 @@ export default {
|
|
|
1274
1330
|
const contextTokens = claudeContextTokens({
|
|
1275
1331
|
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1276
1332
|
});
|
|
1277
|
-
return { text: stdout
|
|
1333
|
+
return { text: claudeResultText(stdout, { credentials: spawnCredentials(spawnCtx) }), newSessionId: sessionId, contextTokens };
|
|
1278
1334
|
} catch (err) {
|
|
1279
1335
|
// A public seat's refusal is not a session-store failure, whatever its
|
|
1280
1336
|
// message happens to contain: it must reach the run loop, never the
|
|
@@ -1295,7 +1351,7 @@ export default {
|
|
|
1295
1351
|
memoryWithheld: ctx.memoryWithheld === true,
|
|
1296
1352
|
});
|
|
1297
1353
|
const retryBase = ['-p', freshPrompt,
|
|
1298
|
-
|
|
1354
|
+
...STREAM_ARGS, '--session-id', freshId, ...modelArgs];
|
|
1299
1355
|
const retryCtx = {
|
|
1300
1356
|
...ctx,
|
|
1301
1357
|
mcpConfigPath: mcpConfig?.file || null,
|
|
@@ -1337,7 +1393,7 @@ export default {
|
|
|
1337
1393
|
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
1338
1394
|
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1339
1395
|
});
|
|
1340
|
-
return { text: stdout
|
|
1396
|
+
return { text: claudeResultText(stdout, { credentials: spawnCredentials(retryCtx) }), newSessionId: freshId, contextTokens };
|
|
1341
1397
|
}
|
|
1342
1398
|
throw err;
|
|
1343
1399
|
} finally {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// The server's verdict for each agent, as `daemon status --watch` shows it. The
|
|
6
|
+
// verdict is deriveAgentState's (backend/services/agentStateService.ts), the
|
|
7
|
+
// same one the Your Team row and the TASK-655 never-connected line use, so the
|
|
8
|
+
// watch and the product name an agent's state the same way. It is read through
|
|
9
|
+
// the operator's login, because the daemon's own credential cannot call the
|
|
10
|
+
// per-pod agent-states route. Each agent's pod comes from its token record.
|
|
11
|
+
|
|
12
|
+
// The reach states mapped to the words the watch prints. `reachable` is a
|
|
13
|
+
// native or push-webhook agent the server always treats as up.
|
|
14
|
+
const LABEL = {
|
|
15
|
+
listening: 'running',
|
|
16
|
+
reachable: 'running',
|
|
17
|
+
'gone-dark': 'stalled',
|
|
18
|
+
'never-connected': 'not connected',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// When one agent is installed in several pods, the most alive verdict wins.
|
|
22
|
+
const RANK = ['running', 'stalled', 'not connected', 'unknown'];
|
|
23
|
+
|
|
24
|
+
export const reachLabel = (reach) => LABEL[reach] || 'unknown';
|
|
25
|
+
|
|
26
|
+
export const seatKey = (agentName, instanceId) => (
|
|
27
|
+
`${String(agentName || '').toLowerCase()}:${String(instanceId || 'default').toLowerCase()}`
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const moreAlive = (a, b) => (RANK.indexOf(a) <= RANK.indexOf(b) ? a : b);
|
|
31
|
+
|
|
32
|
+
// Reads one pod's agents and returns Map<seatKey, label>. `client` is a
|
|
33
|
+
// user-token client.
|
|
34
|
+
export const loadPodReach = async ({ client, podId }) => {
|
|
35
|
+
const body = await client.get(`/api/pods/${podId}/agent-states`);
|
|
36
|
+
const byAgent = new Map();
|
|
37
|
+
for (const agent of body?.agents || []) {
|
|
38
|
+
byAgent.set(seatKey(agent.agentName, agent.instanceId), reachLabel(agent.state));
|
|
39
|
+
}
|
|
40
|
+
return byAgent;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The server allows 60 agent-states reads a minute per IP. Each pod is refreshed
|
|
44
|
+
// once per TTL, and the TTL stretches with the pod count, so N pods cost about 50
|
|
45
|
+
// reads a minute whatever N is: 1.2 s per pod at least, and never below `ttlMs`.
|
|
46
|
+
const PER_POD_SPACING_MS = 1_200;
|
|
47
|
+
|
|
48
|
+
// Caches each pod's verdict, so the 2 s watch frame does not re-read every pod.
|
|
49
|
+
// A failed refresh keeps the pod's last verdict and waits one TTL before trying
|
|
50
|
+
// again, so a 429 is neither discarded nor retried every frame. A pod with no
|
|
51
|
+
// earlier verdict throws, and the next call retries it. `load(podId)` returns
|
|
52
|
+
// Map<seatKey, label>. Returns `reachFor(podIds)`, which merges the pods' maps.
|
|
53
|
+
export const createReachCache = ({ load, ttlMs = 10_000, now = Date.now }) => {
|
|
54
|
+
const cached = new Map();
|
|
55
|
+
const reachFor = async (podIds) => {
|
|
56
|
+
const ttl = Math.max(ttlMs, podIds.length * PER_POD_SPACING_MS);
|
|
57
|
+
const merged = new Map();
|
|
58
|
+
for (const podId of podIds) {
|
|
59
|
+
const hit = cached.get(podId);
|
|
60
|
+
let agents;
|
|
61
|
+
if (hit && now() - hit.loadedAt < ttl) {
|
|
62
|
+
agents = hit.agents;
|
|
63
|
+
} else {
|
|
64
|
+
try {
|
|
65
|
+
agents = await load(podId);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!hit) throw error;
|
|
68
|
+
agents = hit.agents;
|
|
69
|
+
}
|
|
70
|
+
cached.set(podId, { agents, loadedAt: now() });
|
|
71
|
+
}
|
|
72
|
+
for (const [key, label] of agents) {
|
|
73
|
+
merged.set(key, merged.has(key) ? moreAlive(merged.get(key), label) : label);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
};
|
|
78
|
+
return reachFor;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// The agent's last supervised turn, from its session store, the record
|
|
82
|
+
// `agent list` reads. The most recent entry across pods, or null. The
|
|
83
|
+
// supervisor's own stamp is the spawn time, so the watch does not use it.
|
|
84
|
+
export const readSessionLastTurn = (name, { home = homedir() } = {}) => {
|
|
85
|
+
try {
|
|
86
|
+
const sessions = JSON.parse(readFileSync(join(home, '.commonly', 'sessions', `${name}.json`), 'utf8'));
|
|
87
|
+
let latest = null;
|
|
88
|
+
for (const entry of Object.values(sessions || {})) {
|
|
89
|
+
if (entry?.lastTurn && (!latest || entry.lastTurn > latest)) latest = entry.lastTurn;
|
|
90
|
+
}
|
|
91
|
+
return latest;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The live view behind `commonly daemon status --watch`: the machine, then one
|
|
2
|
+
// block per supervised agent with its state, last turn, last error and the tail
|
|
3
|
+
// of its log. Rendering is a pure function of its inputs so the layout can be
|
|
4
|
+
// tested at fixed widths; the redraw loop lives in the command.
|
|
5
|
+
|
|
6
|
+
import { seatKey } from './daemon-agent-states.js';
|
|
7
|
+
|
|
8
|
+
export const WATCH_INTERVAL_MS = 2000;
|
|
9
|
+
|
|
10
|
+
const ANSI = {
|
|
11
|
+
dim: '\x1b[2m',
|
|
12
|
+
bold: '\x1b[1m',
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// A redraw clears the screen and returns the cursor home. Plain ANSI, no
|
|
17
|
+
// dependency; the caller decides whether to use it at all.
|
|
18
|
+
export const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
19
|
+
|
|
20
|
+
// The view is for a person watching a terminal. Anything else gets one print.
|
|
21
|
+
export const shouldRedraw = ({ isTTY, env = process.env } = {}) => (
|
|
22
|
+
Boolean(isTTY) && !(env && env.NO_COLOR !== undefined && env.NO_COLOR !== '')
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const relative = (iso, now) => {
|
|
26
|
+
if (!iso) return 'never';
|
|
27
|
+
const ms = now - new Date(iso).getTime();
|
|
28
|
+
if (!Number.isFinite(ms)) return 'unknown';
|
|
29
|
+
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
|
|
30
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
|
31
|
+
return `${Math.round(ms / 3_600_000)}h ago`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Terminal control sequences in a log or state field would reach the operator's
|
|
35
|
+
// terminal: a line could clear the watch, set the title, or write the clipboard.
|
|
36
|
+
// Strip OSC and CSI sequences, bare escapes, and C0/C1 controls before anything
|
|
37
|
+
// else. Tabs become spaces so the column count stays honest.
|
|
38
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
39
|
+
const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
|
|
40
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
41
|
+
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
42
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
43
|
+
const ESC = /\x1b[@-Z\\-_]/g;
|
|
44
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
45
|
+
const CONTROL = /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g;
|
|
46
|
+
export const stripControls = (text) => String(text ?? '')
|
|
47
|
+
.replace(OSC, '').replace(CSI, '').replace(ESC, '').replace(CONTROL, '').replace(/\t/g, ' ');
|
|
48
|
+
|
|
49
|
+
// Credential shapes the CLI knows, plus the forms that carry a credential in a
|
|
50
|
+
// log without a known prefix: `Authorization: Basic …`, and a quoted or bare
|
|
51
|
+
// `token`/`password`/`secret`/`api_key` field. Generic key/value redaction can
|
|
52
|
+
// over-match, which is the cheaper error for a watch that prints logs.
|
|
53
|
+
const CREDENTIAL = [
|
|
54
|
+
[/cm_(?:daemon|agent|user)_[A-Za-z0-9_-]+/g, '[redacted]'],
|
|
55
|
+
[/\bBearer\s+\S+/gi, '[redacted]'],
|
|
56
|
+
[/\bBasic\s+[A-Za-z0-9+/=._-]+/g, '[redacted]'],
|
|
57
|
+
[/(\bauthorization\s*[:=]\s*)(?:(?:basic|bearer|digest|token)\s+)?\S+/gi, '$1[redacted]'],
|
|
58
|
+
[/(["']?\b(?:token|password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b["']?\s*[:=]\s*)(["']?)[^\s"',}&]+\2/gi, '$1$2[redacted]$2'],
|
|
59
|
+
];
|
|
60
|
+
const redact = (text) => CREDENTIAL.reduce((value, [shape, replacement]) => value.replace(shape, replacement), text);
|
|
61
|
+
|
|
62
|
+
// Every field the watch prints goes through here: control sequences out,
|
|
63
|
+
// credentials redacted, then clipped to the width.
|
|
64
|
+
const clip = (text, width) => {
|
|
65
|
+
const value = redact(stripControls(text));
|
|
66
|
+
return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// A log line can carry a credential or a whole prompt; the same boundary covers it.
|
|
70
|
+
export const safeLogLine = (line, width) => clip(line, width);
|
|
71
|
+
|
|
72
|
+
// The state is the server's verdict, read by daemon-agent-states. `states` is
|
|
73
|
+
// that Map. Without it (no login, or the read failed) the seat shows its local
|
|
74
|
+
// process state, labelled `local`, and never a server word. An agent the verdict
|
|
75
|
+
// does not list has no installation on its pod.
|
|
76
|
+
export const agentStateLabel = (seat = {}, states = null) => {
|
|
77
|
+
if (!states) return `local ${seat.state || 'unknown'}`;
|
|
78
|
+
return states.get(seatKey(seat.agentName, seat.instanceId)) || 'not registered';
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const agentBlock = (seat, { now, width, color, logTail, states }) => {
|
|
82
|
+
const dim = (text) => (color ? `${ANSI.dim}${text}${ANSI.reset}` : text);
|
|
83
|
+
const bold = (text) => (color ? `${ANSI.bold}${text}${ANSI.reset}` : text);
|
|
84
|
+
const lines = [
|
|
85
|
+
`${bold(clip(seat.agentName || 'unknown', width))} ${agentStateLabel(seat, states)} last turn ${relative(seat.lastTurnAt, now)}`,
|
|
86
|
+
];
|
|
87
|
+
if (seat.lastError) lines.push(clip(` error: ${seat.lastError}`, width));
|
|
88
|
+
for (const line of logTail || []) lines.push(dim(` ${safeLogLine(line, Math.max(10, width - 2))}`));
|
|
89
|
+
return lines;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Inputs: `machine` { name, status, lastSeenAt }, `seats` (from the daemon state
|
|
93
|
+
// file), `logTails` { [agentName]: string[] }, `states` (Map from
|
|
94
|
+
// loadReachByAgent, or null), `stateNote` (why states is null), `now` (ms),
|
|
95
|
+
// `width` (columns).
|
|
96
|
+
export const renderDaemonWatch = ({
|
|
97
|
+
machine = {}, seats = [], logTails = {}, states = null, stateNote = '', now = Date.now(), width = 80, color = false,
|
|
98
|
+
} = {}) => {
|
|
99
|
+
const bold = (text) => (color ? `${ANSI.bold}${text}${ANSI.reset}` : text);
|
|
100
|
+
const out = [
|
|
101
|
+
bold(clip(`${machine.name || 'machine'}: ${machine.status || 'unknown'}`, width)),
|
|
102
|
+
clip(`last heartbeat ${relative(machine.lastSeenAt, now)}`, width),
|
|
103
|
+
];
|
|
104
|
+
if (stateNote) out.push(clip(stateNote, width));
|
|
105
|
+
out.push('');
|
|
106
|
+
if (!seats.length) {
|
|
107
|
+
out.push('No supervised agents on this machine.');
|
|
108
|
+
return out.join('\n');
|
|
109
|
+
}
|
|
110
|
+
for (const seat of seats) {
|
|
111
|
+
out.push(...agentBlock(seat, { now, width, color, logTail: logTails[seat.agentName] || [], states }));
|
|
112
|
+
out.push('');
|
|
113
|
+
}
|
|
114
|
+
return out.join('\n').replace(/\n+$/, '');
|
|
115
|
+
};
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* A spawn that does neither inside the window is killed with a reason. When
|
|
17
17
|
* the transcript is missing altogether, the path may simply be wrong, so the
|
|
18
18
|
* spawn is killed only if the console is also locked.
|
|
19
|
+
*
|
|
20
|
+
* That is the rule for a plain-text spawn. A stream-json spawn (TASK-710) counts
|
|
21
|
+
* only model events on stdout; see `modelStream` on watchFirstOutput.
|
|
19
22
|
*/
|
|
20
23
|
import { realpathSync, statSync } from 'fs';
|
|
21
24
|
import { readProcess } from '@commonlyai/seats/process-group';
|
|
@@ -99,15 +102,35 @@ const sizeOf = (file) => {
|
|
|
99
102
|
}
|
|
100
103
|
};
|
|
101
104
|
|
|
105
|
+
// A stream-json line is model progress when it is an event other than `system`.
|
|
106
|
+
// The `system` events are `init` (written before any model call) and
|
|
107
|
+
// `api_retry` (a provider failing and retrying). Counting either would keep a
|
|
108
|
+
// hung provider looking alive until the run timeout.
|
|
109
|
+
export const isModelProgressLine = (line) => {
|
|
110
|
+
try {
|
|
111
|
+
const event = JSON.parse(line);
|
|
112
|
+
return !!event && typeof event === 'object' && event.type !== 'system';
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
102
118
|
/**
|
|
103
119
|
* Watch `proc` for its first sign of life. Returns `{ stop }`; call it when the
|
|
104
120
|
* process closes. `onStall(reason)` fires at most once, after the caller should
|
|
105
121
|
* kill the process.
|
|
122
|
+
*
|
|
123
|
+
* With `modelStream`, the spawn writes stream-json events. Only model output
|
|
124
|
+
* counts as life there. A transcript row does not, because claude writes its
|
|
125
|
+
* first row about a second after spawn, before any model reply. Stderr does
|
|
126
|
+
* not either: claude prints warnings at spawn (an unrecognised model, a
|
|
127
|
+
* connector notice) whatever the provider does next.
|
|
106
128
|
*/
|
|
107
129
|
export const watchFirstOutput = ({
|
|
108
130
|
proc,
|
|
109
131
|
windowMs,
|
|
110
132
|
transcriptPath = null,
|
|
133
|
+
modelStream = false,
|
|
111
134
|
onStall,
|
|
112
135
|
lockProbe = consoleLocked,
|
|
113
136
|
setTimeoutImpl = setTimeout,
|
|
@@ -116,16 +139,32 @@ export const watchFirstOutput = ({
|
|
|
116
139
|
if (!windowMs) return { stop: () => {} };
|
|
117
140
|
let started = false;
|
|
118
141
|
let stopped = false;
|
|
142
|
+
let partial = '';
|
|
119
143
|
const markStarted = () => { started = true; };
|
|
120
|
-
|
|
121
|
-
|
|
144
|
+
if (modelStream) {
|
|
145
|
+
proc.stdout?.on('data', (chunk) => {
|
|
146
|
+
const lines = (partial + chunk.toString()).split('\n');
|
|
147
|
+
partial = lines.pop();
|
|
148
|
+
if (lines.some(isModelProgressLine)) markStarted();
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
proc.stdout?.on('data', markStarted);
|
|
152
|
+
proc.stderr?.on('data', markStarted);
|
|
153
|
+
}
|
|
122
154
|
// A missing file counts as empty, so creating an empty one is not growth.
|
|
123
155
|
const baseline = Math.max(0, sizeOf(transcriptPath));
|
|
124
156
|
const timer = setTimeoutImpl(async () => {
|
|
125
157
|
const size = sizeOf(transcriptPath);
|
|
126
|
-
if (started || size > baseline) return;
|
|
158
|
+
if (started || (!modelStream && size > baseline)) return;
|
|
127
159
|
const locked = await lockProbe().catch(() => null);
|
|
128
|
-
|
|
160
|
+
// Output can arrive while the probe runs (ps or ioreg may take a second).
|
|
161
|
+
if (stopped || started) return;
|
|
162
|
+
if (modelStream) {
|
|
163
|
+
// The model signal does not read the transcript, so a missing path cannot
|
|
164
|
+
// hide a hung provider here.
|
|
165
|
+
onStall(locked ? 'console locked' : 'stuck: no model output inside the window');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
129
168
|
// No transcript where one was expected may only mean the path is wrong, so
|
|
130
169
|
// that alone never kills a turn: it takes a locked console as well.
|
|
131
170
|
if (size < 0 && locked !== true) return;
|