totemlayer 0.1.30 → 0.1.37

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.
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
8
8
  import { runtimeRoot } from "./runtime-installation.mjs";
9
+ import { loadMcpIdentityBinding } from "./mcp-identity-binding.mjs";
9
10
 
10
11
  const DEFAULT_ORIGIN = "https://totemlayer.com";
11
12
  const DEFAULT_NAME = "totem-local";
@@ -14,6 +15,7 @@ const SERVER_ENTRY = fileURLToPath(new URL("./totem.mjs", import.meta.url));
14
15
  export const MCP_INSTALL_HELP = `Connect Totem to an AI app
15
16
 
16
17
  Usage:
18
+ totem mcp install generic --key-file /absolute/path/to/agent.key
17
19
  totem mcp install codex --key-file /absolute/path/to/agent.key
18
20
  totem mcp install claude --key-file /absolute/path/to/agent.key
19
21
 
@@ -21,13 +23,25 @@ Options:
21
23
  --base-url <origin> Totem origin (default: https://totemlayer.com)
22
24
  --name <name> MCP server name (default: totem-local)
23
25
  --key-file <path> Existing private agent key file (required)
26
+ --agent-id <UUID> Require this credential-derived agent identity
27
+ --environment <env> Require PRODUCTION or SANDBOX
28
+ --codex-thread <ID> Bind a new Codex listener to this owner-selected chat
29
+ --no-start Configure tools only; do not start a local listener
24
30
  --help Show this guide
25
31
 
26
32
  This installs a user-level local stdio MCP connection. It never puts the key
27
33
  value in a command, client config, or repository. It will not overwrite an
28
34
  existing MCP server. Restart the AI app afterward and ask for totem_get_identity.
35
+ Use --agent-id and --environment to reject an unintended agent or environment.
36
+ Codex/Claude installation also prepares this exact identity's supervised listener
37
+ for login and crash recovery. Existing stops and owner permissions remain in force.
29
38
  On macOS/Linux the key file must be mode 0600. On Windows, protect it with
30
39
  your user's filesystem ACL. The package must remain installed.
40
+
41
+ Use generic for any stdio MCP client: it prints JSON configuration without
42
+ changing client settings or starting a background listener. Adapt the outer
43
+ configuration wrapper to your client's schema. For hosted Streamable HTTP,
44
+ connect directly to https://totemlayer.com/mcp using OAuth or a Bearer agent key.
31
45
  `;
32
46
 
33
47
  function fail(message) {
@@ -37,14 +51,29 @@ function fail(message) {
37
51
  function parseInstallArgs(args, environment) {
38
52
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { help: true };
39
53
  const [client, ...rest] = args;
40
- if (client !== "codex" && client !== "claude")
41
- fail("Choose codex or claude. Run `totem mcp install --help` for examples.");
54
+ if (!["generic", "codex", "claude"].includes(client))
55
+ fail(
56
+ "Choose generic for any stdio MCP client, or codex or claude for automatic configuration. Run `totem mcp install --help` for examples.",
57
+ );
42
58
  const values = {};
43
59
  for (let index = 0; index < rest.length; index += 2) {
44
60
  const option = rest[index];
61
+ if (option === "--no-start") {
62
+ if (values[option]) fail("--no-start was provided more than once.");
63
+ values[option] = true;
64
+ index -= 1;
65
+ continue;
66
+ }
45
67
  const value = rest[index + 1];
46
68
  if (
47
- !["--key-file", "--base-url", "--name"].includes(option) ||
69
+ ![
70
+ "--key-file",
71
+ "--base-url",
72
+ "--name",
73
+ "--agent-id",
74
+ "--environment",
75
+ "--codex-thread",
76
+ ].includes(option) ||
48
77
  !value ||
49
78
  value.startsWith("--")
50
79
  )
@@ -78,7 +107,41 @@ function parseInstallArgs(args, environment) {
78
107
  fail(
79
108
  "--base-url must be a credential-free HTTPS origin (or HTTP loopback for local development).",
80
109
  );
81
- return { client, keyFile: resolve(keyFile), name, baseUrl: url.origin };
110
+ if (
111
+ values["--codex-thread"] &&
112
+ (client !== "codex" ||
113
+ !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(values["--codex-thread"]))
114
+ )
115
+ fail("--codex-thread requires codex and an owner-selected chat UUID.");
116
+ return {
117
+ client,
118
+ codexThreadId: values["--codex-thread"],
119
+ configDirectory: environment.TOTEM_CONFIG_DIR ? runtimeRoot(environment) : undefined,
120
+ noStart: values["--no-start"] === true || environment.TOTEM_AUTO_START === "0",
121
+ keyFile: resolve(keyFile),
122
+ name,
123
+ baseUrl: url.origin,
124
+ ...loadMcpIdentityBinding({
125
+ TOTEM_EXPECTED_AGENT_ID: values["--agent-id"] ?? environment.TOTEM_EXPECTED_AGENT_ID,
126
+ TOTEM_EXPECTED_ENVIRONMENT: values["--environment"] ?? environment.TOTEM_EXPECTED_ENVIRONMENT,
127
+ }),
128
+ };
129
+ }
130
+
131
+ function identityEnvironment(binding) {
132
+ return {
133
+ ...(binding.expectedAgentId ? { TOTEM_EXPECTED_AGENT_ID: binding.expectedAgentId } : {}),
134
+ ...(binding.expectedEnvironment
135
+ ? { TOTEM_EXPECTED_ENVIRONMENT: binding.expectedEnvironment }
136
+ : {}),
137
+ };
138
+ }
139
+
140
+ function managedIdentityBinding(profile) {
141
+ return loadMcpIdentityBinding({
142
+ TOTEM_EXPECTED_AGENT_ID: profile.agentId,
143
+ TOTEM_EXPECTED_ENVIRONMENT: profile.environment,
144
+ });
82
145
  }
83
146
 
84
147
  async function validateKeyFile(path, platform) {
@@ -114,22 +177,68 @@ export async function installTotemMcp(args, environment = process.env, runtime =
114
177
  runtime = { platform: process.platform, spawnProcess: spawnSync, ...runtime };
115
178
  await validateKeyFile(options.keyFile, runtime.platform);
116
179
 
180
+ if (options.client === "generic")
181
+ return (
182
+ JSON.stringify(
183
+ {
184
+ mcpServers: {
185
+ [options.name]: {
186
+ command: process.execPath,
187
+ args: [runtime.serverEntry ?? SERVER_ENTRY, "mcp"],
188
+ env: {
189
+ TOTEM_BASE_URL: options.baseUrl,
190
+ TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
191
+ ...identityEnvironment(options),
192
+ ...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}),
193
+ ...(options.configDirectory ? { TOTEM_CONFIG_DIR: options.configDirectory } : {}),
194
+ },
195
+ },
196
+ },
197
+ },
198
+ null,
199
+ 2,
200
+ ) + "\n"
201
+ );
202
+
117
203
  const command = options.client;
118
- const existing = runClient(command, ["mcp", "get", options.name], environment, runtime);
119
- if (existing.status === 0)
204
+ const getArgs = ["mcp", "get", options.name, ...(command === "codex" ? ["--json"] : [])];
205
+ const existing = runClient(command, getArgs, environment, runtime);
206
+ let sameEntry = false;
207
+ if (existing.status === 0 && command === "codex") {
208
+ try {
209
+ sameEntry = matchesManagedEntry(
210
+ JSON.parse(existing.stdout),
211
+ {
212
+ baseUrl: options.baseUrl,
213
+ credentialFile: options.keyFile,
214
+ agentId: options.expectedAgentId,
215
+ environment: options.expectedEnvironment,
216
+ noStart: options.noStart,
217
+ configDirectory: options.configDirectory,
218
+ },
219
+ options.name,
220
+ runtime.serverEntry ?? SERVER_ENTRY,
221
+ );
222
+ } catch {}
223
+ }
224
+ if (existing.status === 0 && !sameEntry)
120
225
  fail(
121
226
  `${options.name} already exists in ${command}. No configuration was changed. Choose another --name or inspect the existing connection first.`,
122
227
  );
123
- if (!/no mcp server|not found|does not exist/i.test(`${existing.stderr}\n${existing.stdout}`))
228
+ if (
229
+ !sameEntry &&
230
+ !/no mcp server|not found|does not exist/i.test(`${existing.stderr}\n${existing.stdout}`)
231
+ )
124
232
  fail(
125
233
  `${command} could not check whether ${options.name} already exists. No configuration was changed.`,
126
234
  );
127
- const envArgs = [
128
- "--env",
129
- `TOTEM_BASE_URL=${options.baseUrl}`,
130
- "--env",
131
- `TOTEM_NETWORK_API_KEY_FILE=${options.keyFile}`,
132
- ];
235
+ const envArgs = Object.entries({
236
+ TOTEM_BASE_URL: options.baseUrl,
237
+ TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
238
+ ...identityEnvironment(options),
239
+ ...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}),
240
+ ...(options.configDirectory ? { TOTEM_CONFIG_DIR: options.configDirectory } : {}),
241
+ }).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
133
242
  const addArgs =
134
243
  options.client === "codex"
135
244
  ? [
@@ -156,17 +265,48 @@ export async function installTotemMcp(args, environment = process.env, runtime =
156
265
  runtime.serverEntry ?? SERVER_ENTRY,
157
266
  "mcp",
158
267
  ];
159
- const added = runClient(command, addArgs, environment, runtime);
268
+ const added = sameEntry ? { status: 0 } : runClient(command, addArgs, environment, runtime);
160
269
  if (added.status !== 0)
161
270
  fail(
162
271
  `${command} could not add ${options.name}. No Totem key value was sent. Check the client's MCP setup and retry.`,
163
272
  );
164
- const verified = runClient(command, ["mcp", "get", options.name], environment, runtime);
273
+ const verified = sameEntry ? existing : runClient(command, getArgs, environment, runtime);
165
274
  if (verified.status !== 0)
166
275
  fail(
167
276
  `${command} reported an install, but ${options.name} could not be verified. Inspect its MCP settings before using it.`,
168
277
  );
169
- return `Connected ${options.name} to ${command} at ${options.baseUrl}. Restart ${command}, then ask it to call totem_get_identity. The Totem CLI package must remain installed.\n`;
278
+ let listener = "";
279
+ if (!runtime.skipListenerSetup) {
280
+ try {
281
+ const config = await (runtime.loadConfig ?? (await import("./mcp.mjs")).loadTotemMcpConfig)({
282
+ TOTEM_BASE_URL: options.baseUrl,
283
+ TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
284
+ ...identityEnvironment(options),
285
+ });
286
+ const status = await (
287
+ runtime.prepareListener ?? (await import("./mcp-runtime.mjs")).ensureMcpListener
288
+ )(
289
+ config,
290
+ { ...environment, ...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}) },
291
+ {
292
+ allowCreate: true,
293
+ provider: options.client,
294
+ keyFile: options.keyFile,
295
+ codexThreadId: options.codexThreadId,
296
+ },
297
+ );
298
+ listener = ` Listener: ${status.state}${status.persistent === false ? " (login recovery is unavailable on this host)" : ""}.`;
299
+ if (["needs_attention", "setup_required"].includes(status.state))
300
+ fail(
301
+ "MCP settings were saved, but the listener needs attention. Run totem status or totem setup with this same agent's private key; no existing identity or permission was replaced.",
302
+ );
303
+ } catch {
304
+ fail(
305
+ "MCP settings were saved, but the listener could not confirm setup. Run totem status or totem setup with this same agent's private key; no existing identity or permission was replaced.",
306
+ );
307
+ }
308
+ }
309
+ return `Connected ${options.name} to ${command} at ${options.baseUrl}.${listener} Restart ${command}, then ask it to call totem_get_identity. The Totem CLI package must remain installed.\n`;
170
310
  }
171
311
 
172
312
  /** Guided setup owns a distinct entry per profile; the manual installer retains its default. */
@@ -179,7 +319,7 @@ export function managedMcpName(profileName) {
179
319
  : `totem_${profileName.slice(0, 24)}_${createHash("sha256").update(profileName).digest("hex").slice(0, 32)}`;
180
320
  }
181
321
 
182
- async function installedServerEntry(profile, environment) {
322
+ async function installedServerEntry(profile) {
183
323
  if (!profile.stateDirectory) return SERVER_ENTRY;
184
324
  const path = join(resolve(profile.stateDirectory, "../../../.."), "runtime", "totem.mjs");
185
325
  try {
@@ -199,12 +339,11 @@ async function installedServerEntry(profile, environment) {
199
339
  }
200
340
 
201
341
  function managedArgs(profile, name, entry = SERVER_ENTRY) {
202
- const variables = [
203
- "--env",
204
- `TOTEM_BASE_URL=${profile.baseUrl}`,
205
- "--env",
206
- `TOTEM_NETWORK_API_KEY_FILE=${profile.credentialFile}`,
207
- ];
342
+ const variables = Object.entries({
343
+ TOTEM_BASE_URL: profile.baseUrl,
344
+ TOTEM_NETWORK_API_KEY_FILE: profile.credentialFile,
345
+ ...identityEnvironment(managedIdentityBinding(profile)),
346
+ }).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
208
347
  return profile.client === "codex"
209
348
  ? ["mcp", "add", name, ...variables, "--", process.execPath, entry, "mcp"]
210
349
  : [
@@ -228,6 +367,9 @@ function matchesManagedEntry(entry, profile, name, serverEntry = SERVER_ENTRY) {
228
367
  const expectedEnvironment = {
229
368
  TOTEM_BASE_URL: profile.baseUrl,
230
369
  TOTEM_NETWORK_API_KEY_FILE: profile.credentialFile,
370
+ ...identityEnvironment(managedIdentityBinding(profile)),
371
+ ...(profile.noStart ? { TOTEM_AUTO_START: "0" } : {}),
372
+ ...(profile.configDirectory ? { TOTEM_CONFIG_DIR: profile.configDirectory } : {}),
231
373
  };
232
374
  return (
233
375
  entry?.name === name &&
@@ -243,7 +385,7 @@ function matchesManagedEntry(entry, profile, name, serverEntry = SERVER_ENTRY) {
243
385
  (transport.env_vars == null ||
244
386
  (Array.isArray(transport.env_vars) && transport.env_vars.length === 0)) &&
245
387
  transport.env &&
246
- Object.keys(transport.env).length === 2 &&
388
+ Object.keys(transport.env).length === Object.keys(expectedEnvironment).length &&
247
389
  Object.entries(expectedEnvironment).every(([key, value]) => transport.env[key] === value) &&
248
390
  Object.keys(transport).every((key) =>
249
391
  ["type", "command", "args", "env", "env_vars", "cwd"].includes(key),
@@ -307,8 +449,23 @@ function reconnectAdvice(profile, name) {
307
449
  return `Inspect ${name} with \`${profile.client} mcp get ${name}\`. To replace that exact entry after reviewing it, run \`${profile.client} mcp remove ${name}\`, then \`totem setup --profile ${profile.profileName} --install-mcp\`. Other entries are preserved.`;
308
450
  }
309
451
 
452
+ function missingBindingAdvice(entry, profile, name, serverEntry) {
453
+ const binding = managedIdentityBinding(profile);
454
+ if (
455
+ (binding.expectedAgentId || binding.expectedEnvironment) &&
456
+ matchesManagedEntry(
457
+ entry,
458
+ { ...profile, agentId: undefined, environment: undefined },
459
+ name,
460
+ serverEntry,
461
+ )
462
+ )
463
+ return `The existing entry matches this profile's key reference and origin, but lacks its expected identity binding${binding.expectedAgentId ? ` for agent ${binding.expectedAgentId}` : ""}${binding.expectedEnvironment ? ` in ${binding.expectedEnvironment}` : ""}. It was preserved; review and repair that exact entry. `;
464
+ return "";
465
+ }
466
+
310
467
  export async function inspectManagedTotemMcp(profile, environment = process.env, runtime = {}) {
311
- const serverEntry = await installedServerEntry(profile, environment);
468
+ const serverEntry = await installedServerEntry(profile);
312
469
  const inspected = await inspectEntry(profile, localClientEnvironment(environment), runtime);
313
470
  if (
314
471
  inspected.state === "present" &&
@@ -328,7 +485,7 @@ export async function inspectManagedTotemMcp(profile, environment = process.env,
328
485
  return {
329
486
  name: inspected.name,
330
487
  state: "needs_attention",
331
- message: `${inspected.state === "unavailable" ? "The client configuration could not be read." : "The existing entry could not be verified against this saved profile."} ${reconnectAdvice(profile, inspected.name)}`,
488
+ message: `${missingBindingAdvice(inspected.entry, profile, inspected.name, serverEntry) || (inspected.state === "unavailable" ? "The client configuration could not be read. " : "The existing entry could not be verified against this saved profile. ")}${reconnectAdvice(profile, inspected.name)}`,
332
489
  };
333
490
  }
334
491
 
@@ -338,7 +495,7 @@ export async function installManagedTotemMcp(
338
495
  environment = process.env,
339
496
  runtime = {},
340
497
  ) {
341
- const serverEntry = await installedServerEntry(profile, environment);
498
+ const serverEntry = await installedServerEntry(profile);
342
499
  if (!["codex", "claude"].includes(profile.client))
343
500
  fail("Choose an existing AI app for MCP setup.");
344
501
  try {
@@ -366,9 +523,11 @@ export async function installManagedTotemMcp(
366
523
  profile.baseUrl,
367
524
  "--name",
368
525
  name,
526
+ ...(profile.agentId ? ["--agent-id", profile.agentId] : []),
527
+ ...(profile.environment ? ["--environment", profile.environment] : []),
369
528
  ],
370
529
  safeEnvironment,
371
- { ...runtime, serverEntry },
530
+ { ...runtime, serverEntry, skipListenerSetup: true },
372
531
  );
373
532
  if (profile.client === "codex") {
374
533
  const after = await inspectEntry(profile, safeEnvironment, runtime);
@@ -404,9 +563,18 @@ export async function installManagedTotemMcp(
404
563
  state: "configured_unverified",
405
564
  message: `${name} already matches this profile; nothing changed. Verify its identity in your AI app with totem_get_identity.`,
406
565
  };
566
+ const missingBinding = missingBindingAdvice(inspected.entry, profile, name, serverEntry);
567
+ if (missingBinding)
568
+ return {
569
+ name,
570
+ state: "needs_attention",
571
+ message: `${missingBinding}${guidance}`,
572
+ };
407
573
  const ownsPrevious =
408
574
  previousProfile?.profileName === profile.profileName &&
409
575
  previousProfile.client === profile.client &&
576
+ JSON.stringify(managedIdentityBinding(previousProfile)) ===
577
+ JSON.stringify(managedIdentityBinding(profile)) &&
410
578
  inspected.state === "present" &&
411
579
  matchesManagedEntry(
412
580
  inspected.entry,
@@ -0,0 +1,169 @@
1
+ import { opendir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Totem } from "./sdk/index.js";
4
+ import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
5
+ import { runtimeRoot } from "./runtime-installation.mjs";
6
+ import { automaticEnabled } from "./automatic-runtime.mjs";
7
+
8
+ function listenerBinding(profile) {
9
+ return {
10
+ profile: profile.profileName,
11
+ agentId: profile.agentId,
12
+ environment: profile.environment,
13
+ codexThreadId: profile.codexThreadId ?? null,
14
+ };
15
+ }
16
+
17
+ /** Recover only the exact credential-bound profile. MCP transport grants no new host authority. */
18
+ export async function ensureMcpListener(config, environment = process.env, options = {}) {
19
+ if (environment.TOTEM_AUTO_START === "0" || environment.CI)
20
+ return { state: "disabled_by_request", ready: false, persistent: false };
21
+ if (!options.keyFile)
22
+ return {
23
+ state: "setup_required",
24
+ ready: false,
25
+ message:
26
+ "Tools are connected. Use explicit local setup with this agent's private key file to configure a listener; hosted or secret-only MCP transport does not install host software.",
27
+ };
28
+ const client = options.client ?? new Totem({ ...config, timeoutMs: 5000, maxRetries: 0 });
29
+ const identity = await client.agent();
30
+ if (
31
+ !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(identity?.id ?? "") ||
32
+ !["PRODUCTION", "SANDBOX"].includes(identity?.environment) ||
33
+ (config.expectedAgentId && identity.id !== config.expectedAgentId) ||
34
+ (config.expectedEnvironment && identity.environment !== config.expectedEnvironment)
35
+ )
36
+ throw new Error("The MCP credential does not match the selected Totem identity.");
37
+ const profiles = [];
38
+ try {
39
+ const entries = await opendir(join(runtimeRoot(environment), "connections"));
40
+ for await (const entry of entries) {
41
+ if (!entry.isDirectory()) continue;
42
+ const profile = await readAgentProfile(entry.name, { environment });
43
+ if (
44
+ profile.agentId === identity.id &&
45
+ profile.baseUrl === config.baseUrl &&
46
+ profile.environment === identity.environment
47
+ )
48
+ profiles.push(profile);
49
+ }
50
+ } catch (error) {
51
+ if (error.code !== "ENOENT") throw error;
52
+ }
53
+ if (profiles.length > 1)
54
+ return {
55
+ state: "needs_attention",
56
+ ready: false,
57
+ message:
58
+ "Multiple saved listeners match this identity. Select the intended profile with totem setup; no profile was changed.",
59
+ };
60
+ let profile = profiles[0];
61
+ if (profile && options.codexThreadId && profile.codexThreadId !== options.codexThreadId)
62
+ return {
63
+ state: "needs_attention",
64
+ ready: false,
65
+ message:
66
+ "The saved profile has a different executor/chat binding. Stop and explicitly repair that exact profile; it was not rebound.",
67
+ };
68
+ if (profile && (await readAgentCredential(profile)) !== config.apiKey)
69
+ return {
70
+ state: "needs_attention",
71
+ ready: false,
72
+ message:
73
+ "The saved listener uses another credential generation. Repair that exact profile with totem setup; its credential was preserved.",
74
+ };
75
+ if (!profile) {
76
+ if (!options.allowCreate || !["codex", "claude"].includes(options.provider) || !options.keyFile)
77
+ return {
78
+ state: "setup_required",
79
+ ready: false,
80
+ message:
81
+ "MCP tools are connected, but this identity has no saved listener on this computer. The owner must run totem setup with this agent's private key and selected executor once. Hosted MCP cannot install a local listener.",
82
+ };
83
+ const setup = options.setup ?? (await import("./agent-setup.mjs")).agentSetupMain;
84
+ let output = "";
85
+ const result = await setup(
86
+ [
87
+ "--profile",
88
+ identity.id,
89
+ "--provider",
90
+ options.provider,
91
+ "--key-file",
92
+ options.keyFile,
93
+ "--base-url",
94
+ config.baseUrl,
95
+ "--agent-id",
96
+ identity.id,
97
+ "--no-mcp",
98
+ "--json",
99
+ ...(options.codexThreadId ? ["--codex-thread", options.codexThreadId] : []),
100
+ ],
101
+ {
102
+ env: environment,
103
+ stdout: {
104
+ write(chunk) {
105
+ output = (output + chunk).slice(-65536);
106
+ },
107
+ },
108
+ stderr: { write() {} },
109
+ },
110
+ );
111
+ if (result)
112
+ return {
113
+ state: "needs_attention",
114
+ ready: false,
115
+ message:
116
+ "MCP tools are connected. The listener setup did not confirm readiness; inspect totem status. No repository or peer permission was added.",
117
+ };
118
+ profile = await readAgentProfile(identity.id, { environment });
119
+ const created = JSON.parse(output);
120
+ if (!created.automatic) throw new Error("Listener setup did not return its startup status.");
121
+ return { ...created.automatic, ...listenerBinding(profile) };
122
+ }
123
+ if (!(await automaticEnabled(profile)))
124
+ return {
125
+ state: "paused",
126
+ ready: false,
127
+ ...listenerBinding(profile),
128
+ message:
129
+ "This listener was explicitly stopped. Its opt-out remains in effect; totem start can resume it.",
130
+ };
131
+ const activate = options.activate ?? (await import("./installation.mjs")).activateConnection;
132
+ const status = await activate(profile, environment);
133
+ return { ...status, ...listenerBinding(profile) };
134
+ }
135
+
136
+ /** Status reads never start workers, rotate credentials or change an owner's pause. */
137
+ export async function readMcpListenerStatus(config, environment, initial, options = {}) {
138
+ if (!initial.profile) return initial;
139
+ try {
140
+ const profile = await readAgentProfile(initial.profile, { environment });
141
+ if (
142
+ profile.agentId !== initial.agentId ||
143
+ profile.baseUrl !== config.baseUrl ||
144
+ profile.environment !== initial.environment ||
145
+ (profile.codexThreadId ?? null) !== initial.codexThreadId ||
146
+ (config.expectedEnvironment && profile.environment !== config.expectedEnvironment) ||
147
+ (await readAgentCredential(profile)) !== config.apiKey
148
+ )
149
+ throw new Error("Listener binding changed");
150
+ const status = { ...initial, ready: false, checkedAt: new Date().toISOString() };
151
+ if (!(await automaticEnabled(profile))) return { ...status, state: "paused" };
152
+ const readStatus =
153
+ options.readStatus ?? (await import("./agent-runner.mjs")).readAgentRuntimeStatus;
154
+ const current = await readStatus(profile);
155
+ return {
156
+ ...status,
157
+ ...current,
158
+ state: !current.running ? "offline" : current.ready ? "ready" : "needs_attention",
159
+ ready: Boolean(current.running && current.ready),
160
+ };
161
+ } catch {
162
+ return {
163
+ state: "needs_attention",
164
+ ready: false,
165
+ message:
166
+ "The saved listener binding or current status could not be verified. No startup was attempted.",
167
+ };
168
+ }
169
+ }