totemlayer 0.1.66 → 0.1.68

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/README.md CHANGED
@@ -27,7 +27,7 @@ To configure execution later, rerun setup with `--provider` and the adapter's se
27
27
 
28
28
  ## Connect with automatic listener setup
29
29
 
30
- Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz`, `totem`, `connect`, `--provider`, and `codex` (or `claude` for Claude Code). The host runs the connection itself; the owner does not run a separate listener command. Node.js 22.14 or newer and the selected signed-in client must be available.
30
+ Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.68.tgz`, `totem`, `connect`, `--provider`, and `codex` (or `claude` for Claude Code). The host runs the connection itself; the owner does not run a separate listener command. Node.js 22.14 or newer and the selected signed-in client must be available.
31
31
 
32
32
  During browser sign-in, `totem_get_connection_status` reports progress. Normal tools appear after the approved identity is pinned privately. Codex verifies identity, startup and the saved chat callback before receiving work; reconnect preserves bindings and pauses. Missing capabilities leave work queued. A remote HTTP connector cannot install local software.
33
33
 
@@ -45,7 +45,7 @@ For a hosted connection, add these settings to your client's MCP configuration:
45
45
  For a local stdio connection, install the CLI with Node.js 22.14 or newer:
46
46
 
47
47
  ```sh
48
- npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.66.tgz
48
+ npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.68.tgz
49
49
  ```
50
50
 
51
51
  Save your agent's connection key in a private file. Protect it with mode 0600 on macOS/Linux or your user's filesystem ACL on Windows. Configure these fields in your client's MCP settings:
@@ -64,7 +64,7 @@ Reload the client if needed, discover the available tools, and call `totem_get_i
64
64
  Run the guided installer on the host where your agent will execute work:
65
65
 
66
66
  ```sh
67
- npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz totem install
67
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.68.tgz totem install
68
68
  ```
69
69
 
70
70
  The installer opens Totem sign-in and saves the approved connection privately. It checks your configured agent runtime, configures supported clients, and starts the listener when an authorized executor is available. The supervised listener is enabled by default for this setup and starts when you sign in to the computer on hosts with a supported user service manager. It recovers after a process exit and retries temporary connection failures. An explicit stop stays paused across restarts.
@@ -74,7 +74,7 @@ For manual setup, use `totem mcp install` with a supported client and the privat
74
74
  For a host without a browser:
75
75
 
76
76
  ```sh
77
- npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz totem login --device-login --no-browser
77
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.68.tgz totem login --device-login --no-browser
78
78
  ```
79
79
 
80
80
  Open the printed link on your own device and compare the connection code. Preserve existing MCP entries; inspect conflicts before replacing them. If an executor is unavailable, the saved connection remains available while you complete runtime setup.
@@ -176,3 +176,5 @@ An update changes only the managed code directory. Existing identities, credenti
176
176
  `totem update status` shows the installed version and last check. `totem update check` checks now. `totem update pause` disables automatic downloads; `totem update resume` enables them. For a plugin connection, add its `--provider codex` or `--provider claude` selection. `TOTEM_AUTO_UPDATE=0` also disables checks. Pausing updates does not pause message receiving.
177
177
 
178
178
  Unverified downloads and failed health checks leave working code installed. A receiver that repeatedly crashes immediately after an update falls back to its retained previous version and quarantines the failed version. An offline or sleeping computer checks after it resumes; updates do not wake a closed laptop or add a wake mechanism to an external AI host.
179
+
180
+ Local Codex setup now defaults to the listener-enabled bound installer with `totem mcp install codex`. The client opens approval and automatically prepares persistent receiving when its MCP connection starts. Verify `totem_get_connection_status` reports `ready=true` and `startupConfigured=true` before reporting installation complete. Use `--transport http` only for an explicit hosted MCP connection; it does not register a host service. Existing settings, identities and owner pauses are preserved.
@@ -1,3 +1,4 @@
1
+ import { processIdentity } from "./runtime-lock.mjs";
1
2
  import { startupDiagnostic } from "./startup-diagnostics.mjs";
2
3
  import { spawn } from "node:child_process";
3
4
  import { randomUUID } from "node:crypto";
@@ -225,11 +226,25 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
225
226
  const record = running ?? records[0];
226
227
  if (!record) return { running: false, phase: "not_started" };
227
228
  const fresh = now() - Date.parse(record.updatedAt) <= 30_000;
229
+ const receiving =
230
+ record.listenerHealthRequired !== true ||
231
+ (typeof record.listenerHeartbeatAt === "string" &&
232
+ Number.isFinite(Date.parse(record.listenerHeartbeatAt)) &&
233
+ now() - Date.parse(record.listenerHeartbeatAt) >= -5000 &&
234
+ now() - Date.parse(record.listenerHeartbeatAt) <= 30_000);
228
235
  return {
229
236
  running: Boolean(running),
237
+ listenerReady: Boolean(
238
+ running &&
239
+ fresh &&
240
+ receiving &&
241
+ record.ready &&
242
+ !["reconnecting", "starting", "failed", "stopped"].includes(record.phase),
243
+ ),
230
244
  receiveWakeReplyVerified: Boolean(
231
245
  running &&
232
246
  fresh &&
247
+ receiving &&
233
248
  record.ready === true &&
234
249
  !["reconnecting", "starting", "failed", "stopped"].includes(record.phase),
235
250
  ),
@@ -238,6 +253,7 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
238
253
  ready: Boolean(
239
254
  running &&
240
255
  fresh &&
256
+ receiving &&
241
257
  record.ready === true &&
242
258
  !["blocked", "reconnecting", "starting", "failed", "stopped"].includes(record.phase),
243
259
  ),
@@ -251,6 +267,7 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
251
267
  runId: record.runId,
252
268
  ...(typeof record.packageVersion === "string" ? { packageVersion: record.packageVersion } : {}),
253
269
  updatedAt: record.updatedAt,
270
+ ...(record.listenerHeartbeatAt ? { listenerHeartbeatAt: record.listenerHeartbeatAt } : {}),
254
271
  ...(/^[A-Z][A-Z0-9_]{0,79}$/.test(record.errorCode ?? "")
255
272
  ? { errorCode: record.errorCode }
256
273
  : {}),
@@ -261,8 +278,57 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
261
278
  };
262
279
  }
263
280
 
281
+ /** Recover only this run's verified worker; never signal a reused or unrelated PID. */
282
+ export async function stopStalledAgent(profile, runId, options = {}) {
283
+ if (!UUID.test(runId)) throw new Error("Invalid stalled run identifier.");
284
+ const readStatus = options.readStatus ?? readAgentRuntimeStatus;
285
+ const identity = options.processIdentity ?? processIdentity;
286
+ const signal = options.processSignal ?? process.kill;
287
+ const current = await readStatus(profile);
288
+ if (!current.running || current.runId !== runId || current.phase !== "unresponsive") return false;
289
+ const directory = await runtimeDirectory(profile);
290
+ const record = await readPrivateJson(join(directory, `${runId}.json`));
291
+ const command = `${CLI_PATH} start --profile ${profile.profileName} --run-id ${runId} --json`;
292
+ const owned = async () => {
293
+ const actual = await identity(record.pid);
294
+ if (!actual) return false;
295
+ if (
296
+ record.pid === process.pid ||
297
+ record.runId !== runId ||
298
+ record.agentId !== profile.agentId ||
299
+ record.baseUrl !== profile.baseUrl ||
300
+ typeof record.processIdentity !== "string" ||
301
+ actual !== record.processIdentity ||
302
+ !actual.replaceAll('"', "").includes(command)
303
+ )
304
+ throw Object.assign(new Error("The stalled worker's ownership could not be verified."), {
305
+ code: "LISTENER_PROCESS_UNVERIFIED",
306
+ });
307
+ return true;
308
+ };
309
+ if (!(await owned())) return true;
310
+ await requestStop(profile, runId);
311
+ if (!(await owned())) return true;
312
+ signal(record.pid, "SIGTERM");
313
+ if (process.platform !== "win32" && (await owned())) signal(record.pid, "SIGCONT");
314
+ const deadline = Date.now() + (options.stopTimeoutMs ?? 3000);
315
+ while (Date.now() < deadline) {
316
+ const state = await readStatus(profile);
317
+ if (!state.running || state.runId !== runId || !(await owned())) return true;
318
+ await new Promise((resolve) => setTimeout(resolve, 100));
319
+ }
320
+ const state = await readStatus(profile);
321
+ if (!state.running || state.runId !== runId || !(await owned())) return true;
322
+ // A fresh heartbeat means the worker recovered; leave it and its journal intact.
323
+ if (state.phase !== "unresponsive") return false;
324
+ if (await owned()) signal(record.pid, "SIGKILL");
325
+ return true;
326
+ }
327
+
264
328
  function eventState(event) {
265
- if (event?.type === "runtime_ready") return { ready: true };
329
+ if (event?.type === "runtime_ready")
330
+ return { ready: true, listenerHeartbeatAt: new Date().toISOString() };
331
+ if (event?.type === "runtime_heartbeat") return { listenerHeartbeatAt: new Date().toISOString() };
266
332
  const candidate = event?.state ?? event?.status ?? event?.type;
267
333
  const phase =
268
334
  typeof candidate === "string" ? candidate.replace(/^mission_agent_|^agent_/, "") : "";
@@ -328,6 +394,8 @@ export async function runAgentSession(profile, options = {}) {
328
394
  baseUrl: profile.baseUrl,
329
395
  startedAt: new Date().toISOString(),
330
396
  updatedAt: new Date().toISOString(),
397
+ processIdentity: await (options.processIdentity ?? processIdentity)(process.pid),
398
+ listenerHealthRequired: true,
331
399
  phase: "starting",
332
400
  };
333
401
  let writes = Promise.resolve();
@@ -383,7 +451,25 @@ export async function runAgentSession(profile, options = {}) {
383
451
  }
384
452
  let polling;
385
453
  let stopReadFailed = false;
454
+ let listenerExpired = false;
455
+ const listenerWatchdogMs = options.listenerWatchdogMs ?? 30_000;
456
+ if (
457
+ !Number.isInteger(listenerWatchdogMs) ||
458
+ listenerWatchdogMs < 50 ||
459
+ listenerWatchdogMs > 120_000
460
+ )
461
+ throw new Error("Invalid listener watchdog deadline.");
386
462
  const poll = async () => {
463
+ if (status.ready && Date.now() - Date.parse(status.listenerHeartbeatAt) > listenerWatchdogMs) {
464
+ listenerExpired = true;
465
+ status = {
466
+ ...status,
467
+ ready: false,
468
+ phase: "reconnecting",
469
+ errorCode: "LISTENER_HEARTBEAT_TIMEOUT",
470
+ };
471
+ controller.abort();
472
+ }
387
473
  try {
388
474
  const request = await readPrivateJson(join(directory, `${runId}.stop`));
389
475
  if (
@@ -463,6 +549,10 @@ export async function runAgentSession(profile, options = {}) {
463
549
  },
464
550
  });
465
551
  }
552
+ if (listenerExpired)
553
+ throw Object.assign(new Error("The listener stopped renewing its receive heartbeat."), {
554
+ code: "LISTENER_HEARTBEAT_TIMEOUT",
555
+ });
466
556
  if (persistenceFailed || stopReadFailed)
467
557
  throw new Error("Agent status could not be saved; the worker was stopped.");
468
558
  } catch (error) {
@@ -551,7 +551,13 @@ export async function agentSetupMain(args, options = {}) {
551
551
  ? `Connection saved. Automatic receiving is not configured. To add an executor, run totem setup --profile ${profile.profileName} --provider <provider> --replace with its settings.`
552
552
  : `Run totem status --profile ${profile.profileName} to check your connection and automatic listener.`;
553
553
  const report = {
554
- ok: (!mcp || mcp.state !== "needs_attention") && automatic?.state !== "needs_attention",
554
+ ok:
555
+ (!mcp || mcp.state !== "needs_attention") &&
556
+ (connectionOnly || flags["no-start"] || io.deferActivation
557
+ ? true
558
+ : automatic?.ready === true &&
559
+ automatic?.persistent === true &&
560
+ automatic?.running === true),
555
561
  profile: profile.profileName,
556
562
  agent: identity.address,
557
563
  origin: baseUrl,
@@ -9,12 +9,13 @@ import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { Totem } from "./sdk/index.js";
11
11
  import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
12
- import { agentStartMain, readAgentRuntimeStatus, requestStop } from "./agent-runner.mjs";
13
12
  import {
14
- privateRead,
15
- privateWrite,
16
- runtimeRoot,
17
- } from "./runtime-installation.mjs";
13
+ agentStartMain,
14
+ readAgentRuntimeStatus,
15
+ requestStop,
16
+ stopStalledAgent,
17
+ } from "./agent-runner.mjs";
18
+ import { privateRead, privateWrite, runtimeRoot } from "./runtime-installation.mjs";
18
19
  import { resumeDeviceLogin } from "./device-login.mjs";
19
20
 
20
21
  const PACKAGE_VERSION = JSON.parse(
@@ -93,7 +94,13 @@ export async function superviseOnce(env = process.env, options = {}) {
93
94
  await reportActivations(activationClient, activationIds, "paused");
94
95
  continue;
95
96
  }
96
- const state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
97
+ let state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
98
+ if (state.running && state.phase === "unresponsive") {
99
+ await (options.recoverStalled ?? stopStalledAgent)(profile, state.runId);
100
+ state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
101
+ if (state.running) continue;
102
+ if (!(await automaticEnabled(profile))) continue;
103
+ }
97
104
  if (state.running && state.packageVersion === (options.packageVersion ?? PACKAGE_VERSION))
98
105
  await reportActivations(
99
106
  activationClient,
@@ -150,13 +157,18 @@ export async function superviseOnce(env = process.env, options = {}) {
150
157
  throw Object.assign(new Error("START_FAILED"), failure);
151
158
  }
152
159
  const started = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
160
+ if (!started.running)
161
+ throw Object.assign(
162
+ new Error("The listener process did not remain running after startup."),
163
+ { code: "LISTENER_START_FAILED" },
164
+ );
153
165
  await reportActivations(
154
166
  activationClient,
155
167
  activationIds,
156
168
  started.ready ? "ready" : "starting",
157
169
  );
158
170
  await privateWrite(join(profile.stateDirectory, "automatic-health.json"), {
159
- state: "running",
171
+ state: started.ready ? "running" : "starting",
160
172
  activationIds,
161
173
  attempts: 0,
162
174
  retryAt: null,
@@ -76,7 +76,7 @@ export async function runConnectedAgentService(settings, dependencies = {}) {
76
76
  replyTimeoutMs: 90000,
77
77
  pollMs: settings.pollMs ?? DEFAULT_LISTENER_POLL_MS,
78
78
  emit(event) {
79
- if (event.type === "runtime_ready") emit(event);
79
+ if (["runtime_ready", "runtime_heartbeat"].includes(event.type)) emit(event);
80
80
  else if (event.type === "runtime_reconnecting")
81
81
  report("messages", { type: "reconnecting" });
82
82
  else if (event.type === "runtime_resumed") report("messages", { type: "waiting" });
@@ -31,13 +31,13 @@ export async function activateConnection(profile, env = process.env, options = {
31
31
  ...(profile.agentTokenEnv ? { TOTEM_AGENT_TOKEN_ENV: profile.agentTokenEnv } : {}),
32
32
  });
33
33
  if (!enabled) return { state: "paused", ...service, entry: runtime.cli };
34
- if (profile.interactiveRequired && !service.persistent)
34
+ if (!service.persistent)
35
35
  return {
36
36
  state: "needs_attention",
37
37
  ready: false,
38
38
  ...service,
39
39
  entry: runtime.cli,
40
- code: "HARNESS_SETUP_REQUIRED",
40
+ code: "LISTENER_STARTUP_UNAVAILABLE",
41
41
  message:
42
42
  "Automatic startup is not registered. Incoming work remains queued until host setup succeeds.",
43
43
  };
@@ -89,8 +89,9 @@ export async function activateConnection(profile, env = process.env, options = {
89
89
  }
90
90
  const status = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
91
91
  return {
92
- state: status.ready ? "ready" : "needs_attention",
93
- ready: Boolean(status.ready),
92
+ state: status.running && status.ready ? "ready" : "needs_attention",
93
+ ready: Boolean(status.running && status.ready),
94
+ running: status.running === true,
94
95
  ...service,
95
96
  entry: runtime.cli,
96
97
  };
@@ -232,14 +233,15 @@ export async function installationMain(args = [], options = {}) {
232
233
  return (options.activateConnection ?? activateConnection)(profile, environment, options);
233
234
  };
234
235
  const reportActivation = (status) => {
236
+ const ready = status.ready === true && status.persistent === true && status.running === true;
235
237
  stdout.write(
236
238
  flags.json
237
239
  ? JSON.stringify(status) + "\n"
238
- : status.ready
240
+ : ready
239
241
  ? "Ready. The listener passed its controlled receive, wake and reply check. This host must remain awake and online.\n"
240
242
  : `Runtime ${status.state}. Run totem status for diagnostics; an installed or paused listener is not ready.\n`,
241
243
  );
242
- return status.ready ? 0 : 1;
244
+ return ready ? 0 : 1;
243
245
  };
244
246
  if (environment.TOTEM_CONNECTION_ID) {
245
247
  for (const file of ["connection-ready.json", "connection-request.json"]) {
@@ -328,8 +330,10 @@ export async function installationMain(args = [], options = {}) {
328
330
  for (const name of saved) results.push({ profile: name, ...(await activate(name)) });
329
331
  return reportActivation({
330
332
  installed: true,
331
- ready: results.every((r) => r.ready),
332
- state: results.every((r) => r.ready) ? "ready" : "needs_attention",
333
+ ready: results.every((r) => r.ready && r.persistent && r.running),
334
+ persistent: results.every((r) => r.persistent),
335
+ running: results.every((r) => r.running),
336
+ state: results.every((r) => r.ready && r.persistent && r.running) ? "ready" : "needs_attention",
333
337
  profiles: results,
334
338
  service,
335
339
  });
@@ -7,7 +7,7 @@ import { resolve, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
10
- import { runtimeRoot } from "./runtime-installation.mjs";
10
+ import { runtimeRoot, ensureInstalledRuntime } from "./runtime-installation.mjs";
11
11
  import { loadMcpIdentityBinding } from "./mcp-identity-binding.mjs";
12
12
 
13
13
  const DEFAULT_ORIGIN = "https://totemlayer.com";
@@ -18,7 +18,8 @@ export const MCP_INSTALL_HELP = `Connect Totem to an AI app
18
18
 
19
19
  Usage:
20
20
  totem mcp install claude Add hosted MCP and open one OAuth approval
21
- totem mcp install codex Add hosted MCP and open one OAuth approval
21
+ totem mcp install codex Add local MCP with automatic listener setup
22
+ totem mcp install codex --transport http Explicit hosted MCP without a host service
22
23
  totem mcp install generic HTTP settings for other hosts
23
24
  totem mcp config HTTP settings for any MCP client
24
25
  totem mcp config --transport stdio --key-file /absolute/path/to/agent.key
@@ -31,13 +32,18 @@ Options:
31
32
  --base-url <origin> Totem origin (default: https://totemlayer.com)
32
33
  --name <name> MCP name (default: totem, or totem-local with --key-file)
33
34
  --key-file <path> Use an existing private agent key for local stdio instead
35
+ --transport <type> http or stdio (Codex defaults to listener-enabled stdio)
34
36
  --agent-id <UUID> Require this credential-derived agent identity
35
37
  --environment <env> Require PRODUCTION or SANDBOX
36
38
  --codex-thread <ID> Bind a new Codex listener to this owner-selected chat
37
39
  --no-start Configure tools only; do not start a local listener
38
40
  --help Show this guide
39
41
 
40
- Without --key-file, this uses the client's native hosted MCP and OAuth support.
42
+ Without --key-file, Codex uses the bound browser installer and supervised listener.
43
+ The selected Codex host opens approval and finishes listener setup on connection.
44
+ Settings alone are not setup completion: check totem_get_connection_status until
45
+ ready and startupConfigured are true. Explicit stops remain paused.
46
+ Claude and generic use hosted MCP; --transport http explicitly selects that flow.
41
47
  The client saves credentials privately. No Totem website guide, copied key,
42
48
  local worker, or model change is required. Existing connections are preserved.
43
49
  The host may require reloading tools after adding a server. Call
@@ -87,6 +93,7 @@ function parseInstallArgs(args, environment) {
87
93
  "--agent-id",
88
94
  "--environment",
89
95
  "--codex-thread",
96
+ "--transport",
90
97
  ].includes(option) ||
91
98
  !value ||
92
99
  value.startsWith("--")
@@ -96,6 +103,13 @@ function parseInstallArgs(args, environment) {
96
103
  values[option] = value;
97
104
  }
98
105
  const keyFile = values["--key-file"] ?? environment.TOTEM_NETWORK_API_KEY_FILE;
106
+ const transport = values["--transport"] ?? (keyFile || client === "codex" ? "stdio" : "http");
107
+ if (!["http", "stdio"].includes(transport)) fail("Choose --transport http or stdio.");
108
+ if (keyFile && transport === "http") fail("Use a private key file only with stdio transport.");
109
+ if (!keyFile && transport === "stdio" && client !== "codex")
110
+ fail(
111
+ "Keyless local listener setup currently requires Codex. Use this host's supported incoming integration or an explicit private key file.",
112
+ );
99
113
  const name = values["--name"] ?? (keyFile ? DEFAULT_NAME : "totem");
100
114
  if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name))
101
115
  fail(
@@ -128,6 +142,7 @@ function parseInstallArgs(args, environment) {
128
142
  fail("--codex-thread requires codex and an owner-selected chat UUID.");
129
143
  return {
130
144
  client,
145
+ transport,
131
146
  codexThreadId: values["--codex-thread"],
132
147
  configDirectory: environment.TOTEM_CONFIG_DIR ? runtimeRoot(environment) : undefined,
133
148
  noStart: values["--no-start"] === true || environment.TOTEM_AUTO_START === "0",
@@ -305,7 +320,10 @@ export async function installTotemMcp(args, environment = process.env, runtime =
305
320
  const options = parseInstallArgs(args, environment);
306
321
  if (options.help) return MCP_INSTALL_HELP;
307
322
  runtime = { platform: process.platform, spawnProcess: spawnSync, ...runtime };
308
- if (!options.keyFile) return installHostedTotemMcp(options, environment, runtime);
323
+ if (!options.keyFile)
324
+ return options.transport === "stdio"
325
+ ? installBoundTotemMcp(options, environment, runtime)
326
+ : installHostedTotemMcp(options, environment, runtime);
309
327
  await validateKeyFile(options.keyFile, runtime.platform);
310
328
 
311
329
  if (options.client === "generic")
@@ -440,6 +458,74 @@ export async function installTotemMcp(args, environment = process.env, runtime =
440
458
  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`;
441
459
  }
442
460
 
461
+ /** Persist the self-configuring entry, rather than a tools-only hosted URL.
462
+ * Authorization and readiness happen inside that exact client connection. */
463
+ async function installBoundTotemMcp(options, environment, runtime) {
464
+ if (options.codexThreadId)
465
+ fail("Use the existing private-key setup to bind an explicitly selected Codex chat.");
466
+ const installed = await (runtime.ensureRuntime ?? ensureInstalledRuntime)(environment);
467
+ const args = [
468
+ installed.cli,
469
+ "connect",
470
+ "--provider",
471
+ options.client,
472
+ "--base-url",
473
+ options.baseUrl,
474
+ ...(options.expectedAgentId ? ["--agent-id", options.expectedAgentId] : []),
475
+ ...(options.expectedEnvironment ? ["--environment", options.expectedEnvironment] : []),
476
+ ];
477
+ const savedEnv = options.noStart ? { TOTEM_AUTO_START: "0" } : {};
478
+ const env = localClientEnvironment(environment);
479
+ const get = () => runClient(options.client, ["mcp", "get", options.name, "--json"], env, runtime);
480
+ const matches = (entry) => {
481
+ try {
482
+ const value = JSON.parse(entry.stdout),
483
+ transport = value.transport;
484
+ return (
485
+ entry.status === 0 &&
486
+ value.enabled !== false &&
487
+ transport?.type === "stdio" &&
488
+ transport.command === process.execPath &&
489
+ JSON.stringify(transport.args) === JSON.stringify(args) &&
490
+ JSON.stringify(Object.entries(transport.env ?? {}).sort()) ===
491
+ JSON.stringify(Object.entries(savedEnv).sort()) &&
492
+ (!value.disabled_tools || value.disabled_tools.length === 0) &&
493
+ (!value.enabled_tools || value.enabled_tools.includes("totem_get_connection_status"))
494
+ );
495
+ } catch {
496
+ return false;
497
+ }
498
+ };
499
+ const existing = get();
500
+ if (existing.status === 0 && !matches(existing))
501
+ fail(
502
+ "This MCP entry already has different or disabled settings. It was preserved. Inspect that exact connection before repairing its automatic receiver; adding tools alone does not complete listener setup.",
503
+ );
504
+ if (existing.status !== 0) {
505
+ if (!/no mcp server|not found|does not exist/i.test(`${existing.stderr}\n${existing.stdout}`))
506
+ fail("The client could not check its existing MCP settings. No configuration was changed.");
507
+ const add = runClient(
508
+ options.client,
509
+ [
510
+ "mcp",
511
+ "add",
512
+ options.name,
513
+ ...Object.entries(savedEnv).flatMap(([name, value]) => ["--env", `${name}=${value}`]),
514
+ "--",
515
+ process.execPath,
516
+ ...args,
517
+ ],
518
+ env,
519
+ runtime,
520
+ );
521
+ if (add.status !== 0 || !matches(get()))
522
+ fail(
523
+ "The listener-enabled MCP settings could not be verified. Inspect this exact entry before retrying.",
524
+ );
525
+ }
526
+ return "Totem's listener-enabled MCP settings are saved. Reload the selected client's MCP tools to open its bound browser approval and configure the persistent listener automatically. Setup is incomplete until totem_get_connection_status reports ready=true and startupConfigured=true; then verify a permitted incoming message and reply. Existing pauses and permissions are preserved.\n";
527
+ }
528
+
443
529
  /** Guided setup owns a distinct entry per profile; the manual installer retains its default. */
444
530
  export function managedMcpName(profileName) {
445
531
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(profileName) || profileName.length > 64)
Binary file
@@ -1 +1 @@
1
- {"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"fa6dad0474b12a9e8d4ba11ec1032166939ea330817a13de6cbdece265edbadf","architectures":["arm64","x86_64"],"minimumMacOS":"13.0"}
1
+ {"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"841e12dec0c0269584f5a13793f9a66471af09db73783fcca3d5e1750582aafc","architectures":["arm64","x86_64"],"minimumMacOS":"13.0"}
@@ -793,6 +793,7 @@ export async function runRuntime(settings) {
793
793
  },
794
794
  { signal },
795
795
  );
796
+ emit({ type: "runtime_heartbeat" });
796
797
  diagnosticsSupported = result?.diagnosticsSupported === true;
797
798
  operationalStatesSupported = result?.operationalStatesSupported === true;
798
799
  return result;
@@ -7,6 +7,9 @@ const MISSION_ERROR_CODES = new Map([
7
7
  ]);
8
8
 
9
9
  export const RUNTIME_ERROR_CODES = new Set([
10
+ "LISTENER_STARTUP_UNAVAILABLE",
11
+ "LISTENER_HEARTBEAT_TIMEOUT",
12
+ "LISTENER_PROCESS_UNVERIFIED",
10
13
  ...MISSION_ERROR_CODES.values(),
11
14
  "AGENT_START_FAILED",
12
15
  "AGENT_EXIT_FAILED",
@@ -562,6 +562,22 @@ export async function installRuntimeService(runtime, env = process.env, options
562
562
  await run("systemctl", ["--user", "daemon-reload"]);
563
563
  await run("systemctl", ["--user", "enable", "--now", unit]);
564
564
  if (runtime.upgraded) await run("systemctl", ["--user", "restart", unit]);
565
+ const enabled = await run("systemctl", ["--user", "is-enabled", unit]);
566
+ const active = await run("systemctl", ["--user", "is-active", unit]);
567
+ const loaded = await run("systemctl", [
568
+ "--user",
569
+ "show",
570
+ unit,
571
+ "--property=ExecStart",
572
+ "--value",
573
+ ]);
574
+ if (
575
+ enabled.stdout.trim() !== "enabled" ||
576
+ active.stdout.trim() !== "active" ||
577
+ !loaded.stdout.includes(node) ||
578
+ !loaded.stdout.includes(runtime.supervisor)
579
+ )
580
+ throw new Error("The exact persistent listener service is not enabled and active.");
565
581
  return acknowledged({ mode: "systemd-user", persistent: true, label: unit, path });
566
582
  }
567
583
  if (platform === "win32") {
@@ -572,6 +588,17 @@ export async function installRuntimeService(runtime, env = process.env, options
572
588
  } catch {
573
589
  /* Ephemeral/headless hosts often have no user service manager. */
574
590
  }
591
+ if (windowsRegistration?.mode !== "startup-shortcut")
592
+ return {
593
+ mode: "unavailable",
594
+ persistent: false,
595
+ code:
596
+ startupCode ??
597
+ windowsRegistration?.code ??
598
+ (platform === "darwin" || platform === "win32"
599
+ ? "STARTUP_REGISTRATION_FAILED"
600
+ : "LISTENER_STARTUP_UNAVAILABLE"),
601
+ };
575
602
  const child = (options.spawn ?? spawn)(node, [runtime.supervisor], {
576
603
  env: { ...env, TOTEM_CONFIG_DIR: runtime.root },
577
604
  stdio: "ignore",
@@ -580,13 +607,5 @@ export async function installRuntimeService(runtime, env = process.env, options
580
607
  });
581
608
  child.on("error", () => {});
582
609
  child.unref();
583
- return windowsRegistration?.mode === "startup-shortcut"
584
- ? acknowledged({ mode: "startup-shortcut", persistent: true, label: task })
585
- : {
586
- mode: "background-supervisor",
587
- persistent: false,
588
- ...(["win32", "darwin"].includes(platform)
589
- ? { code: startupCode ?? windowsRegistration?.code ?? "STARTUP_REGISTRATION_FAILED" }
590
- : {}),
591
- };
610
+ return acknowledged({ mode: "startup-shortcut", persistent: true, label: task });
592
611
  }
@@ -4,6 +4,19 @@ export const HOST_APPROVAL_RECOVERY =
4
4
  "Stop retrying. Ask the owner to inspect this command in the host app's approval or automatic review controls. If no approval path is available, contact the host's support with the denial. Resume once after the owner resolves it; do not bypass the policy or change agents.";
5
5
 
6
6
  const causes = {
7
+ LISTENER_STARTUP_UNAVAILABLE: [
8
+ "The host has no verified enabled persistent listener service.",
9
+ "Restore the host's supported startup service, then rerun setup. A temporary background process is not a complete installation.",
10
+ ],
11
+ LISTENER_HEARTBEAT_TIMEOUT: [
12
+ "The listener stopped renewing its receive heartbeat.",
13
+ "The supervisor will restart this same saved profile. Inspect network availability if receiving repeatedly stalls.",
14
+ ],
15
+ LISTENER_PROCESS_UNVERIFIED: [
16
+ "The stalled listener process could not be safely identified.",
17
+ "Inspect this profile's saved process and stop the exact worker before resuming; no unrelated process was signaled.",
18
+ false,
19
+ ],
7
20
  HOST_APPROVAL_DENIED: [
8
21
  "The host denied approval to execute the startup command.",
9
22
  HOST_APPROVAL_RECOVERY,
package/bin/totem.mjs CHANGED
@@ -240,9 +240,9 @@ function help() {
240
240
  writeBrand();
241
241
  writeHelpBody(`Start here
242
242
  Updates totem update status | check | pause | resume
243
- Install once npm install --global https://totemlayer.com/downloads/totemlayer-0.1.66.tgz
243
+ Install once npm install --global https://totemlayer.com/downloads/totemlayer-${CLI_VERSION}.tgz
244
244
  Confirm installation totem --version
245
- Run one-off npx --yes --package https://totemlayer.com/downloads/totemlayer-0.1.66.tgz totem --help
245
+ Run one-off npx --yes --package https://totemlayer.com/downloads/totemlayer-${CLI_VERSION}.tgz totem --help
246
246
 
247
247
  Connect once and receive messages and approved Mission work
248
248
  Select Codex, Claude, a local model, or an owner-configured CLI/HTTP agent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {