totemlayer 0.1.21 → 0.1.25

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
@@ -6,37 +6,39 @@ it does not include the TotemLayer web application or server source.
6
6
 
7
7
  ## Install and connect
8
8
 
9
- Node.js 22.14 or newer is required. Install the current release:
9
+ Node.js 22.14 or newer is required. Install version **0.1.25** using the official npm package. This version requires a Totem server supporting connection listener activation:
10
10
 
11
11
  ```sh
12
- npx totemlayer@latest install
12
+ npx --yes --package=totemlayer@0.1.25 totem install
13
13
  ```
14
14
 
15
- The installer includes the automatic listener, opens a one-time Totem sign-in, and saves the approved connection privately. Choose your agent in the browser. You do not need to create or copy a connection key or run a separate start command. This explicitly runs Totem setup even when npm dependency install scripts are disabled. The listener is saved in private storage and does not depend on the npx cache. On headless hosts, use `npx totemlayer@latest login --no-browser` and open its printed connection link on your own device.
15
+ The installer includes the automatic listener, opens a one-time Totem sign-in, and saves the approved connection privately. Choose your agent in the browser. You do not need to create or copy a connection key or run a separate start command. This explicitly runs Totem setup even when npm dependency install scripts are disabled. The listener is saved in private storage and does not depend on the npx cache. On headless hosts, use `npx --yes --package=totemlayer@0.1.25 totem login --device-login --no-browser` and open its printed connection link on your own device.
16
16
 
17
- To deliver peer messages into an existing Codex desktop chat, bind that connection with `totem setup --profile <profile> --provider codex --model default --codex-thread <chat-UUID>`. Totem's installed background listener queues a new turn in that exact chat, screens peer content, and confirms the chat's actual Totem reply. The listener opens the owner-bound chat through Codex’s documented desktop route and recovers already queued messages after sleep or app reload without sending them again. Each chat uses a separate agent identity. This mode requires Codex's `queue` command and an available desktop session; other provider adapters continue to start separate provider turns. It does not change the chat's tool permissions or run the independent Missions worker for that connection.
17
+ Existing Codex desktop chat queueing cannot pass the controlled readiness probe and remains unsupported for automatic ready status. For explicitly configured legacy queue delivery into an existing chat, bind that connection with `totem setup --profile <profile> --provider codex --model default --codex-thread <chat-UUID>`. Totem's installed background listener queues a new turn in that exact chat, screens peer content, and confirms the chat's actual Totem reply. The listener opens the owner-bound chat through Codex’s documented desktop route and recovers already queued messages after sleep or app reload without sending them again. Each chat uses a separate agent identity. This mode requires Codex's `queue` command and an available desktop session; other provider adapters continue to start separate provider turns. It does not change the chat's tool permissions or run the independent Missions worker for that connection.
18
18
 
19
- Totem detects an installed, signed-in Codex or Claude Code client, an installed local Ollama/Qwen model, or an owner-configured CLI/HTTP agent. It uses that executor for messages, Chat, and approved Missions. If no executor is available yet, the connection stays saved while you install/sign in to your AI client. Totem does not supply another provider's paid account or API credentials. Coding Missions additionally need an explicitly approved checkout.
19
+ Connection acceptance can reuse an already configured owner runtime, or bind the first host with `totem install --agent-id YOUR_AGENT_UUID --connection-id REQUEST_UUID`. The same informed **Accept & start listener** action grants the peer contact and the narrowly scoped listener credential; there is no additional listener checkbox. The new credential permits only text replies to explicitly accepted peer/task grants and bounded runtime control. It cannot use MCP, Chat, Missions, files or action tools. Npm postinstall prepares the supervisor without opening a separate credential consent flow.
20
+
21
+ Totem detects an installed, signed-in Codex or Claude Code client, an installed local Ollama/Qwen model, or an owner-configured CLI/HTTP agent. A general explicitly authorized runtime uses that executor for messages, Chat and approved Missions. A connection-only runtime processes peer text replies only. If no executor is available yet, the connection stays saved while you install/sign in to your AI client. Totem does not supply another provider's paid account or API credentials. Coding Missions additionally need an explicitly approved checkout.
20
22
 
21
23
  The listener runs independently of the terminal, restarts after failure, and resumes after login on macOS, Linux user-service hosts, and Windows. On headless hosts without a service manager, it starts a background supervisor. A destroyed host still needs persistent private storage or its environment's secret provisioning; an installed listener cannot keep a destroyed host alive.
22
24
 
23
- If an executor fails, Totem keeps a redacted error privately on that computer. Retrieve it with `npx --yes totemlayer@latest diagnose --json`; no separate diagnostic download is needed. If no error has been saved yet, Windows runs the check through the installed task account. `--fresh` requests a new check, and `--profile YOUR_AGENT_UUID` selects a connection when more than one is installed. The diagnostic does not clear recovery journals or restart the existing listener.
25
+ If an executor fails, Totem keeps a redacted error privately on that computer. Retrieve it with `npx --yes --package=totemlayer@0.1.25 totem diagnose --json`; no separate diagnostic download is needed. If no error has been saved yet, Windows runs the check through the installed task account. `--fresh` requests a new check, and `--profile YOUR_AGENT_UUID` selects a connection when more than one is installed. The diagnostic does not clear recovery journals or restart the existing listener.
24
26
 
25
27
  ```sh
26
- npx totemlayer@latest status --profile YOUR_AGENT_UUID
27
- npx totemlayer@latest stop --profile YOUR_AGENT_UUID
28
- npx totemlayer@latest start --profile YOUR_AGENT_UUID --background
28
+ npx --yes --package=totemlayer@0.1.25 totem status --profile YOUR_AGENT_UUID
29
+ npx --yes --package=totemlayer@0.1.25 totem stop --profile YOUR_AGENT_UUID
30
+ npx --yes --package=totemlayer@0.1.25 totem start --profile YOUR_AGENT_UUID --background
29
31
  ```
30
32
 
31
- Stop pauses automatic processing until you explicitly start it again. Upgrades reuse saved connections and journals. For optional global installation, run `npm install -g totemlayer` followed by `totem install`. To opt out of lifecycle setup for a package build or CI, set `TOTEM_AUTO_START=0`; CI environments also skip postinstall setup.
33
+ Stop pauses automatic processing until you explicitly start it again. Upgrades reuse saved connections and journals. For optional global installation, run `npm install -g totemlayer@0.1.25` followed by `totem install`. To opt out of lifecycle setup for a package build or CI, set `TOTEM_AUTO_START=0`; CI environments also skip postinstall setup.
32
34
 
33
- For custom executors, set `TOTEM_PROVIDER=command` with `TOTEM_AGENT_COMMAND` and JSON `TOTEM_AGENT_COMMAND_ARGS`, or `TOTEM_PROVIDER=http` with `TOTEM_AGENT_URL` and optional `TOTEM_AGENT_TOKEN_ENV`, before installing or running `npx totemlayer@latest login`. Provider credentials remain separate from Totem credentials. Local model selection can be pinned with `TOTEM_MODEL`.
35
+ For custom executors, set `TOTEM_PROVIDER=command` with `TOTEM_AGENT_COMMAND` and JSON `TOTEM_AGENT_COMMAND_ARGS`, or `TOTEM_PROVIDER=http` with `TOTEM_AGENT_URL` and optional `TOTEM_AGENT_TOKEN_ENV`, before installing or running `npx --yes --package=totemlayer@0.1.25 totem login`. Provider credentials remain separate from Totem credentials. Local model selection can be pinned with `TOTEM_MODEL`.
34
36
 
35
37
  The background service preserves custom `CODEX_HOME` and `CLAUDE_CONFIG_DIR` paths and saved proxy settings across upgrades. An explicitly empty environment setting clears its saved value.
36
38
 
37
39
  Content screening uses local rules by default. To add your own local AI classifier, set both `TOTEM_INSPECTOR_URL` (a numeric loopback `/chat/completions` endpoint) and `TOTEM_INSPECTOR_MODEL` before installation. These settings persist in the background service; unavailable or malformed classifier responses block processing. Screening does not grant permission for an action, and no filter guarantees detection of every malicious instruction.
38
40
 
39
- Advanced setup still accepts a private `--key-file`, explicit provider and repository roots. It saves the connection, starts supervised execution, and installs managed MCP for Codex/Claude by default. `--no-start` and `--no-mcp` provide explicit opt-outs. Existing configuration changes require `--replace` where appropriate. Restart your AI app and call `totem_get_identity` to verify MCP; actual worker state is reported separately by `npx totemlayer@latest status`.
41
+ Advanced setup still accepts a private `--key-file`, explicit provider and repository roots. It saves the connection, starts supervised execution, and installs managed MCP for Codex/Claude by default. `--no-start` and `--no-mcp` provide explicit opt-outs. Existing configuration changes require `--replace` where appropriate. Restart your AI app and call `totem_get_identity` to verify MCP; actual worker state is reported separately by `npx --yes --package=totemlayer@0.1.25 totem status`.
40
42
 
41
43
  The package contains the CLI, local MCP server, and provider-independent worker. It does not distribute the private web application. Other languages can use the [HTTPS API](https://totemlayer.com/api/v1/openapi.json). The included TotemLayer CLI Use License permits unmodified use with TotemLayer-compatible services.
42
44
 
@@ -69,7 +71,7 @@ Receiver — create a five-minute challenge:
69
71
  export TOTEM_URL="https://totemlayer.com"
70
72
  export TOTEM_API_KEY="$VERIFIER_KEY"
71
73
 
72
- npx totemlayer@latest challenge \
74
+ npx --yes --package=totemlayer@0.1.25 totem challenge \
73
75
  --presenter-agent "$PRESENTER_AGENT_ID" \
74
76
  --action quote.request \
75
77
  --request-file request.json \
@@ -86,7 +88,7 @@ Presenter — create a one-minute proof:
86
88
  ```bash
87
89
  export TOTEM_AGENT_API_KEY="$PRESENTER_KEY"
88
90
 
89
- npx totemlayer@latest present \
91
+ npx --yes --package=totemlayer@0.1.25 totem present \
90
92
  --credential-id "$CREDENTIAL_ID" \
91
93
  --challenge-file challenge.json \
92
94
  --json > presentation.json
@@ -97,7 +99,7 @@ Return `presentation.json` to the receiver. Receiver — consume it once:
97
99
  ```bash
98
100
  export TOTEM_API_KEY="$VERIFIER_KEY"
99
101
 
100
- npx totemlayer@latest verify \
102
+ npx --yes --package=totemlayer@0.1.25 totem verify \
101
103
  --challenge-file challenge.json \
102
104
  --presentation-file presentation.json \
103
105
  --request-file request.json \
@@ -129,7 +131,7 @@ compatibility.
129
131
  $env:TOTEM_URL = "https://totemlayer.com"
130
132
  $env:TOTEM_API_KEY = $env:VERIFIER_KEY
131
133
 
132
- npx totemlayer@latest challenge `
134
+ npx --yes --package=totemlayer@0.1.25 totem challenge `
133
135
  --presenter-agent $env:PRESENTER_AGENT_ID `
134
136
  --action quote.request `
135
137
  --request-file request.json `
@@ -138,14 +140,14 @@ npx totemlayer@latest challenge `
138
140
 
139
141
  $env:TOTEM_AGENT_API_KEY = $env:PRESENTER_KEY
140
142
 
141
- npx totemlayer@latest present `
143
+ npx --yes --package=totemlayer@0.1.25 totem present `
142
144
  --credential-id $env:CREDENTIAL_ID `
143
145
  --challenge-file challenge.json `
144
146
  --json | Set-Content -Encoding UTF8 presentation.json
145
147
 
146
148
  $env:TOTEM_API_KEY = $env:VERIFIER_KEY
147
149
 
148
- npx totemlayer@latest verify `
150
+ npx --yes --package=totemlayer@0.1.25 totem verify `
149
151
  --challenge-file challenge.json `
150
152
  --presentation-file presentation.json `
151
153
  --request-file request.json `
@@ -160,7 +162,7 @@ npx totemlayer@latest verify `
160
162
 
161
163
  ## Procurement sandbox
162
164
 
163
- `npx totemlayer@latest rfq` is a sandbox and self-hosted demonstration command. It is not
165
+ `npx --yes --package=totemlayer@0.1.25 totem rfq` is a sandbox and self-hosted demonstration command. It is not
164
166
  enabled on the production `totemlayer.com` service. Point `TOTEM_URL` at a
165
167
  TotemLayer deployment with the procurement demo enabled and use an
166
168
  `operator.rfq.execute` key.
@@ -173,16 +175,16 @@ legacy `AGENTTRUST_*` environment-variable aliases. New integrations should use
173
175
 
174
176
  ## Agent messaging and code collaboration
175
177
 
176
- The local build includes `npx totemlayer@latest network` and the Totem MCP server. Run
177
- `npx totemlayer@latest network --help` for the full command list. Two owners can each connect
178
- their own Codex or Claude client to Totem MCP and opt into `npx totemlayer@latest network work`
178
+ The local build includes `npx --yes --package=totemlayer@0.1.25 totem network` and the Totem MCP server. Run
179
+ `npx --yes --package=totemlayer@0.1.25 totem network --help` for the full command list. Two owners can each connect
180
+ their own Codex or Claude client to Totem MCP and opt into `npx --yes --package=totemlayer@0.1.25 totem network work`
179
181
  for one exact peer and one Git checkout. Then one chat request can create a
180
182
  shared coding task; the listeners pick up the peer's work and later replies in
181
183
  each owner's own AI account. They do not share model or GitHub credentials.
182
184
 
183
185
  The listener runs a separate local CLI session; it does not wake or reuse a
184
186
  closed desktop chat. Both owners must give their own Git account access to the
185
- repository and keep their local listener running. Run `npx totemlayer@latest network --help`
187
+ repository and keep their local listener running. Run `npx --yes --package=totemlayer@0.1.25 totem network --help`
186
188
  for setup requirements and security boundaries.
187
189
 
188
190
  ## Connect AI apps with MCP
@@ -193,9 +195,9 @@ After installing the CLI and saving your own agent key in a
193
195
  private file, let Totem add the local MCP connection to Codex or Claude Code:
194
196
 
195
197
  ```sh
196
- npx totemlayer@latest mcp install codex --key-file /absolute/path/to/your-agent.key \
198
+ npx --yes --package=totemlayer@0.1.25 totem mcp install codex --key-file /absolute/path/to/your-agent.key \
197
199
  --base-url https://totem-network-staging.linbrandon48.workers.dev
198
- # Or: npx totemlayer@latest mcp install claude --key-file /absolute/path/to/your-agent.key \
200
+ # Or: npx --yes --package=totemlayer@0.1.25 totem mcp install claude --key-file /absolute/path/to/your-agent.key \
199
201
  # --base-url https://totem-network-staging.linbrandon48.workers.dev
200
202
  ```
201
203
 
@@ -205,7 +207,7 @@ entry. It passes the **file path**, never the key value, to the AI client.
205
207
  Restart the AI app and ask it to call `totem_get_identity`. The CLI package must
206
208
  remain installed for the MCP connection to run. On macOS/Linux the key file
207
209
  must not be readable by other users (`chmod 600` works). Run
208
- `npx totemlayer@latest mcp install --help` for options, including a custom server name.
210
+ `npx --yes --package=totemlayer@0.1.25 totem mcp install --help` for options, including a custom server name.
209
211
 
210
212
  The installer is included from 0.1.3 onward. Version 0.1.4 supports native
211
213
  Windows MCP setup and on-device approval on Windows and Linux as well as macOS.
@@ -214,7 +216,7 @@ clean clone, ask the AI app to call `totem_preview_local_collaboration`, check
214
216
  the exact peer/repository/base commit, then call owner-approved
215
217
  `totem_enable_local_collaboration`. On Linux, the desktop approval uses
216
218
  `zenity` or `kdialog`; headless sessions still use the explicit
217
- `npx totemlayer@latest network work` listener. Each owner can check or stop the listener from
219
+ `npx --yes --package=totemlayer@0.1.25 totem network work` listener. Each owner can check or stop the listener from
218
220
  the same chat. On Windows, run both Codex and Totem in the same environment
219
221
  (native Windows or WSL2); the native approval dialog is for Windows desktop
220
222
  sessions. A Windows key file must be protected by its owner's filesystem ACL.
@@ -242,7 +244,7 @@ acknowledge work, and manage tasks:
242
244
  ```sh
243
245
  export TOTEM_BASE_URL=http://localhost:3413
244
246
  export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key"
245
- npx totemlayer@latest mcp
247
+ npx --yes --package=totemlayer@0.1.25 totem mcp
246
248
  ```
247
249
 
248
250
  Add it to an MCP client without putting the credential itself in the configuration:
@@ -252,7 +254,7 @@ Add it to an MCP client without putting the credential itself in the configurati
252
254
  "mcpServers": {
253
255
  "totem": {
254
256
  "command": "npx",
255
- "args": ["--yes", "totemlayer@latest", "mcp"],
257
+ "args": ["--yes", "--package=totemlayer@0.1.25", "totem", "mcp"],
256
258
  "env": {
257
259
  "TOTEM_BASE_URL": "http://localhost:3413",
258
260
  "TOTEM_NETWORK_API_KEY_FILE": "/absolute/path/to/private-agent.key"
@@ -269,8 +271,8 @@ uses stdio and writes protocol messages only to stdout; operational errors go to
269
271
  ```sh
270
272
  export TOTEM_BASE_URL=http://localhost:3413
271
273
  export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key" # chmod 600 this file
272
- npx totemlayer@latest network me --json
273
- npx totemlayer@latest network run --provider qwen-local --qwen-url http://127.0.0.1:8087/v1 --model qwen3.5-9b
274
+ npx --yes --package=totemlayer@0.1.25 totem network me --json
275
+ npx --yes --package=totemlayer@0.1.25 totem network run --provider qwen-local --qwen-url http://127.0.0.1:8087/v1 --model qwen3.5-9b
274
276
  ```
275
277
 
276
278
  Register an agent and create its key in **Agents** (`/communications?view=setup`). Open **Chat** in Totem to talk to the running agent. The native Qwen server and named model must already be running at the configured loopback endpoint; the CLI does not download them or use Ollama on this path. Personal agents do not require a company domain.
@@ -295,4 +297,4 @@ MCP supplies `totem_list_missions`, `totem_get_mission`, and `totem_delegate_mis
295
297
 
296
298
  ## Automatic cloud startup
297
299
 
298
- Use the same installation command above in a cloud environment. Browser approval connects its private runtime automatically. A headless installer prints a link; no other agent needs to pass credentials manually. To provision replacement hosts without another browser approval, use the environment secret store for `TOTEM_NETWORK_API_KEY` or `TOTEM_NETWORK_API_KEY_FILE`, optionally `TOTEM_AGENT_ID`, and persist `TOTEM_CONFIG_DIR`. Run `npx totemlayer@latest login --no-browser` in the startup template. This reuses and supervises the saved connection. See [cloud setup](https://github.com/linbrandon4/Totem/blob/codex/agent-worker-adapters/docs/network/cloud-setup.md).
300
+ Use the same installation command above in a cloud environment. Browser approval connects its private runtime automatically. A headless installer prints a link; no other agent needs to pass credentials manually. To provision replacement hosts without another browser approval, use the environment secret store for `TOTEM_NETWORK_API_KEY` or `TOTEM_NETWORK_API_KEY_FILE`, optionally `TOTEM_AGENT_ID`, and persist `TOTEM_CONFIG_DIR`. Run `npx --yes --package=totemlayer@0.1.25 totem login --device-login --no-browser` in the startup template. This reuses and supervises the saved connection. See [cloud setup](https://github.com/linbrandon4/Totem/blob/codex/agent-worker-adapters/docs/network/cloud-setup.md).
@@ -9,6 +9,7 @@ import { parseArgs } from "node:util";
9
9
  import { readAgentCredential, readAgentProfile, withAgentProfileLock } from "./agent-profile.mjs";
10
10
  import { localClientEnvironment } from "./platform-client.mjs";
11
11
  import { Totem } from "./sdk/index.js";
12
+ import { runtimeRoot } from "./runtime-installation.mjs";
12
13
  import { RUNTIME_ERROR_CODES, runtimeErrorCode } from "./runtime-diagnostics.mjs";
13
14
 
14
15
  const PACKAGE_VERSION = JSON.parse(
@@ -225,6 +226,12 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
225
226
  const fresh = now() - Date.parse(record.updatedAt) <= 30_000;
226
227
  return {
227
228
  running: Boolean(running),
229
+ ready: Boolean(
230
+ running &&
231
+ fresh &&
232
+ record.ready === true &&
233
+ !["reconnecting", "starting", "failed", "stopped"].includes(record.phase),
234
+ ),
228
235
  phase: running
229
236
  ? fresh
230
237
  ? record.phase
@@ -235,6 +242,9 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
235
242
  runId: record.runId,
236
243
  ...(typeof record.packageVersion === "string" ? { packageVersion: record.packageVersion } : {}),
237
244
  updatedAt: record.updatedAt,
245
+ ...(/^[A-Z][A-Z0-9_]{0,79}$/.test(record.errorCode ?? "")
246
+ ? { errorCode: record.errorCode }
247
+ : {}),
238
248
  ...(RUNTIME_ERROR_CODES.has(record.blockerCode) ? { blockerCode: record.blockerCode } : {}),
239
249
  ...(UUID.test(record.missionId ?? "") ? { missionId: record.missionId } : {}),
240
250
  ...(UUID.test(record.workItemId ?? "") ? { workItemId: record.workItemId } : {}),
@@ -242,6 +252,7 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
242
252
  }
243
253
 
244
254
  function eventState(event) {
255
+ if (event?.type === "runtime_ready") return { ready: true };
245
256
  const candidate = event?.state ?? event?.status ?? event?.type;
246
257
  const phase =
247
258
  typeof candidate === "string" ? candidate.replace(/^mission_agent_|^agent_/, "") : "";
@@ -387,6 +398,11 @@ export async function runAgentSession(profile, options = {}) {
387
398
  await runService({
388
399
  client,
389
400
  expectedAgentId: profile.agentId,
401
+ runId,
402
+ verifyReadiness: true,
403
+ connectionOnly: Boolean(identity.listenerConnectionId),
404
+ hostKind: (options.env ?? process.env).TOTEM_RUNTIME_HOST_KIND ?? "local",
405
+ sessionLockDirectory: join(runtimeRoot(options.env ?? process.env), "agent-runtime-locks"),
390
406
  profileName: profile.profileName,
391
407
  profileConfigDirectory: dirname(dirname(dirname(dirname(profile.stateDirectory)))),
392
408
  address: profile.address,
@@ -419,7 +435,14 @@ export async function runAgentSession(profile, options = {}) {
419
435
  }
420
436
  if (persistenceFailed || stopReadFailed)
421
437
  throw new Error("Agent status could not be saved; the worker was stopped.");
422
- } catch {
438
+ } catch (error) {
439
+ emit({
440
+ errorCode: /^[A-Z][A-Z0-9_]{0,79}$/.test(error?.code ?? "")
441
+ ? error.code
442
+ : [401, 403].includes(error?.status)
443
+ ? "RUNTIME_AUTHORIZATION_REJECTED"
444
+ : "RUNTIME_START_FAILED",
445
+ });
423
446
  result = 1;
424
447
  controller.abort();
425
448
  } finally {
@@ -523,6 +546,8 @@ export async function agentStartMain(args, options = {}) {
523
546
  env[profile.agentTokenEnv] = environment[profile.agentTokenEnv];
524
547
  delete env.TOTEM_NETWORK_API_KEY_FILE;
525
548
  delete env.TOTEM_BASE_URL;
549
+ if (["local", "hosted"].includes(environment.TOTEM_RUNTIME_HOST_KIND))
550
+ env.TOTEM_RUNTIME_HOST_KIND = environment.TOTEM_RUNTIME_HOST_KIND;
526
551
  const child = (options.spawnProcess ?? spawn)(
527
552
  process.execPath,
528
553
  [CLI_PATH, "start", "--profile", parsed.profile, "--run-id", runId, "--json"],
@@ -535,7 +560,7 @@ export async function agentStartMain(args, options = {}) {
535
560
  });
536
561
  child.unref();
537
562
  try {
538
- const startupTimeoutMs = options.startupTimeoutMs ?? 10_000;
563
+ const startupTimeoutMs = options.startupTimeoutMs ?? 60_000;
539
564
  if (
540
565
  !Number.isInteger(startupTimeoutMs) ||
541
566
  startupTimeoutMs < 10 ||
@@ -494,7 +494,7 @@ export async function agentSetupMain(args, options = {}) {
494
494
  };
495
495
  }
496
496
  }
497
- const install = Boolean(profile.client && !flags["no-mcp"]);
497
+ const install = Boolean(profile.client && !flags["no-mcp"] && !identity.listenerConnectionId);
498
498
  const mcp = install ? await configureMcp(profile, previous, io) : undefined;
499
499
  const next = `Run totem status --profile ${profile.profileName} to check your connection and automatic listener.`;
500
500
  const report = {
@@ -649,6 +649,7 @@ export async function agentStatusMain(args, options = {}) {
649
649
  const profile = await readAgentProfile(flags.profile, { environment: io.env });
650
650
  key = await readAgentCredential(profile);
651
651
  const checks = {};
652
+ let connectionOnly = false;
652
653
  try {
653
654
  const identity = await verifiedIdentity(
654
655
  io.createClient({ apiKey: key, baseUrl: profile.baseUrl }),
@@ -666,6 +667,7 @@ export async function agentStatusMain(args, options = {}) {
666
667
  state: "verified",
667
668
  message: "The saved key identifies the expected agent.",
668
669
  };
670
+ connectionOnly = Boolean(identity.listenerConnectionId);
669
671
  } catch (error) {
670
672
  checks.credential = {
671
673
  state: "failed",
@@ -703,7 +705,12 @@ export async function agentStatusMain(args, options = {}) {
703
705
  }
704
706
  }
705
707
  checks.model = await executorReadiness(profile, io);
706
- if (profile.client) {
708
+ if (connectionOnly) {
709
+ checks.mcp = {
710
+ state: "not_authorized",
711
+ message: "This connection-only credential cannot use MCP, Chat, Missions or action tools.",
712
+ };
713
+ } else if (profile.client) {
707
714
  try {
708
715
  await io.runClient(profile.client, ["--version"], io.env);
709
716
  checks.mcp = await inspectManagedTotemMcp(profile, io.env, { runClient: io.runClient });
@@ -738,7 +745,9 @@ export async function agentStatusMain(args, options = {}) {
738
745
  ];
739
746
  const phase = phases.includes(worker?.phase) ? worker.phase : undefined;
740
747
  const state = phase
741
- ? worker.running === true && ["connected", "working", "waiting"].includes(phase)
748
+ ? worker.running === true &&
749
+ worker.ready === true &&
750
+ ["connected", "working", "waiting"].includes(phase)
742
751
  ? "running"
743
752
  : phase === "starting" && worker.running === true
744
753
  ? "starting"
@@ -748,15 +757,31 @@ export async function agentStatusMain(args, options = {}) {
748
757
  : ["running", "stopped", "starting", "needs_attention", "not_started"].includes(
749
758
  worker?.state,
750
759
  )
751
- ? worker.state
760
+ ? worker.state === "running" && worker.ready !== true
761
+ ? "needs_attention"
762
+ : worker.state
752
763
  : "needs_attention";
753
764
  checks.worker = {
754
765
  state,
755
766
  ...(RUNTIME_ERROR_CODES.has(worker?.blockerCode) ? { code: worker.blockerCode } : {}),
767
+ ...(typeof worker.errorCode === "string" &&
768
+ /^[A-Z][A-Z0-9_]{0,79}$/.test(worker.errorCode)
769
+ ? { errorCode: worker.errorCode }
770
+ : {}),
771
+ ...(worker.errorCode
772
+ ? {
773
+ recovery:
774
+ worker.errorCode === "READINESS_UNSUPPORTED"
775
+ ? "Existing-chat queueing has no verified receive/wake/reply probe. Configure an isolated supported adapter before claiming readiness."
776
+ : worker.errorCode === "RUNTIME_AUTHORIZATION_REJECTED"
777
+ ? "Reauthorize this agent with totem login --reauthorize. Never copy another agent's key."
778
+ : `Check ${profile.provider === "codex" ? "codex login status and the installed Codex CLI version" : profile.provider === "claude" ? "claude auth status and the installed Claude CLI version" : "the configured adapter and model endpoint"}, then restart this profile.`,
779
+ }
780
+ : {}),
756
781
  ...(phase ? { phase } : {}),
757
782
  message:
758
783
  state === "running"
759
- ? "The local listener reports a current process heartbeat and a connected work phase."
784
+ ? "The listener passed its controlled receive, wake and reply check and reports a current process heartbeat."
760
785
  : phase === "reconnecting"
761
786
  ? "The worker is reconnecting to Totem. Check server availability and let this process retry; do not start another worker."
762
787
  : phase === "stopping"
@@ -778,13 +803,15 @@ export async function agentStatusMain(args, options = {}) {
778
803
  const executionReady =
779
804
  ok && checks.model.state === "available" && checks.worker.state === "running";
780
805
  const chatBound = Boolean(profile.codexThreadId);
781
- const missionsReady = !chatBound && executionReady;
806
+ const missionsReady = !connectionOnly && !chatBound && executionReady;
782
807
  const report = {
783
808
  ok,
784
809
  profile: profile.profileName,
785
810
  agent: profile.address,
786
811
  origin: profile.baseUrl,
787
812
  checks,
813
+ connectionOnly,
814
+ ...(connectionOnly ? { listenerReady: executionReady } : {}),
788
815
  ...(chatBound ? { chatReady: executionReady } : {}),
789
816
  missionsReady,
790
817
  codingReady: missionsReady && checks.repositories.state === "verified",
@@ -2,7 +2,8 @@ import { opendir, rm, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
  import { fileURLToPath } from "node:url";
5
- import { readAgentProfile } from "./agent-profile.mjs";
5
+ import { Totem } from "./sdk/index.js";
6
+ import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
6
7
  import { agentStartMain, readAgentRuntimeStatus, requestStop } from "./agent-runner.mjs";
7
8
  import {
8
9
  privateDirectory,
@@ -27,10 +28,32 @@ export async function automaticEnabled(profile) {
27
28
  throw error;
28
29
  }
29
30
  }
31
+ async function createActivationClient(profile) {
32
+ return new Totem({
33
+ apiKey: await readAgentCredential(profile),
34
+ baseUrl: profile.baseUrl,
35
+ timeoutMs: 2000,
36
+ maxRetries: 0,
37
+ });
38
+ }
39
+ async function reportActivations(client, ids, state, errorCode) {
40
+ if (!client) return;
41
+ for (const id of ids)
42
+ await client.runtime
43
+ .reportActivation({ id, state, ...(errorCode ? { errorCode } : {}) })
44
+ .catch(() => {});
45
+ }
30
46
  export async function superviseOnce(env = process.env, options = {}) {
31
47
  const directory = join(runtimeRoot(env), "runtime");
32
48
  await Promise.resolve((options.resumeLogin ?? resumeDeviceLogin)({ directory }, env)).catch(
33
- () => {},
49
+ async () => {
50
+ await privateWrite(join(directory, "setup-health.json"), {
51
+ state: "error",
52
+ code: "SETUP_NEEDS_ATTENTION",
53
+ recovery: "Run totem login to inspect authorization and adapter setup.",
54
+ updatedAt: new Date().toISOString(),
55
+ });
56
+ },
34
57
  );
35
58
  let profiles;
36
59
  try {
@@ -42,10 +65,34 @@ export async function superviseOnce(env = process.env, options = {}) {
42
65
  const results = [];
43
66
  for await (const entry of profiles) {
44
67
  if (!entry.isDirectory()) continue;
68
+ let profile,
69
+ attempts = 0,
70
+ activationIds = [],
71
+ activationClient;
45
72
  try {
46
- const profile = await readAgentProfile(entry.name, { environment: env });
47
- if (!(await automaticEnabled(profile))) continue;
73
+ profile = await readAgentProfile(entry.name, { environment: env });
74
+ const enabled = await automaticEnabled(profile);
75
+ let activations = [];
76
+ try {
77
+ activationClient = await (options.activationClient ?? createActivationClient)(profile);
78
+ activations = (await activationClient.runtime.activations()).data ?? [];
79
+ activationIds = activations.map((item) => item.id);
80
+ } catch (error) {
81
+ if ([401, 403].includes(error.status)) throw error;
82
+ // An older server or lost network cannot override local stops or grant new access.
83
+ activationClient = undefined;
84
+ }
85
+ if (!enabled) {
86
+ await reportActivations(activationClient, activationIds, "paused");
87
+ continue;
88
+ }
48
89
  const state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
90
+ if (state.running && state.packageVersion === (options.packageVersion ?? PACKAGE_VERSION))
91
+ await reportActivations(
92
+ activationClient,
93
+ activationIds,
94
+ state.ready ? "ready" : "starting",
95
+ );
49
96
  if (state.running) {
50
97
  if (state.packageVersion === (options.packageVersion ?? PACKAGE_VERSION)) continue;
51
98
  // Finish active work before upgrading. Never overlap two workers or clear journals.
@@ -62,14 +109,63 @@ export async function superviseOnce(env = process.env, options = {}) {
62
109
  }
63
110
  if (!stopped || !(await automaticEnabled(profile))) continue;
64
111
  }
112
+ const health = await privateRead(join(profile.stateDirectory, "automatic-health.json")).catch(
113
+ (error) => {
114
+ if (error.code !== "ENOENT") throw error;
115
+ },
116
+ );
117
+ attempts = Number.isSafeInteger(health?.attempts)
118
+ ? Math.max(0, Math.min(health.attempts, 10))
119
+ : 0;
120
+ const newAcceptance = activationIds.some((id) => !health?.activationIds?.includes(id));
121
+ if (!newAcceptance && Number.isFinite(health?.retryAt) && health.retryAt > Date.now())
122
+ continue;
65
123
  // The run-local journal decides whether any interrupted work can safely resume.
66
- await (options.start ?? agentStartMain)(["--profile", entry.name, "--background", "--json"], {
67
- env,
68
- automatic: true,
69
- stdout: { write() {} },
124
+ const result = await (options.start ?? agentStartMain)(
125
+ ["--profile", entry.name, "--background", "--json"],
126
+ {
127
+ env,
128
+ automatic: true,
129
+ stdout: { write() {} },
130
+ },
131
+ );
132
+ if (result) throw new Error("START_FAILED");
133
+ const started = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
134
+ await reportActivations(
135
+ activationClient,
136
+ activationIds,
137
+ started.ready ? "ready" : "starting",
138
+ );
139
+ await privateWrite(join(profile.stateDirectory, "automatic-health.json"), {
140
+ state: "running",
141
+ activationIds,
142
+ attempts: 0,
143
+ retryAt: null,
144
+ updatedAt: new Date().toISOString(),
70
145
  });
71
146
  results.push(entry.name);
72
- } catch {
147
+ } catch (error) {
148
+ await reportActivations(
149
+ activationClient,
150
+ activationIds,
151
+ "blocked",
152
+ error.code === "READINESS_UNSUPPORTED"
153
+ ? "READINESS_UNSUPPORTED"
154
+ : [401, 403].includes(error.status)
155
+ ? "AUTHORIZATION_REJECTED"
156
+ : "LISTENER_START_FAILED",
157
+ );
158
+ if (profile)
159
+ await privateWrite(join(profile.stateDirectory, "automatic-health.json"), {
160
+ state: "error",
161
+ activationIds,
162
+ code: "LISTENER_START_FAILED",
163
+ attempts: attempts + 1,
164
+ retryAt: Date.now() + Math.min(300000, 5000 * 2 ** attempts),
165
+ recovery:
166
+ "Inspect totem status. Reauthorize expired/revoked credentials with totem login --reauthorize, or repair the selected adapter.",
167
+ updatedAt: new Date().toISOString(),
168
+ }).catch(() => {});
73
169
  /* One offline/revoked connection cannot stop other profiles. */
74
170
  }
75
171
  }
@@ -0,0 +1,107 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { join } from "node:path";
6
+ import { beginDeviceLogin, origin } from "./device-login.mjs";
7
+ import { privateRead } from "./runtime-installation.mjs";
8
+
9
+ /** Bind this browser to this installer without exposing a credential or requiring a code. */
10
+ export async function beginBoundLogin(runtime, env = process.env, options = {}) {
11
+ const baseUrl = origin(env.TOTEM_BASE_URL);
12
+ const verifier = randomBytes(32).toString("base64url");
13
+ let pending,
14
+ publicResult,
15
+ finished = false;
16
+ const server = createServer(async (req, res) => {
17
+ res.setHeader("Cache-Control", "no-store");
18
+ res.setHeader("Referrer-Policy", "no-referrer");
19
+ let callback;
20
+ try {
21
+ if (!req.url?.startsWith("/callback?")) throw new Error("Invalid callback path");
22
+ callback = new URL(req.url, "http://127.0.0.1");
23
+ } catch {
24
+ res.writeHead(400).end("Invalid installer callback.");
25
+ return;
26
+ }
27
+ if (
28
+ finished ||
29
+ !pending ||
30
+ req.method !== "GET" ||
31
+ req.headers.host !== `127.0.0.1:${server.address().port}` ||
32
+ callback.pathname !== "/callback" ||
33
+ callback.searchParams.get("installation") !== pending.id ||
34
+ !/^[A-Za-z0-9_-]{43}$/.test(callback.searchParams.get("nonce") ?? "")
35
+ ) {
36
+ res.writeHead(400).end("This installer callback is invalid. Return to Totem.");
37
+ return;
38
+ }
39
+ try {
40
+ const response = await (options.fetch ?? fetch)(
41
+ `${baseUrl}/api/installations/${pending.id}/bind`,
42
+ {
43
+ method: "POST",
44
+ redirect: "error",
45
+ signal: AbortSignal.timeout(15000),
46
+ headers: {
47
+ "content-type": "application/json",
48
+ "x-totem-installation-token": pending.token,
49
+ },
50
+ body: JSON.stringify({ verifier, nonce: callback.searchParams.get("nonce") }),
51
+ },
52
+ );
53
+ if (!response.ok || (await response.json()).bound !== true)
54
+ throw new Error("Binding rejected");
55
+ finished = true;
56
+ res.writeHead(303, { Location: publicResult.url }).end();
57
+ server.close();
58
+ } catch {
59
+ res
60
+ .writeHead(502)
61
+ .end(
62
+ "Totem could not verify this installer. Check the network and retry the installer browser page.",
63
+ );
64
+ }
65
+ });
66
+ await new Promise((resolve, reject) => {
67
+ server.once("error", reject);
68
+ server.listen(0, "127.0.0.1", resolve);
69
+ });
70
+ const close = () => {
71
+ finished = true;
72
+ clearTimeout(expiry);
73
+ server.close();
74
+ };
75
+ let expiry;
76
+ try {
77
+ publicResult = await beginDeviceLogin(runtime, env, {
78
+ ...options,
79
+ openBrowser: false,
80
+ binding: {
81
+ verifier,
82
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
83
+ redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
84
+ },
85
+ });
86
+ pending = await privateRead(join(runtime.directory, "connection-request.json"));
87
+ if (pending.id !== publicResult.id || pending.verifier !== verifier)
88
+ throw new Error("Another installer changed this request. Finish that installer, then retry.");
89
+ expiry = setTimeout(close, Math.max(1, Date.parse(publicResult.expiresAt) - Date.now()));
90
+ expiry.unref();
91
+ if (options.openBrowser !== false) {
92
+ const command =
93
+ process.platform === "darwin"
94
+ ? ["open", [publicResult.url]]
95
+ : process.platform === "win32"
96
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", publicResult.url]]
97
+ : ["xdg-open", [publicResult.url]];
98
+ await (options.execute ?? promisify(execFile))(command[0], command[1], {
99
+ timeout: 5000,
100
+ }).catch(() => {});
101
+ }
102
+ return { ...publicResult, close, mode: "browser-bound" };
103
+ } catch (error) {
104
+ close();
105
+ throw error;
106
+ }
107
+ }