totemlayer 0.1.4 → 0.1.5

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.
@@ -0,0 +1,659 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { parseArgs, promisify, stripVTControlCharacters } from "node:util";
6
+
7
+ import { readAgentCredential, readAgentProfile, saveAgentProfile } from "./agent-profile.mjs";
8
+ import { installTotemMcp, installManagedTotemMcp, inspectManagedTotemMcp } from "./mcp-install.mjs";
9
+ import { localModelEndpoint } from "./local-model-provider.mjs";
10
+ import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
11
+ import { inspectLocalRepository } from "./repository.mjs";
12
+ import { Totem } from "./sdk/index.js";
13
+
14
+ const execute = promisify(execFile);
15
+ const ORIGIN = "https://totemlayer.com";
16
+ const KEY_LIMIT = 8192;
17
+
18
+ export const AGENT_SETUP_HELP = `Connect your agent once
19
+
20
+ totem setup [--profile default] [--provider qwen-local|codex|claude]
21
+ [--key-file <private-file>] [--base-url <Totem-origin>] [--agent-id <expected-id>]
22
+ [--repo-root <checkout>]... [--model qwen3.5-9b]
23
+ [--qwen-url http://127.0.0.1:8087/v1] [--install-mcp] [--replace] [--json]
24
+ totem status [--profile default] [--json]
25
+
26
+ In a terminal, setup can ask for a hidden connection key. Otherwise use --key-file.
27
+ Keys are never accepted as command arguments. Repeating unchanged setup is safe.
28
+ --replace explicitly replaces this profile's connection settings and credential.
29
+ --repo-root approves a checkout for this profile; omit it for connection-only setup.
30
+ --install-mcp connects Codex or Claude using the saved private key-file path.
31
+ Setup does not download models or start workers. Codex/Claude MCP connections
32
+ provide tools; automatic Mission execution currently uses the local Qwen worker.
33
+ Status only reads readiness; it never claims work or sends a heartbeat.
34
+ `;
35
+
36
+ function fail(message, code) {
37
+ throw Object.assign(new Error(message), { setupSafe: true, ...(code ? { code } : {}) });
38
+ }
39
+
40
+ function parse(args, status = false) {
41
+ let parsed;
42
+ try {
43
+ parsed = parseArgs({
44
+ args,
45
+ strict: true,
46
+ allowPositionals: false,
47
+ options: {
48
+ help: { type: "boolean", short: "h" },
49
+ json: { type: "boolean" },
50
+ profile: { type: "string", default: "default" },
51
+ ...(!status && {
52
+ provider: { type: "string" },
53
+ "key-file": { type: "string" },
54
+ "base-url": { type: "string" },
55
+ "agent-id": { type: "string" },
56
+ "repo-root": { type: "string", multiple: true },
57
+ model: { type: "string" },
58
+ "qwen-url": { type: "string" },
59
+ "install-mcp": { type: "boolean" },
60
+ replace: { type: "boolean" },
61
+ }),
62
+ },
63
+ });
64
+ } catch {
65
+ fail("Unknown or incomplete option. Run totem setup --help. Never pass a key as an argument.");
66
+ }
67
+ const options = parsed.values;
68
+ if (
69
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(options.profile) ||
70
+ options.profile.length > 64 ||
71
+ /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/.test(options.profile)
72
+ )
73
+ fail("Choose a profile name using lowercase letters, numbers and single hyphens.");
74
+ if (
75
+ options["agent-id"] &&
76
+ !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(options["agent-id"])
77
+ )
78
+ fail("The expected agent ID must be a valid UUID.");
79
+ return options;
80
+ }
81
+
82
+ function origin(value) {
83
+ let url;
84
+ try {
85
+ url = new URL(value);
86
+ } catch {
87
+ fail("Choose a valid HTTPS Totem origin, or HTTP loopback for local development.");
88
+ }
89
+ if (
90
+ url.username ||
91
+ url.password ||
92
+ url.search ||
93
+ url.hash ||
94
+ url.pathname !== "/" ||
95
+ (url.protocol !== "https:" &&
96
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
97
+ )
98
+ fail("Choose a credential-free HTTPS Totem origin, or HTTP loopback for local development.");
99
+ return url.origin;
100
+ }
101
+
102
+ function credential(value) {
103
+ const key = typeof value === "string" ? value.trim() : "";
104
+ if (!key || Buffer.byteLength(key) > KEY_LIMIT || /\s|[\x00-\x1f\x7f]/.test(key))
105
+ fail("The connection key must be one nonempty value no larger than 8 KiB.");
106
+ return key;
107
+ }
108
+
109
+ async function keyFromFile(path) {
110
+ try {
111
+ const info = await stat(path);
112
+ if (!info.isFile() || !info.size || info.size > KEY_LIMIT)
113
+ fail("Use a nonempty regular key file no larger than 8 KiB.");
114
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
115
+ fail("The key file must be private. Set its permissions to 0600 before setup.");
116
+ return credential(await readFile(path, "utf8"));
117
+ } catch (error) {
118
+ if (error?.setupSafe) throw error;
119
+ fail("The key file could not be read. Check its path and permissions.");
120
+ }
121
+ }
122
+
123
+ async function ask(question, io) {
124
+ const reader = createInterface({ input: io.stdin, output: io.stderr });
125
+ try {
126
+ return (await reader.question(question)).trim();
127
+ } finally {
128
+ reader.close();
129
+ }
130
+ }
131
+
132
+ export async function readHiddenCredential({
133
+ stdin = process.stdin,
134
+ stderr = process.stderr,
135
+ } = {}) {
136
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function")
137
+ fail("Noninteractive setup requires --key-file with a private connection key file.");
138
+ stderr.write("Paste your Totem connection key (hidden): ");
139
+ const wasRaw = Boolean(stdin.isRaw);
140
+ const wasPaused = stdin.isPaused();
141
+ stdin.setRawMode(true);
142
+ stdin.resume();
143
+ try {
144
+ return await new Promise((resolveKey, reject) => {
145
+ let value = "";
146
+ const cleanup = () => {
147
+ stdin.removeListener("data", data);
148
+ stdin.removeListener("end", ended);
149
+ stdin.removeListener("error", ended);
150
+ };
151
+ const finish = (error) => {
152
+ cleanup();
153
+ if (error) reject(error);
154
+ else resolveKey(value);
155
+ };
156
+ const ended = () =>
157
+ finish(Object.assign(new Error("Key entry was canceled."), { setupSafe: true }));
158
+ const data = (chunk) => {
159
+ for (const character of chunk.toString("utf8")) {
160
+ if (character === "\u0003" || character === "\u0004") return ended();
161
+ if (character === "\r" || character === "\n") return finish();
162
+ if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
163
+ else value += character;
164
+ if (Buffer.byteLength(value) > KEY_LIMIT) return ended();
165
+ }
166
+ };
167
+ stdin.on("data", data);
168
+ stdin.once("end", ended);
169
+ stdin.once("error", ended);
170
+ });
171
+ } finally {
172
+ stdin.setRawMode(wasRaw);
173
+ if (wasPaused) stdin.pause();
174
+ stderr.write("\n");
175
+ }
176
+ }
177
+
178
+ function dependencies(options) {
179
+ return {
180
+ env: options.env ?? options.environment ?? process.env,
181
+ stdin: process.stdin,
182
+ stdout: process.stdout,
183
+ stderr: process.stderr,
184
+ createClient: (config) => new Totem({ ...config, timeoutMs: 5000, maxRetries: 0 }),
185
+ fetch: globalThis.fetch,
186
+ inspectRepository: inspectLocalRepository,
187
+ installMcp: installTotemMcp,
188
+ ask,
189
+ readSecret: readHiddenCredential,
190
+ runClient: async (client, args, environment) => {
191
+ const invocation =
192
+ process.platform === "win32"
193
+ ? installedClientInvocation(client, args, environment)
194
+ : { command: client, args };
195
+ return execute(invocation.command, invocation.args, {
196
+ env: localClientEnvironment(environment),
197
+ timeout: 5000,
198
+ maxBuffer: 64 * 1024,
199
+ encoding: "utf8",
200
+ });
201
+ },
202
+ ...options,
203
+ };
204
+ }
205
+
206
+ function safeOutput(value, secret) {
207
+ let text = typeof value === "string" ? value : JSON.stringify(value);
208
+ if (secret) text = text.split(secret).join("[REDACTED]");
209
+ return stripVTControlCharacters(text);
210
+ }
211
+
212
+ function identityError(error) {
213
+ return error?.status === 401 || error?.status === 403
214
+ ? "The connection key was rejected. For a saved profile, rerun setup with --replace to enter a new key, or use --replace --key-file with the replacement key."
215
+ : "Totem identity could not be verified. Check the origin, connection key, and server availability.";
216
+ }
217
+
218
+ async function verifiedIdentity(client) {
219
+ let identity;
220
+ try {
221
+ identity = await client.agent({ signal: AbortSignal.timeout(5000) });
222
+ } catch (error) {
223
+ fail(
224
+ identityError(error),
225
+ error?.status === 401 || error?.status === 403 ? "CONNECTION_KEY_REJECTED" : undefined,
226
+ );
227
+ }
228
+ if (
229
+ !identity ||
230
+ typeof identity.id !== "string" ||
231
+ !identity.id ||
232
+ typeof identity.address !== "string" ||
233
+ !/^[^\s@]+@[^\s@]+$/.test(identity.address) ||
234
+ !["SANDBOX", "PRODUCTION"].includes(identity.environment) ||
235
+ (identity.status && !["ACTIVE", "OFFLINE"].includes(identity.status))
236
+ )
237
+ fail("Totem did not return an active agent identity. Check this agent in Totem.");
238
+ return identity;
239
+ }
240
+
241
+ async function selectedRepositories(roots, previous, io) {
242
+ if (!roots) return previous?.repositoryRoots ?? [];
243
+ const repositories = [];
244
+ for (const value of roots) {
245
+ const root = resolve(value);
246
+ let info;
247
+ try {
248
+ info = await io.inspectRepository({ TOTEM_REPOSITORY_ROOT: root });
249
+ } catch {
250
+ fail("A selected checkout could not be verified. Check its Git origin and permissions.");
251
+ }
252
+ if (!info.clean || !info.currentBranch)
253
+ fail("Choose a clean checkout on a named branch before approving it for Mission work.");
254
+ if (repositories.some((entry) => entry.repositoryId === info.repository))
255
+ fail("Choose one checkout for each repository in this profile.");
256
+ repositories.push({ repositoryId: info.repository, root });
257
+ }
258
+ return repositories;
259
+ }
260
+
261
+ async function configureMcp(profile, previous, io) {
262
+ return installManagedTotemMcp(profile, previous, io.env, {
263
+ runClient: io.runClient,
264
+ install: io.installMcp,
265
+ });
266
+ }
267
+
268
+ export async function agentSetupMain(args, options = {}) {
269
+ const io = dependencies(options);
270
+ let key;
271
+ const json = args.includes("--json");
272
+ try {
273
+ const flags = parse(args);
274
+ if (flags.help) {
275
+ io.stdout.write(AGENT_SETUP_HELP);
276
+ return 0;
277
+ }
278
+ let previous;
279
+ try {
280
+ previous = await readAgentProfile(flags.profile, { environment: io.env });
281
+ } catch (error) {
282
+ if (!["ENOENT", "AGENT_PROFILE_NOT_FOUND"].includes(error?.code))
283
+ fail("The saved profile could not be read safely. Inspect it before replacing it.");
284
+ }
285
+ const interactive = Boolean(io.stdin.isTTY && !flags.json);
286
+ let mode = flags.provider ?? previous?.client ?? (previous ? "qwen-local" : undefined);
287
+ if (!mode && interactive) {
288
+ const choice = await io.ask("Connect with 1) local Qwen, 2) Codex, or 3) Claude [1]: ", io);
289
+ mode = { "": "qwen-local", 1: "qwen-local", 2: "codex", 3: "claude" }[choice] ?? choice;
290
+ }
291
+ mode ??= "qwen-local";
292
+ if (!["qwen-local", "codex", "claude"].includes(mode))
293
+ fail("Choose qwen-local, codex, or claude.");
294
+ if (flags["install-mcp"] && mode === "qwen-local")
295
+ fail("Choose Codex or Claude before using --install-mcp.");
296
+ let baseUrl = flags["base-url"] ?? previous?.baseUrl ?? io.env.TOTEM_BASE_URL;
297
+ if (!baseUrl && interactive) baseUrl = await io.ask(`Totem origin [${ORIGIN}]: `, io);
298
+ baseUrl = origin(baseUrl || ORIGIN);
299
+ const originChanged = previous && previous.baseUrl !== baseUrl;
300
+ if (originChanged && !flags.replace)
301
+ fail(
302
+ "This profile is bound to another origin. Choose a new profile or explicitly use --replace with a key for the new origin.",
303
+ );
304
+ let selectedRoots = flags["repo-root"];
305
+ if (!selectedRoots && !previous && interactive && mode === "qwen-local") {
306
+ const chosen = await io.ask(
307
+ "Checkout to approve for coding Missions (leave blank for connection only): ",
308
+ io,
309
+ );
310
+ if (chosen) selectedRoots = [chosen];
311
+ }
312
+ const qwenUrl = flags["qwen-url"] ?? previous?.qwenUrl ?? "http://127.0.0.1:8087/v1";
313
+ try {
314
+ localModelEndpoint("qwen-local", { qwenUrl });
315
+ } catch {
316
+ fail("Choose a credential-free loopback HTTP /v1 endpoint for Qwen.");
317
+ }
318
+ const model = flags.model ?? previous?.model ?? "qwen3.5-9b";
319
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model))
320
+ fail("Choose a valid installed local model name.");
321
+ const keyFile = flags["key-file"] ?? io.env.TOTEM_NETWORK_API_KEY_FILE;
322
+ if (keyFile) key = await keyFromFile(keyFile);
323
+ else if (previous && !originChanged) key = credential(await readAgentCredential(previous));
324
+ else {
325
+ if (!interactive)
326
+ fail("Noninteractive setup requires --key-file with a private connection key file.");
327
+ key = credential(await io.readSecret(io));
328
+ }
329
+ let identity;
330
+ try {
331
+ identity = await verifiedIdentity(io.createClient({ apiKey: key, baseUrl }));
332
+ } catch (error) {
333
+ if (
334
+ !interactive ||
335
+ keyFile ||
336
+ (previous && !flags.replace) ||
337
+ error?.code !== "CONNECTION_KEY_REJECTED"
338
+ )
339
+ throw error;
340
+ io.stderr.write("The key was rejected. Enter a replacement key for this profile.\n");
341
+ key = credential(await io.readSecret(io));
342
+ identity = await verifiedIdentity(io.createClient({ apiKey: key, baseUrl }));
343
+ }
344
+ if (flags["agent-id"] && identity.id !== flags["agent-id"])
345
+ fail(
346
+ "This key belongs to a different agent than the selected agent. Use that agent's own connection key.",
347
+ );
348
+ if (
349
+ previous &&
350
+ !flags.replace &&
351
+ (previous.agentId !== identity.id ||
352
+ previous.baseUrl !== baseUrl ||
353
+ previous.address !== identity.address ||
354
+ previous.environment !== identity.environment)
355
+ )
356
+ fail(
357
+ "This profile is bound to another agent or origin. Choose a new profile or explicitly use --replace.",
358
+ );
359
+ const repositoryRoots = await selectedRepositories(selectedRoots, previous, io);
360
+ let profile;
361
+ try {
362
+ profile = await saveAgentProfile(
363
+ flags.profile,
364
+ {
365
+ baseUrl,
366
+ agentId: identity.id,
367
+ address: identity.address,
368
+ environment: identity.environment,
369
+ provider: "qwen-local",
370
+ model,
371
+ qwenUrl,
372
+ repositoryRoots,
373
+ ...(mode === "qwen-local" ? {} : { client: mode }),
374
+ },
375
+ key,
376
+ { environment: io.env, replace: flags.replace === true },
377
+ );
378
+ } catch (error) {
379
+ if (error?.code === "AGENT_PROFILE_RUNNING")
380
+ fail(
381
+ "Stop this agent before replacing its connection or settings. Run totem stop for this profile and wait for it to stop.",
382
+ );
383
+ if (error?.code === "AGENT_PROFILE_BUSY")
384
+ fail(
385
+ "Another setup or worker start may be updating this profile. Wait for it to finish. If this persists after those processes have stopped, inspect this profile's private profile.lock file before recovery. The lock is never cleared automatically.",
386
+ );
387
+ fail(
388
+ "The profile could not be saved safely. If its settings changed, choose a new profile or explicitly use --replace.",
389
+ );
390
+ }
391
+ let install = Boolean(flags["install-mcp"]);
392
+ if (profile.client && interactive && !install && previous?.client !== profile.client)
393
+ install = /^(y|yes)$/i.test(
394
+ await io.ask(`Add the saved connection to ${profile.client}? [y/N]: `, io),
395
+ );
396
+ const mcp = install ? await configureMcp(profile, previous, io) : undefined;
397
+ const next = profile.client
398
+ ? "Use totem_get_identity in your AI app. MCP tools alone do not execute Missions; automatic Missions currently need the local Qwen worker."
399
+ : repositoryRoots.length
400
+ ? `Run totem start --profile ${profile.profileName} to keep this agent available for assigned Missions.`
401
+ : `Run totem start --profile ${profile.profileName} for approved Missions. Coding Missions also need a checkout: run setup with an explicit --repo-root and --replace before assigning code work.`;
402
+ const report = {
403
+ ok: !mcp || mcp.state !== "needs_attention",
404
+ profile: profile.profileName,
405
+ agent: identity.address,
406
+ origin: baseUrl,
407
+ connection: "verified",
408
+ mode,
409
+ approvedRepositories: repositoryRoots.length,
410
+ worker: "not_started_by_setup",
411
+ ...(mcp ? { mcp } : {}),
412
+ next,
413
+ };
414
+ io.stdout.write(
415
+ safeOutput(
416
+ flags.json
417
+ ? report
418
+ : `Connected ${identity.address} at ${baseUrl}.\n${mcp ? `${mcp.message}\n` : ""}${next}\n`,
419
+ key,
420
+ ) + (flags.json ? "\n" : ""),
421
+ );
422
+ return report.ok ? 0 : 1;
423
+ } catch (error) {
424
+ const message = error?.setupSafe
425
+ ? error.message
426
+ : "Setup could not complete safely. Check the saved profile, private key file, and connection settings.";
427
+ const output = json ? { ok: false, error: { code: "SETUP_FAILED", message } } : `${message}\n`;
428
+ (json ? io.stdout : io.stderr).write(safeOutput(output, key) + (json ? "\n" : ""));
429
+ return 1;
430
+ }
431
+ }
432
+
433
+ async function modelReadiness(profile, io) {
434
+ try {
435
+ localModelEndpoint("qwen-local", { qwenUrl: profile.qwenUrl });
436
+ const response = await io.fetch(`${profile.qwenUrl.replace(/\/$/, "")}/models`, {
437
+ method: "GET",
438
+ redirect: "error",
439
+ signal: AbortSignal.timeout(5000),
440
+ });
441
+ if (!response.ok) throw new Error();
442
+ const reader = response.body?.getReader();
443
+ if (!reader) throw new Error();
444
+ const chunks = [];
445
+ let length = 0;
446
+ try {
447
+ while (true) {
448
+ const { done, value } = await reader.read();
449
+ if (done) break;
450
+ length += value.byteLength;
451
+ if (length > 256 * 1024) throw new Error();
452
+ chunks.push(Buffer.from(value));
453
+ }
454
+ } finally {
455
+ await reader.cancel().catch(() => {});
456
+ }
457
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
458
+ if (!Array.isArray(body.data) || !body.data.some((entry) => entry?.id === profile.model))
459
+ return {
460
+ state: "model_missing",
461
+ message:
462
+ "The model server is reachable but the configured model is not listed. Start it with the saved model alias.",
463
+ };
464
+ return {
465
+ state: "available",
466
+ message: "The local server lists the configured model. No generation was requested.",
467
+ };
468
+ } catch {
469
+ return {
470
+ state: "unavailable",
471
+ message:
472
+ "Start the local Qwen server at the saved endpoint, then check status again. No model is downloaded automatically.",
473
+ };
474
+ }
475
+ }
476
+
477
+ export async function agentStatusMain(args, options = {}) {
478
+ const io = dependencies(options);
479
+ let key;
480
+ const json = args.includes("--json");
481
+ try {
482
+ const flags = parse(args, true);
483
+ if (flags.help) {
484
+ io.stdout.write(AGENT_SETUP_HELP);
485
+ return 0;
486
+ }
487
+ const profile = await readAgentProfile(flags.profile, { environment: io.env });
488
+ key = await readAgentCredential(profile);
489
+ const checks = {};
490
+ try {
491
+ const identity = await verifiedIdentity(
492
+ io.createClient({ apiKey: key, baseUrl: profile.baseUrl }),
493
+ );
494
+ if (
495
+ identity.id !== profile.agentId ||
496
+ identity.address !== profile.address ||
497
+ identity.environment !== profile.environment
498
+ )
499
+ fail(
500
+ "The saved key no longer matches this profile's agent. Run setup with a separate profile or explicitly replace it.",
501
+ );
502
+ checks.credential = {
503
+ state: "verified",
504
+ message: "The saved key identifies the expected agent.",
505
+ };
506
+ } catch (error) {
507
+ checks.credential = {
508
+ state: "failed",
509
+ message: error?.setupSafe
510
+ ? error.message
511
+ : "The saved agent identity could not be verified.",
512
+ };
513
+ }
514
+ checks.repositories = {
515
+ state: profile.repositoryRoots.length ? "verified" : "not_configured",
516
+ message: profile.repositoryRoots.length
517
+ ? "Approved repository origins match their saved checkouts."
518
+ : "Connection-only profile. Add an explicit --repo-root with setup before coding Missions.",
519
+ };
520
+ for (const approved of profile.repositoryRoots) {
521
+ try {
522
+ const local = await io.inspectRepository({ TOTEM_REPOSITORY_ROOT: approved.root });
523
+ if (local.repository !== approved.repositoryId) throw new Error();
524
+ if (!local.clean || !local.currentBranch)
525
+ checks.repositories = {
526
+ state: "needs_attention",
527
+ message:
528
+ "An approved checkout is dirty or detached. Inspect it before Mission work; status will not reset it.",
529
+ };
530
+ } catch {
531
+ checks.repositories = {
532
+ state: "mismatch",
533
+ message:
534
+ "An approved checkout is missing or its origin changed. Inspect it and update setup explicitly.",
535
+ };
536
+ break;
537
+ }
538
+ }
539
+ checks.model = profile.client
540
+ ? {
541
+ state: "not_required",
542
+ message:
543
+ "Your AI app supplies its own model. This MCP profile does not enable automatic Mission execution.",
544
+ }
545
+ : await modelReadiness(profile, io);
546
+ if (profile.client) {
547
+ try {
548
+ await io.runClient(profile.client, ["--version"], io.env);
549
+ checks.mcp = await inspectManagedTotemMcp(profile, io.env, { runClient: io.runClient });
550
+ } catch {
551
+ checks.mcp = {
552
+ state: "client_missing",
553
+ message:
554
+ "Install and sign in to the selected AI client, then run setup with --install-mcp.",
555
+ };
556
+ }
557
+ }
558
+ checks.worker = profile.client
559
+ ? {
560
+ state: "not_supported",
561
+ message:
562
+ "This profile connects an AI app through MCP. Use a separate local Qwen profile for automatic Mission execution.",
563
+ }
564
+ : {
565
+ state: "not_checked",
566
+ message:
567
+ "A verified connection does not prove a worker is running. Use totem start for this profile; status has not started a worker.",
568
+ };
569
+ if (io.readRuntimeStatus && !profile.client) {
570
+ try {
571
+ const worker = await io.readRuntimeStatus(profile);
572
+ const phases = [
573
+ "starting",
574
+ "connected",
575
+ "working",
576
+ "waiting",
577
+ "blocked",
578
+ "stopped",
579
+ "failed",
580
+ "not_started",
581
+ "interrupted",
582
+ "unresponsive",
583
+ "reconnecting",
584
+ "stopping",
585
+ ];
586
+ const phase = phases.includes(worker?.phase) ? worker.phase : undefined;
587
+ const state = phase
588
+ ? worker.running === true && ["connected", "working", "waiting"].includes(phase)
589
+ ? "running"
590
+ : phase === "starting" && worker.running === true
591
+ ? "starting"
592
+ : ["stopped", "not_started"].includes(phase)
593
+ ? phase
594
+ : "needs_attention"
595
+ : ["running", "stopped", "starting", "needs_attention", "not_started"].includes(
596
+ worker?.state,
597
+ )
598
+ ? worker.state
599
+ : "needs_attention";
600
+ checks.worker = {
601
+ state,
602
+ ...(phase ? { phase } : {}),
603
+ message:
604
+ state === "running"
605
+ ? "The local Mission worker reports a current process heartbeat and a connected work phase."
606
+ : phase === "reconnecting"
607
+ ? "The worker is reconnecting to Totem. Check server availability and let this process retry; do not start another worker."
608
+ : phase === "stopping"
609
+ ? "The worker is stopping and preserving progress. Wait for it to stop before changing setup or restarting."
610
+ : phase === "blocked"
611
+ ? "The running worker has a Mission blocker. Open Missions to resolve it; starting another worker will not resolve the blocker."
612
+ : phase === "unresponsive"
613
+ ? "The worker process is present but its status is stale. Inspect or stop it before starting another worker."
614
+ : "The local worker is not confirmed ready. Check it with totem start for this profile.",
615
+ };
616
+ } catch {
617
+ checks.worker = {
618
+ state: "needs_attention",
619
+ message: "The local worker status could not be read safely.",
620
+ };
621
+ }
622
+ }
623
+ const ok = checks.credential.state === "verified";
624
+ const missionsReady =
625
+ !profile.client &&
626
+ ok &&
627
+ checks.model.state === "available" &&
628
+ checks.worker.state === "running";
629
+ const report = {
630
+ ok,
631
+ profile: profile.profileName,
632
+ agent: profile.address,
633
+ origin: profile.baseUrl,
634
+ checks,
635
+ missionsReady,
636
+ codingReady: missionsReady && checks.repositories.state === "verified",
637
+ };
638
+ const output = flags.json
639
+ ? report
640
+ : `${profile.address} at ${profile.baseUrl}\n${Object.entries(checks)
641
+ .map(([name, check]) => `${name}: ${check.state}. ${check.message}`)
642
+ .join("\n")}\n`;
643
+ io.stdout.write(safeOutput(output, key) + (flags.json ? "\n" : ""));
644
+ return ok ? 0 : 1;
645
+ } catch (error) {
646
+ const message = error?.setupSafe
647
+ ? error.message
648
+ : "The profile or its private key could not be read safely. Run totem setup to connect this agent.";
649
+ (json ? io.stdout : io.stderr).write(
650
+ safeOutput(
651
+ json ? { ok: false, error: { code: "STATUS_FAILED", message } } : `${message}\n`,
652
+ key,
653
+ ) + (json ? "\n" : ""),
654
+ );
655
+ return 1;
656
+ }
657
+ }
658
+
659
+ export const agentDoctorMain = agentStatusMain;