@ctrl-spc/cs 0.7.14 → 0.7.16

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,49 +1,17 @@
1
- import { ensureAutostart } from './autostart.js';
2
- import { liveClient, startPresence, stopPresence } from './presence.js';
3
- /**
4
- * The terminal presence daemon (`cs start`). Comes online and heartbeats until
5
- * the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
6
- * restarted by the OS. The companion server (`cs open`) shares the same presence
7
- * loop via presence.ts, so the two front-ends never diverge.
8
- *
9
- * ═══ AND SINCE recovery-1 SLICE 4 IT ALSO ANSWERS AGENT PANEL CARDS. ═══ Lane's
10
- * ruling (2026-08-21): *the user experience must never require them to launch or
11
- * authenticate multiple CLIs. They launch the CLI with `cs start`.* Before this
12
- * the panel was a second binary with a second sign-in, so a stranded card could
13
- * name the right machine and print a command that started the wrong daemon.
14
- *
15
- * THE PANEL GETS THE SESSION THIS ALREADY HOLDS, never one of its own. Two
16
- * clients in one process is two refresh loops on one rotating refresh token,
17
- * both writing `session.json`.
18
- *
19
- * Companion and the terminal daemon both use the same presence lifecycle,
20
- * which owns the card worker, session and per-machine lock.
21
- */
1
+ import { inspectLocalRuntime, startLocalRuntime, stopLocalOwnerForSignal } from './daemon-lifecycle.js';
2
+ import { CLI_VERSION } from './package-version.js';
3
+ import { machineHostname } from './config.js';
4
+ /** Foreground entry; local lifecycle owns service control and cloud reconnects. */
22
5
  export async function runDaemon() {
23
- const { machineName, agents } = await startPresence();
24
- ensureAutostart(); // default-on: install the login item unless the user opted out
25
- console.log(`CTRL+SPC this computer: ${machineName}`);
26
- console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
27
- console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
28
- let stopping = false;
29
- async function shutdown(code = 0) {
30
- if (stopping)
31
- return;
32
- stopping = true;
33
- // Shared presence stops the card worker and both readiness records.
34
- await stopPresence();
35
- process.exit(code);
6
+ if (!await startLocalRuntime()) {
7
+ const current = await inspectLocalRuntime();
8
+ console.log(`CTRL+SPC is already running on ${machineHostname()} (pid ${current?.record.process.pid ?? 'unknown'}).`);
9
+ console.log(`Running version: ${current?.status?.version ?? 'unavailable'}. Installed version: ${CLI_VERSION}.`);
10
+ console.log('Use cs status to check it, cs stop to stop it, or cs restart to load this installed version.');
11
+ return;
36
12
  }
37
- process.on('SIGINT', () => void shutdown());
38
- process.on('SIGTERM', () => void shutdown());
39
- // Presence owns sign-out cleanup; the terminal entry also exits.
40
- const live = liveClient();
41
- if (!live)
42
- throw new Error('the presence loop is not running, so there is no session to watch');
43
- live.auth.onAuthStateChange((event) => {
44
- if (event === 'SIGNED_OUT')
45
- void shutdown(1);
46
- });
47
- // Keep the event loop alive.
48
- await new Promise(() => { });
13
+ console.log(`CTRL+SPC is running on ${machineHostname()}. Loaded version: ${CLI_VERSION}.`);
14
+ console.log('Cloud connection is being checked. Run cs status for readiness. Use cs stop or cs restart to manage this service.');
15
+ process.on('SIGINT', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
16
+ process.on('SIGTERM', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
49
17
  }
@@ -0,0 +1,340 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { createServer, createConnection } from 'node:net';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const execute = promisify(execFile);
9
+ const modulePath = fileURLToPath(import.meta.url);
10
+ const executable = join(dirname(modulePath), 'native', 'darwin-coalition');
11
+ export function validMacCoalition(value) {
12
+ const v = value;
13
+ return !!v && /^\d+$/.test(v.id) && /^[\da-f-]{36}$/i.test(v.boot) && /^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(v.label)
14
+ && (v.domain === undefined || v.domain === 'gui' || v.domain === 'user');
15
+ }
16
+ function timeout(deadline) {
17
+ if (Date.now() >= deadline)
18
+ throw new Error('Service operation exceeded its deadline.');
19
+ return Math.max(1, Math.min(5000, deadline - Date.now()));
20
+ }
21
+ async function native(args, deadline = Date.now() + 5000) {
22
+ for (;;) {
23
+ try {
24
+ const { stdout } = await execute(executable, args, { timeout: timeout(deadline), maxBuffer: 8 * 1024 * 1024 });
25
+ return stdout.trim() ? JSON.parse(stdout) : null;
26
+ }
27
+ catch (error) {
28
+ // A fork/exit between enumeration and the atomic count is a known,
29
+ // read-only race. Retry only this result within the caller's deadline.
30
+ if (error.code !== 75 || Date.now() + 25 >= deadline) {
31
+ // Preparation failures can become cloud-visible text. execFile's own
32
+ // message embeds local executable paths; the helper's fixed messages
33
+ // contain only the operation and OS error number.
34
+ const detail = error.stderr;
35
+ throw new Error(typeof detail === 'string' && detail.trim() ? detail.trim().slice(0, 500)
36
+ : 'Mac execution ownership could not be verified. Reinstall CTRL+SPC or retry the command.', { cause: error });
37
+ }
38
+ await new Promise((resolve) => setTimeout(resolve, 25));
39
+ }
40
+ }
41
+ }
42
+ async function processCoalition(pid, deadline) {
43
+ const value = await native(['process', String(pid)], deadline);
44
+ if (value === null)
45
+ return null;
46
+ if (!value || typeof value.id !== 'string' || !/^\d+$/.test(value.id) || typeof value.boot !== 'string' || !/^[\da-f-]{36}$/i.test(value.boot)
47
+ || value.pid !== pid || value.owner !== process.getuid())
48
+ throw new Error('Mac execution identity is unavailable.');
49
+ return value;
50
+ }
51
+ export async function inspectMacCoalition(coalition, deadline = Date.now() + 5000) {
52
+ if (!validMacCoalition(coalition))
53
+ throw new Error('Mac execution identity is invalid.');
54
+ const result = await native(['inspect', coalition.id, coalition.boot], deadline);
55
+ if (!result || typeof result.exists !== 'boolean' || !Number.isSafeInteger(result.active) || result.active < 0 || !Array.isArray(result.members))
56
+ throw new Error('Mac execution membership is unavailable.');
57
+ const rows = result.members.map((row) => nativeIdentity(row, coalition));
58
+ if (result.active > rows.length || (result.active === 0 && rows.length))
59
+ throw new Error('Mac execution membership changed. Retry the command.');
60
+ return rows;
61
+ }
62
+ function nativeIdentity(row, coalition) {
63
+ if (!row || !Number.isInteger(row.pid) || row.pid <= 0 || !Number.isInteger(row.ppid) || !Number.isInteger(row.pgid)
64
+ || row.owner !== String(process.getuid()) || typeof row.birth !== 'string' || !Number.isFinite(Date.parse(row.birth))
65
+ || !Number.isInteger(row.version) || row.version <= 0)
66
+ throw new Error('Mac process identity is invalid.');
67
+ return { pid: row.pid, ppid: row.ppid, pgid: row.pgid, owner: row.owner, birth: row.birth,
68
+ commandHash: createHash('sha256').update(`${row.pid}:${row.version}`).digest('hex'), macProcessVersion: row.version, ...(coalition ? { macCoalition: coalition } : {}) };
69
+ }
70
+ export async function inspectMacProcess(pid, deadline) {
71
+ const result = await native(['process', String(pid)], deadline);
72
+ if (result === null)
73
+ return null;
74
+ return nativeIdentity({ ...result, owner: String(result.owner) });
75
+ }
76
+ export async function inspectMacProcesses(deadline) {
77
+ const result = await native(['all'], deadline);
78
+ if (!result || !Array.isArray(result.members))
79
+ throw new Error('Mac process enumeration is unavailable.');
80
+ return result.members.map((row) => nativeIdentity(row));
81
+ }
82
+ export async function signalMacMember(identity, deadline, signal = 'KILL') {
83
+ if (!identity.macCoalition || !identity.macProcessVersion)
84
+ throw new Error('Mac process ownership is unavailable.');
85
+ await native(['signal', identity.macCoalition.id, identity.macCoalition.boot, String(identity.pid), String(identity.macProcessVersion), signal], deadline);
86
+ }
87
+ export async function signalMacProcess(identity, deadline) {
88
+ if (identity.owner !== String(process.getuid()))
89
+ throw new Error('Mac process belongs to another user.');
90
+ const own = await processCoalition(process.pid, deadline);
91
+ if (!own)
92
+ throw new Error('Mac process signaling is unavailable.');
93
+ await native(['signal-process', own.boot, String(identity.pid), identity.birth], deadline);
94
+ }
95
+ export async function unloadMacExecution(coalition, deadline) {
96
+ if (!validMacCoalition(coalition))
97
+ throw new Error('Mac execution identity is invalid.');
98
+ // Only the first unpublished candidate omitted this field and used gui.
99
+ // Never try both namespaces: the recorded one is the only owned job.
100
+ try {
101
+ await execute('/bin/launchctl', ['bootout', `${coalition.domain ?? 'gui'}/${process.getuid()}/${coalition.label}`], { timeout: timeout(deadline) });
102
+ }
103
+ catch (error) {
104
+ if (![3, 113].includes(error.code ?? -1))
105
+ throw new Error('The owned Mac execution job could not be unloaded.', { cause: error });
106
+ }
107
+ }
108
+ export function spawnMacExecution(bin, args, options, reservationId, file) {
109
+ if (!existsSync(executable))
110
+ throw new Error('The Mac execution helper is missing. Reinstall CTRL+SPC before starting work.');
111
+ if (!/^[\da-f-]{36}$/i.test(reservationId))
112
+ throw new Error('Invalid execution reservation.');
113
+ // A private short path avoids sockaddr_un's path limit for long config dirs.
114
+ const directory = mkdtempSync(`/tmp/ctrl-spc-execution-${process.getuid()}-`);
115
+ chmodSync(directory, 0o700);
116
+ const access = statSync(directory);
117
+ if (access.uid !== process.getuid() || (access.mode & 0o777) !== 0o700)
118
+ throw new Error('Mac execution transport could not be made private.');
119
+ const ready = join(directory, 'ready.json');
120
+ const launch = { bin, args, cwd: options.cwd?.toString() ?? process.cwd(), env: options.env ?? process.env,
121
+ shell: options.shell ?? false, label: `com.ctrl-spc.execution.${reservationId}`, domain: 'user', directory, ready };
122
+ try {
123
+ writeFileSync(file, JSON.stringify(launch), { flag: 'wx', mode: 0o600 });
124
+ return { child: spawn(process.execPath, [modulePath, '--bridge', file], { ...options, shell: false, detached: true }), ready };
125
+ }
126
+ catch (error) {
127
+ rmSync(directory, { recursive: true, force: true });
128
+ rmSync(file, { force: true });
129
+ throw error;
130
+ }
131
+ }
132
+ export function readMacExecution(ready) {
133
+ let text;
134
+ try {
135
+ text = readFileSync(ready, 'utf8');
136
+ }
137
+ catch (error) {
138
+ if (error.code === 'ENOENT')
139
+ return null;
140
+ throw error;
141
+ }
142
+ const result = JSON.parse(text);
143
+ if (!validMacCoalition(result))
144
+ throw new Error('Mac execution identity is invalid.');
145
+ return result;
146
+ }
147
+ export function removeMacExecutionFiles(ready) { rmSync(dirname(ready), { recursive: true, force: true }); }
148
+ function send(socket, frame, source) {
149
+ if (!socket.write(JSON.stringify(frame) + '\n') && source) {
150
+ source.pause();
151
+ socket.once('drain', () => source.resume());
152
+ }
153
+ }
154
+ function frames(socket, receive) {
155
+ let pending = '';
156
+ socket.setEncoding('utf8');
157
+ socket.on('data', (chunk) => {
158
+ pending += chunk;
159
+ if (pending.length > 2 * 1024 * 1024) {
160
+ socket.destroy(new Error('Mac execution transport exceeded its frame limit.'));
161
+ return;
162
+ }
163
+ let end;
164
+ while ((end = pending.indexOf('\n')) >= 0) {
165
+ const line = pending.slice(0, end);
166
+ pending = pending.slice(end + 1);
167
+ try {
168
+ receive(JSON.parse(line));
169
+ }
170
+ catch {
171
+ socket.destroy(new Error('Mac execution transport is invalid.'));
172
+ return;
173
+ }
174
+ }
175
+ });
176
+ }
177
+ function xml(text) { return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
178
+ async function bridge(file) {
179
+ const launch = JSON.parse(readFileSync(file, 'utf8'));
180
+ const own = await processCoalition(process.pid);
181
+ if (!own)
182
+ throw new Error('Mac execution capability is unavailable; no agent was started.');
183
+ const socketPath = join(launch.directory, 'io.sock'), plist = join(launch.directory, 'job.plist');
184
+ let socket = null, connected = false, ended = false, exitReceived = false;
185
+ let jobIdentity = null;
186
+ process.stdin.pause();
187
+ const server = createServer((peer) => {
188
+ if (connected) {
189
+ peer.destroy();
190
+ return;
191
+ }
192
+ connected = true;
193
+ socket = peer;
194
+ peer.on('error', () => { process.exitCode = 1; });
195
+ peer.on('close', () => {
196
+ if (!exitReceived)
197
+ process.exitCode = 1;
198
+ process.stdin.destroy();
199
+ if (!exitReceived)
200
+ server.close();
201
+ });
202
+ frames(peer, (frame) => {
203
+ if (frame.kind === 'hello' && typeof frame.pid === 'number') {
204
+ void (async () => {
205
+ const identity = await processCoalition(frame.pid);
206
+ if (!identity || identity.id === own.id)
207
+ throw new Error('The Mac execution job is not isolated.');
208
+ const coalition = { id: identity.id, boot: identity.boot, label: launch.label, domain: launch.domain };
209
+ const members = await inspectMacCoalition(coalition);
210
+ if (members.length !== 1 || members[0].pid !== frame.pid)
211
+ throw new Error('The Mac execution job is not exclusively owned.');
212
+ jobIdentity = members[0];
213
+ const temp = launch.ready + '.tmp';
214
+ writeFileSync(temp, JSON.stringify(coalition), { flag: 'wx', mode: 0o600 });
215
+ renameSync(temp, launch.ready);
216
+ process.stdin.on('data', (chunk) => send(peer, { kind: 'stdin', data: chunk.toString('base64') }, process.stdin));
217
+ process.stdin.on('end', () => { ended = true; send(peer, { kind: 'end' }); });
218
+ process.stdin.resume();
219
+ })().catch(() => { console.error('The Mac execution job could not be safely registered.'); process.exitCode = 1; peer.destroy(); });
220
+ }
221
+ else if ((frame.kind === 'stdout' || frame.kind === 'stderr') && typeof frame.data === 'string') {
222
+ const output = frame.kind === 'stdout' ? process.stdout : process.stderr;
223
+ if (!output.write(Buffer.from(frame.data, 'base64'))) {
224
+ peer.pause();
225
+ output.once('drain', () => peer.resume());
226
+ }
227
+ }
228
+ else if (frame.kind === 'exit' && typeof frame.code === 'number') {
229
+ exitReceived = true;
230
+ process.exitCode = frame.code;
231
+ peer.end();
232
+ process.stdin.destroy();
233
+ void (async () => {
234
+ const deadline = Date.now() + 5000;
235
+ while (jobIdentity && Date.now() < deadline) {
236
+ const current = await inspectMacProcess(jobIdentity.pid, deadline);
237
+ if (!current || current.birth !== jobIdentity.birth)
238
+ return;
239
+ await new Promise((resolve) => setTimeout(resolve, 10));
240
+ }
241
+ if (jobIdentity)
242
+ throw new Error('Mac execution helper did not close.');
243
+ })().catch(() => { process.exitCode = 1; }).finally(() => server.close());
244
+ }
245
+ else
246
+ throw new Error('Invalid execution transport frame.');
247
+ });
248
+ });
249
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
250
+ const content = '<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>' + launch.label + '</string>'
251
+ + '<key>ProgramArguments</key><array>' + [process.execPath, modulePath, '--job', file, launch.domain].map((value) => '<string>' + xml(value) + '</string>').join('') + '</array>'
252
+ + '<key>LimitLoadToSessionType</key><string>Background</string><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>AbandonProcessGroup</key><true/></dict></plist>';
253
+ writeFileSync(plist, content, { flag: 'wx', mode: 0o600 });
254
+ try {
255
+ await execute('/bin/launchctl', ['bootstrap', `${launch.domain}/${process.getuid()}`, plist], { timeout: 5000 });
256
+ }
257
+ catch (error) {
258
+ socket?.destroy();
259
+ server.close();
260
+ throw error;
261
+ }
262
+ // Only bootstrap/registration is bounded. Running agents have no timer.
263
+ const timer = setTimeout(() => { if (!existsSync(launch.ready)) {
264
+ console.error('The Mac execution job did not become ready.');
265
+ process.exitCode = 1;
266
+ socket?.destroy();
267
+ server.close();
268
+ } }, 5000);
269
+ timer.unref();
270
+ process.on('exit', () => { if (!ended)
271
+ process.stdin.destroy(); });
272
+ }
273
+ function job(file) {
274
+ const closeUnpromptedJob = (label, domain = process.argv[4] === 'user' ? 'user' : 'gui') => {
275
+ if (!label || !/^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(label))
276
+ return;
277
+ // The creator can disappear before this queued job even reads its launch
278
+ // file. No grant can then arrive; remove this exact one-shot job as well.
279
+ void execute('/bin/launchctl', ['bootout', `${domain}/${process.getuid()}/${label}`], { timeout: 5000 }).catch(() => { process.exitCode = 1; });
280
+ };
281
+ let launch;
282
+ try {
283
+ launch = JSON.parse(readFileSync(file, 'utf8'));
284
+ }
285
+ catch {
286
+ closeUnpromptedJob(process.env.XPC_SERVICE_NAME);
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ const socket = createConnection(join(launch.directory, 'io.sock'));
291
+ let provider = null, admitted = false;
292
+ socket.on('connect', () => send(socket, { kind: 'hello', pid: process.pid }));
293
+ socket.on('error', () => { if (!admitted)
294
+ process.exitCode = 1; });
295
+ socket.on('close', () => { if (!admitted)
296
+ closeUnpromptedJob(launch.label, launch.domain ?? 'gui'); });
297
+ frames(socket, (frame) => {
298
+ if (frame.kind === 'stdin' && typeof frame.data === 'string') {
299
+ let bytes = Buffer.from(frame.data, 'base64');
300
+ if (!admitted) {
301
+ if (!bytes.length || bytes[0] !== 1)
302
+ throw new Error('Invalid execution grant.');
303
+ admitted = true;
304
+ bytes = bytes.subarray(1);
305
+ provider = spawn(launch.bin, launch.args, { cwd: launch.cwd, env: launch.env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'] });
306
+ provider.stdin?.on('error', () => { });
307
+ provider.stdout?.on('data', (chunk) => { if (!socket.destroyed)
308
+ send(socket, { kind: 'stdout', data: chunk.toString('base64') }, provider.stdout); });
309
+ provider.stderr?.on('data', (chunk) => { if (!socket.destroyed)
310
+ send(socket, { kind: 'stderr', data: chunk.toString('base64') }, provider.stderr); });
311
+ provider.on('error', () => { if (!socket.destroyed)
312
+ send(socket, { kind: 'stderr', data: Buffer.from('The harness could not start.').toString('base64') }); });
313
+ provider.on('close', (code, signal) => { if (!socket.destroyed) {
314
+ send(socket, { kind: 'exit', code: code ?? 1, signal });
315
+ socket.end();
316
+ } });
317
+ }
318
+ if (bytes.length && !provider?.stdin?.write(bytes)) {
319
+ socket.pause();
320
+ provider?.stdin?.once('drain', () => socket.resume());
321
+ }
322
+ }
323
+ else if (frame.kind === 'end') {
324
+ if (provider)
325
+ provider.stdin?.end();
326
+ else {
327
+ send(socket, { kind: 'exit', code: 0 });
328
+ socket.end();
329
+ }
330
+ }
331
+ else
332
+ throw new Error('Invalid execution transport frame.');
333
+ });
334
+ }
335
+ if (process.argv[1] === modulePath) {
336
+ if (process.argv[2] === '--bridge')
337
+ void bridge(process.argv[3]).catch(() => { console.error('Mac safe execution is unavailable; no agent was started.'); process.exitCode = 1; });
338
+ else if (process.argv[2] === '--job')
339
+ job(process.argv[3]);
340
+ }
@@ -64,22 +64,8 @@ export function plainFailureReason(agent, error, stderr = '') {
64
64
  || haystack.includes('429') || haystack.includes('overloaded')) {
65
65
  return `${name}'s usage limit is in effect on this account, so the run could not start.`;
66
66
  }
67
- /* SIGNED OUT. Recoverable by the user, and silent otherwise: the run just
68
- fails, over and over, for as long as the session is stale.
69
-
70
- 18a SLICE 7 ADDED THE OAUTH AND 401 SIGNATURES, from the real thing rather
71
- than from imagination. The Windows machine's Claude Code had a stale token
72
- and said, on STDOUT as a stream-json result: `"error":
73
- "authentication_failed"` and `API Error: 401 OAuth access token has
74
- expired.` The word "authentication" above would have matched that, but only
75
- once stdout was being read at all, which is the other half of the fix. `401`
76
- and `oauth` are here so the same failure is caught when it arrives in
77
- shorter words. */
78
- if (haystack.includes('unauthorized') || haystack.includes('not logged in')
79
- || haystack.includes('authentication') || haystack.includes('oauth')
80
- || haystack.includes('401')) {
81
- return `${name} is not signed in on this machine.`;
82
- }
67
+ if (nativeFailureKind(stderr, error) === 'authentication')
68
+ return failureMessage(agent, 'authentication');
83
69
  /* THE RUN WAS KILLED, by the timeout or by a signal from outside. Not the
84
70
  user's Stop, which never reaches here: a stopped run is settled with its own
85
71
  flag well before this. */
@@ -96,3 +82,77 @@ export function plainFailureReason(agent, error, stderr = '') {
96
82
  }
97
83
  return `${name} stopped without finishing.`;
98
84
  }
85
+ /** Accept only native error records and verified diagnostic lines. Assistant
86
+ * prose, a quoted status code and credential-file presence prove nothing. */
87
+ export function nativeFailureKind(output, diagnostics = '') {
88
+ const classify = (value) => {
89
+ if (!value || typeof value !== 'object')
90
+ return 'unknown';
91
+ const record = value;
92
+ const nested = record.error;
93
+ const error = nested && typeof nested === 'object' ? nested : record;
94
+ const code = typeof error.code === 'string' ? error.code : typeof nested === 'string' ? nested : typeof error.type === 'string' ? error.type : '';
95
+ if (['authentication_error', 'authentication_failed', 'invalid_api_key', 'invalid_authentication', 'token_expired', 'refresh_token_expired', 'refresh_token_reused', 'refresh_token_invalidated'].includes(code))
96
+ return 'authentication';
97
+ if (['rate_limit_error', 'rate_limit_exceeded', 'usage_limit_reached', 'insufficient_quota'].includes(code))
98
+ return 'usage-limit';
99
+ if (code === 'invalid_request_error' && record.status === 400 && typeof error.message === 'string' && /^The requested model is not supported for this account\.?$/.test(error.message))
100
+ return 'invalid-model';
101
+ if (['model_not_found', 'unsupported_model', 'unrecognized_model'].includes(code))
102
+ return 'invalid-model';
103
+ if (['overloaded_error', 'server_error', 'temporarily_unavailable', 'connection_error'].includes(code))
104
+ return 'transient';
105
+ if (typeof error.message === 'string') {
106
+ if (error.message === 'Your access token could not be refreshed. Please log out and sign in again.')
107
+ return 'authentication';
108
+ try {
109
+ return classify(JSON.parse(error.message));
110
+ }
111
+ catch { /* Not a nested native record. */ }
112
+ }
113
+ return 'unknown';
114
+ };
115
+ for (const line of output.split('\n')) {
116
+ const text = line.trim();
117
+ try {
118
+ const event = JSON.parse(text);
119
+ // Ignore native assistant messages, tool results and their quoted prose.
120
+ if (['turn.failed', 'error'].includes(event?.type) || (event?.type === 'result' && event.is_error === true)) {
121
+ if (event.type === 'result' && (event.api_error_status === undefined || event.api_error_status === 401)
122
+ && event.result === 'Failed to authenticate. API Error: 401 Invalid bearer token')
123
+ return 'authentication';
124
+ if (event.type === 'result' && event.api_error_status === 401 && typeof event.result === 'string'
125
+ && /^Failed to authenticate\. API Error: 401 OAuth access token (?:has (?:expired|been revoked)|is invalid)\./.test(event.result))
126
+ return 'authentication';
127
+ const kind = classify(event);
128
+ if (kind !== 'unknown')
129
+ return kind;
130
+ }
131
+ }
132
+ catch { /* Native diagnostics may be non-JSON. */ }
133
+ }
134
+ for (const text of diagnostics.split('\n').map(line => line.trim())) {
135
+ if (/^API Error: (529|503) (Overloaded|Service unavailable)$/.test(text))
136
+ return 'transient';
137
+ if (/^API Error: 401 (?:OAuth access token (?:has (?:expired|been revoked)|is invalid)\.|.*"type"\s*:\s*"authentication_error")/.test(text)
138
+ || /^\[claude-code:authentication_failed\]/.test(text)
139
+ || /^ERROR: Your access token could not be refreshed because your refresh token was already used\./.test(text))
140
+ return 'authentication';
141
+ if (/^\[claude-code:unrecognized_model\]/.test(text))
142
+ return 'invalid-model';
143
+ }
144
+ return 'unknown';
145
+ }
146
+ export function failureMessage(agent, kind) {
147
+ const name = agentDisplayName(agent);
148
+ switch (kind) {
149
+ case 'authentication': return `${name} needs sign-in on its assigned computer. Saved work is waiting for you to restart this card.`;
150
+ case 'preparation-unavailable': return `${name}'s local credentials or execution configuration could not be checked.`;
151
+ case 'missing-binary': return `${name} is not installed on this machine, or is not on its PATH.`;
152
+ case 'timeout': return 'The run was stopped for taking too long.';
153
+ case 'usage-limit': return `${name}'s usage limit is in effect on this account.`;
154
+ case 'invalid-model': return `${name} rejected the selected model. Choose an available model and retry.`;
155
+ case 'transient': return `${name}'s service is temporarily unavailable.`;
156
+ default: return `${name} stopped without finishing.`;
157
+ }
158
+ }