totemlayer 0.1.63 → 0.1.67

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.63.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.67.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.63.tgz
48
+ npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.67.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.63.tgz totem install
67
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.67.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.63.tgz totem login --device-login --no-browser
77
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.67.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.
@@ -146,3 +146,33 @@ An AI app such as Instinct or Codex may reject a setup, start, or listener tool
146
146
  Ask the owner to inspect the host app's approval or review controls for that operation. If the host offers no approval path or explanation, direct the owner to that host's support. Do not promise an approve-once button, infer permission from a "Full access" label, change agents or credentials, disable safeguards, or use another execution path to bypass the denial.
147
147
 
148
148
  A command denied before execution cannot write Totem status. Saved configuration and verified identity do not prove that the worker started. Keep startup and message delivery marked unverified. When Totem itself observes an approval denial, it saves the cause and recovery and pauses automatic startup attempts. After the owner resolves the host decision, explicitly run totem start --profile <profile> once to resume, inspect totem status --profile <profile>, and verify an authorized incoming message and reply. Do not repeat setup or reauthorize credentials solely because of a host denial.
149
+
150
+ ### Native turn cleanup
151
+
152
+ On macOS 13 or later, the CLI's bundled native supervisor starts a temporary
153
+ launchd job for each turn. Its descendants remain in one kernel resource
154
+ coalition even if they change sessions or process groups. Completion, cancellation,
155
+ timeouts, output limits, and loss of the calling process trigger cleanup; Totem
156
+ waits for the kernel to reap the coalition before settling the turn. Ordinary
157
+ subprocesses remain available within the same filesystem and network sandbox.
158
+ No VM or permanently installed launch service is needed. macOS must permit the
159
+ temporary job to start. A missing supervisor stops execution; a failure to confirm
160
+ cleanup does not become a successful result. Force-killing the trusted supervisor
161
+ itself is outside this guarantee.
162
+ When approved code is in a macOS protected folder such as Documents, the parent
163
+ copies only its explicit code files and approved executable package into the
164
+ temporary turn folder. Relative code layout is preserved and command arguments
165
+ are updated; unrelated owner files remain unavailable. Code that hardcodes its
166
+ original protected location must use its staged path instead.
167
+
168
+ Build the universal supervisor on macOS with `node packages/cli/scripts/build-native.mjs`. The CLI package includes the binary; installation requires no compiler.
169
+
170
+ ## Automatic MCP and receiver updates
171
+
172
+ Install version 0.1.66 or newer once, or update the local plugin to that version. After that, the existing receiver checks for releases every 15 minutes while its host is running. Local MCP connections also check in the background. Downloads must have a valid Totem release signature and matching SHA-256 hash; dependencies are bundled and no package installation scripts run during an automatic update.
173
+
174
+ An update changes only the managed code directory. Existing identities, credentials, permissions, message cursors, journals, and receiver pauses remain in place. Receiver workers finish active work before upgrading. An open MCP session keeps its loaded tools until the host opens its next connection. Hosted MCP tools continue to update on the server.
175
+
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
+
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.
@@ -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,
@@ -1,22 +1,21 @@
1
+ import { checkRuntimeUpdate } from "./runtime-update.mjs";
2
+ import { acquireSupervisorLock } from "./runtime-lock.mjs";
3
+ export { acquireSupervisorLock, processIdentity, readSupervisorOwner } from "./runtime-lock.mjs";
1
4
  import { startupDiagnostic } from "./startup-diagnostics.mjs";
2
5
  import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
3
- import { opendir, rm, readFile, lstat, rename, readdir, unlink, rmdir } from "node:fs/promises";
6
+ import { opendir, rm, readFile } from "node:fs/promises";
4
7
  import { join } from "node:path";
5
- import { randomUUID } from "node:crypto";
6
- import { execFile } from "node:child_process";
7
- import { promisify } from "node:util";
8
- import { powershellInvocation } from "./platform-client.mjs";
9
8
  import { setTimeout as sleep } from "node:timers/promises";
10
9
  import { fileURLToPath } from "node:url";
11
10
  import { Totem } from "./sdk/index.js";
12
11
  import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
13
- import { agentStartMain, readAgentRuntimeStatus, requestStop } from "./agent-runner.mjs";
14
12
  import {
15
- privateDirectory,
16
- privateRead,
17
- privateWrite,
18
- runtimeRoot,
19
- } 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";
20
19
  import { resumeDeviceLogin } from "./device-login.mjs";
21
20
 
22
21
  const PACKAGE_VERSION = JSON.parse(
@@ -95,7 +94,13 @@ export async function superviseOnce(env = process.env, options = {}) {
95
94
  await reportActivations(activationClient, activationIds, "paused");
96
95
  continue;
97
96
  }
98
- 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
+ }
99
104
  if (state.running && state.packageVersion === (options.packageVersion ?? PACKAGE_VERSION))
100
105
  await reportActivations(
101
106
  activationClient,
@@ -152,13 +157,18 @@ export async function superviseOnce(env = process.env, options = {}) {
152
157
  throw Object.assign(new Error("START_FAILED"), failure);
153
158
  }
154
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
+ );
155
165
  await reportActivations(
156
166
  activationClient,
157
167
  activationIds,
158
168
  started.ready ? "ready" : "starting",
159
169
  );
160
170
  await privateWrite(join(profile.stateDirectory, "automatic-health.json"), {
161
- state: "running",
171
+ state: started.ready ? "running" : "starting",
162
172
  activationIds,
163
173
  attempts: 0,
164
174
  retryAt: null,
@@ -194,116 +204,6 @@ export async function superviseOnce(env = process.env, options = {}) {
194
204
  }
195
205
  return results;
196
206
  }
197
- const execute = promisify(execFile);
198
- export async function processIdentity(pid) {
199
- if (!Number.isSafeInteger(pid) || pid <= 1) return null;
200
- try {
201
- process.kill(pid, 0);
202
- } catch (error) {
203
- if (error.code === "ESRCH") return null;
204
- throw error;
205
- }
206
- // PID reuse across reboot must not make an abandoned lock permanent. A failed
207
- // process inspection is uncertainty, never evidence that another owner died.
208
- const invocation =
209
- process.platform === "win32"
210
- ? powershellInvocation(
211
- `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}';if(!$p){exit 1};$p|Select-Object ProcessId,CreationDate,CommandLine|ConvertTo-Json -Compress`,
212
- process.env,
213
- )
214
- : { command: "ps", args: ["-p", String(pid), "-o", "lstart=", "-o", "args="] };
215
- const result = await execute(invocation.command, invocation.args, {
216
- encoding: "utf8",
217
- timeout: 3000,
218
- maxBuffer: 8192,
219
- });
220
- if (!result.stdout.trim()) throw new Error("Cannot verify the supervisor process identity.");
221
- return result.stdout.trim();
222
- }
223
- export async function readSupervisorOwner(lock) {
224
- const info = await lstat(lock);
225
- if (
226
- !info.isDirectory() ||
227
- info.isSymbolicLink() ||
228
- (process.getuid && info.uid !== process.getuid()) ||
229
- (process.platform !== "win32" && (info.mode & 0o777) !== 0o700)
230
- )
231
- throw new Error("Unsafe supervisor lock.");
232
- const names = await readdir(lock);
233
- if (!names.length) return null;
234
- if (names.length !== 1 || !/^(?:owner|owner-[a-f0-9-]{36})\.json$/.test(names[0]))
235
- throw new Error("The supervisor lock contains unrecognized state.");
236
- const owner = await privateRead(join(lock, names[0]));
237
- if (
238
- !Number.isSafeInteger(owner?.pid) ||
239
- owner.pid <= 1 ||
240
- (names[0] !== "owner.json" &&
241
- (names[0] !== `owner-${owner.token}.json` ||
242
- typeof owner.identity !== "string" ||
243
- !owner.identity))
244
- )
245
- throw new Error("Invalid supervisor owner.");
246
- return { file: names[0], owner };
247
- }
248
- export async function acquireSupervisorLock(directory, options = {}) {
249
- const lock = join(directory, "supervisor.lock");
250
- const stage = join(directory, `supervisor-start-${randomUUID()}`);
251
- const token = randomUUID();
252
- const ownerFile = `owner-${token}.json`;
253
- const identity = options.processIdentity ?? processIdentity;
254
- await privateDirectory(directory);
255
- const owner = { pid: process.pid, token, identity: await identity(process.pid) };
256
- if (!owner.identity) throw new Error("Cannot verify this supervisor process.");
257
- await privateDirectory(stage);
258
- await privateWrite(join(stage, ownerFile), owner);
259
- const removeObserved = async (file) => {
260
- if (file)
261
- await unlink(join(lock, file)).catch((error) => {
262
- if (error.code !== "ENOENT") throw error;
263
- });
264
- // Never recursively delete or rename the shared lock path. A replacement
265
- // contains its own unique owner file and makes rmdir fail with ENOTEMPTY.
266
- await rmdir(lock).catch((error) => {
267
- if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
268
- });
269
- };
270
- try {
271
- for (let attempt = 0; attempt < 3; attempt++) {
272
- const snapshot = await lstat(lock).catch((error) => {
273
- if (error.code !== "ENOENT") throw error;
274
- return null;
275
- });
276
- if (snapshot) {
277
- const current = await readSupervisorOwner(lock).catch((error) => {
278
- if (error.code !== "ENOENT") throw error;
279
- return undefined;
280
- });
281
- if (current === undefined) continue;
282
- if (!current) {
283
- if (Date.now() - snapshot.mtimeMs < (options.emptyLockGraceMs ?? 30000))
284
- throw new Error("Another supervisor is starting. Wait before retrying.");
285
- } else {
286
- const actual = await identity(current.owner.pid);
287
- if (actual && (!current.owner.identity || current.owner.identity === actual))
288
- throw Object.assign(new Error("The Totem supervisor is already running."), {
289
- code: "SUPERVISOR_ALREADY_RUNNING",
290
- });
291
- }
292
- await removeObserved(current?.file);
293
- }
294
- try {
295
- // Publish owner and lock together. New installations never expose an empty lock.
296
- await rename(stage, lock);
297
- return () => removeObserved(ownerFile);
298
- } catch (error) {
299
- if (!["EEXIST", "ENOTEMPTY", "ENOENT"].includes(error.code)) throw error;
300
- }
301
- }
302
- throw new Error("Another supervisor is starting. Wait before retrying.");
303
- } finally {
304
- await rm(stage, { recursive: true, force: true });
305
- }
306
- }
307
207
  export async function automaticRuntimeMain(env = process.env, options = {}) {
308
208
  const directory = join(runtimeRoot(env), "runtime");
309
209
  const releaseLock = await acquireSupervisorLock(directory, options);
@@ -336,6 +236,13 @@ export async function automaticRuntimeMain(env = process.env, options = {}) {
336
236
  updatedAt: new Date().toISOString(),
337
237
  });
338
238
  await superviseOnce(environment, options);
239
+ await (options.checkUpdate ?? checkRuntimeUpdate)(environment).catch(() => {});
240
+ const installed = await privateRead(join(directory, "current.json")).catch(() => ({}));
241
+ if (installed.version && installed.version !== (options.packageVersion ?? PACKAGE_VERSION)) {
242
+ // The launcher reloads the supervisor; existing workers finish their active work first.
243
+ process.exitCode = 75;
244
+ return;
245
+ }
339
246
  await sleep(options.intervalMs ?? DEFAULT_LISTENER_POLL_MS, undefined, {
340
247
  signal: options.signal
341
248
  ? AbortSignal.any([controller.signal, options.signal])
@@ -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
  });
@@ -0,0 +1,68 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { readFile } from "node:fs/promises";
4
+ import { pluginConnectionOptions } from "./plugin-connect.mjs";
5
+ import { ensureInstalledRuntime, privateRead, runtimeRoot } from "./runtime-installation.mjs";
6
+ import {
7
+ checkRuntimeUpdate,
8
+ compareRuntimeVersions,
9
+ readRuntimeUpdateStatus,
10
+ setRuntimeAutoUpdates,
11
+ } from "./runtime-update.mjs";
12
+
13
+ function selectedEnvironment(args, env) {
14
+ if (args[0] !== "connect") return env;
15
+ const selection = pluginConnectionOptions(args.slice(1), env);
16
+ return selection.help ? env : { ...env, TOTEM_CONFIG_DIR: selection.configDirectory };
17
+ }
18
+ export async function managedMcpMain(args, env = process.env) {
19
+ const managed = selectedEnvironment(args, env);
20
+ // Help is independent of installation and never starts a receive process.
21
+ if (args.includes("--help") || args.includes("-h")) {
22
+ const { pluginConnectMain } = await import("./plugin-connect.mjs");
23
+ return pluginConnectMain(args.slice(1), { env });
24
+ }
25
+ await ensureInstalledRuntime(managed);
26
+ const current = await privateRead(join(runtimeRoot(managed), "runtime", "current.json"));
27
+ compareRuntimeVersions(current.version, current.version);
28
+ const packageDirectory = join(
29
+ runtimeRoot(managed),
30
+ "runtime-packages",
31
+ current.version,
32
+ "node_modules",
33
+ "totemlayer",
34
+ );
35
+ const metadata = JSON.parse(await readFile(join(packageDirectory, "package.json"), "utf8"));
36
+ if (metadata.name !== "totemlayer" || metadata.version !== current.version)
37
+ throw new Error("Invalid managed MCP installation.");
38
+ const target = args[0] === "connect" ? "plugin-connect.mjs" : "local-mcp.mjs";
39
+ const implementation = await import(pathToFileURL(join(packageDirectory, "bin", target)));
40
+ if (args[0] === "connect") await implementation.pluginConnectMain(args.slice(1), { env });
41
+ else await implementation.mcpMain(env);
42
+ // Long-lived sessions stage code without replacing handlers during an active tool call.
43
+ // The next MCP connection loads that code; receiver services reload themselves when idle.
44
+ const controller = new AbortController();
45
+ const check = () => checkRuntimeUpdate(managed, { signal: controller.signal }).catch(() => {});
46
+ void check();
47
+ const timer = setInterval(() => {
48
+ void check();
49
+ }, 60000);
50
+ timer.unref();
51
+ process.stdin.once("end", () => {
52
+ clearInterval(timer);
53
+ controller.abort();
54
+ });
55
+ }
56
+ export async function runtimeUpdateMain(args, env = process.env) {
57
+ const command = args[0] ?? "check";
58
+ if (!["check", "status", "pause", "resume"].includes(command))
59
+ throw new Error("Use totem update check, status, pause, or resume.");
60
+ const rest = args.slice(1).filter((arg) => arg !== "--json");
61
+ const managed = rest.length ? selectedEnvironment(["connect", ...rest], env) : env;
62
+ await ensureInstalledRuntime(managed);
63
+ if (command === "pause" || command === "resume")
64
+ await setRuntimeAutoUpdates(command === "resume", managed);
65
+ if (command === "check" || command === "resume")
66
+ await checkRuntimeUpdate(managed, { force: true });
67
+ process.stdout.write(JSON.stringify(await readRuntimeUpdateStatus(managed)) + "\n");
68
+ }
Binary file
@@ -0,0 +1 @@
1
+ {"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"611fb4d6e7fae5b5e3fc0b39b37b304070a81c0a2e6b8140c73fbe72a5be88b1","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;