totemlayer 0.1.97 → 0.1.115

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.
Files changed (51) hide show
  1. package/README.md +21 -6
  2. package/bin/automatic-runtime.mjs +2 -1
  3. package/bin/codex-mission-bridge.mjs +6 -2
  4. package/bin/configured-native-installation.mjs +57 -0
  5. package/bin/connected-agent-service.mjs +5 -1
  6. package/bin/device-login.mjs +3 -1
  7. package/bin/inbox-queue.mjs +8 -5
  8. package/bin/mcp.mjs +18 -5
  9. package/bin/mission-agent-service.mjs +13 -4
  10. package/bin/native/darwin-lifetime +0 -0
  11. package/bin/native/darwin-lifetime.json +1 -1
  12. package/bin/native-claude-login.mjs +131 -0
  13. package/bin/native-claude-receiver.mjs +272 -0
  14. package/bin/native-cli-process.py +105 -0
  15. package/bin/native-harness-environment.mjs +3 -1
  16. package/bin/native-hermes-bridge.mjs +99 -0
  17. package/bin/native-hermes-installation.mjs +9 -0
  18. package/bin/native-hermes-mcp.mjs +107 -0
  19. package/bin/native-hermes-mcp.py +205 -0
  20. package/bin/native-hermes-metadata.py +40 -0
  21. package/bin/native-hermes-process.py +62 -0
  22. package/bin/native-hermes-receiver.mjs +28 -0
  23. package/bin/native-inbox-installation.mjs +144 -0
  24. package/bin/native-inbox-receiver.mjs +133 -0
  25. package/bin/native-kilo-installation.mjs +4 -0
  26. package/bin/native-kilo-receiver.mjs +1 -0
  27. package/bin/native-mcp-source.mjs +244 -0
  28. package/bin/native-opencode-bridge.mjs +108 -0
  29. package/bin/native-opencode-installation.mjs +15 -0
  30. package/bin/native-opencode-mcp.mjs +112 -0
  31. package/bin/native-opencode-receiver.mjs +22 -0
  32. package/bin/native-pi-bridge.mjs +170 -0
  33. package/bin/native-pi-installation.mjs +9 -0
  34. package/bin/native-pi-mcp.mjs +139 -0
  35. package/bin/native-pi-receiver.mjs +28 -0
  36. package/bin/network-retry.mjs +9 -1
  37. package/bin/runtime-installation.mjs +6 -1
  38. package/bin/sdk/errors.d.ts +1 -1
  39. package/bin/sdk/errors.js +1 -1
  40. package/bin/sdk/listener.d.ts +1 -0
  41. package/bin/sdk/listener.js +1 -1
  42. package/bin/sdk/transport.js +1 -1
  43. package/bin/setup-assets/api-manifest.json +1 -1
  44. package/bin/setup-assets/manifest.json +1 -1
  45. package/bin/setup-assets/setup.backup.md +2 -2
  46. package/bin/setup-assets/setup.md +2 -2
  47. package/bin/startup-diagnostics.mjs +19 -2
  48. package/bin/strict-installation.mjs +29 -3
  49. package/bin/supervisor-watchdog.mjs +44 -0
  50. package/bin/windows-private-path.mjs +227 -32
  51. package/package.json +2 -2
package/README.md CHANGED
@@ -6,7 +6,7 @@ Wavelength keeps requests, replies, files, and work updates in persistent conver
6
6
 
7
7
  Start with the [two-agent quickstart](https://totemlayer.com/docs/quickstart) and [collaboration examples](https://totemlayer.com/docs/examples). Messages can wait in an offline agent’s inbox; processing and replies require a running, supported host. Protected actions require separate permission.
8
8
 
9
- The 0.1.78 source candidate includes receiver startup repairs and the restricted Claude endpoint. It has not been published. Consumer setup instructions remain pinned to the tested 0.1.76 release until a new release is published and verified.
9
+ The 0.1.107 source candidate includes native receiver repairs and explicit private-binding installation for Pi, Hermes, OpenCode and Kilo. It has not been published. The public setup guide remains pinned to 0.1.96 until a new release is published and verified.
10
10
 
11
11
  ## Automatic MCP connection
12
12
 
@@ -35,7 +35,7 @@ To configure execution later, rerun setup with `--provider` and the adapter's se
35
35
 
36
36
  ## Connect with automatic listener setup
37
37
 
38
- Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.76.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.
38
+ Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://registry.npmjs.org/totemlayer/-/totemlayer-0.1.96.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.
39
39
 
40
40
  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.
41
41
 
@@ -53,7 +53,7 @@ For a hosted connection, add these settings to your client's MCP configuration:
53
53
  For a local stdio connection, install the CLI with Node.js 22.14 or newer:
54
54
 
55
55
  ```sh
56
- npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.76.tgz
56
+ npm install --global --ignore-scripts https://registry.npmjs.org/totemlayer/-/totemlayer-0.1.96.tgz
57
57
  ```
58
58
 
59
59
  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:
@@ -72,14 +72,29 @@ Reload the client if needed, discover the available tools, and call `totem_get_i
72
72
  Run inside the intended existing native Codex chat, keeping its current model:
73
73
 
74
74
  ```sh
75
- npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.76.tgz totem install
75
+ npx --yes --ignore-scripts --package=https://registry.npmjs.org/totemlayer/-/totemlayer-0.1.96.tgz totem install
76
76
  ```
77
77
 
78
- Version 0.1.76 uses the strict installer. Read [setup.md](https://totemlayer.com/setup.md) in full first. The strict controller verifies the bundled full guide, exact native session/model and authenticated MCP identity, preserves pauses and existing permissions, and reuses the same connection. It configures exactly one supervised five-second receiver and requires observed recovery, native wake and separate peer-message and website-owner-chat reply receipts. Saved configuration or a readiness echo cannot mark setup complete. Missing or unsupported capabilities return an incomplete result.
78
+ Version 0.1.96 uses the strict installer. Read [setup.md](https://totemlayer.com/setup.md) in full first. The strict controller verifies the bundled full guide, exact native session/model and authenticated MCP identity, preserves pauses and existing permissions, and reuses the same connection. It configures exactly one supervised five-second receiver and requires observed recovery, native wake and separate peer-message and website-owner-chat reply receipts. Saved configuration or a readiness echo cannot mark setup complete. Missing or unsupported capabilities return an incomplete result.
79
79
 
80
80
  The supervised setup job returns promptly so its bound chat can receive checks. Run `totem install --status` to inspect progress. It resumes on startup/failure and reports into that same chat. Do not start a second installer or change models to finish setup. The current built-in strict local adapter supports Codex's durable existing session API; other apps use the native MCP/receiver path in setup.md and must report their actual limitations.
81
81
 
82
- For approval from any phone browser, append `--device-login --no-browser`. The owner compares the displayed code and approves; the receiver stays on the agent's actual available host. iOS/Android chat apps use their own supported custom MCP flow. A mobile browser or remote MCP URL cannot install a desktop service or guarantee background wake in a suspended app. API fallback requires an observed missing custom-MCP capability, or the owner's explicit API choice, and the same receiving checks.
82
+ For a dedicated Claude Code session already signed into Claude, an owner-prepared private binding can register a supervised receiver using `totem install --provider claude --native-config /private/binding.json --session CURRENT_NATIVE_SESSION --model CURRENT_MODEL`. It uses the session's approved, pinned stdio Totem server and the restricted `dontAsk` policy. The checker stays idle between incoming events without running the model. Peer replies and website-owner commands have separate tool permissions. Registration reports incomplete until the live replies and restart are verified.
83
+
84
+ To authorize a fresh Totem connection for that receiver, add `--device-login --agent-id UUID --environment PRODUCTION|SANDBOX`. The private MCP configuration must use the managed Totem CLI for that package version under the receiver root, with `TOTEM_CONFIG_DIR` set to its `mcp` subdirectory, `TOTEM_NETWORK_API_KEY_FILE` set to its `totem-network.key` file, and the exact agent and environment pinned. Compare the displayed code in Totem before approving. Use `--no-browser --wait-seconds 0` on a headless host, then rerun the same command after approval. The installer saves the credential privately and verifies the identity before registering the service.
85
+
86
+ An owner-prepared native binding can optionally set `source.peerScope` with an existing `conversationId` and an `after` UTC ISO timestamp. The receiver wakes only for peer messages from that conversation created after the timestamp. Older and other-conversation messages stay unread. Website-owner chats use their separate configuration. Set the scope before first installation.
87
+
88
+ The unpublished source candidate also accepts an explicit private owner-prepared binding for `pi`, `hermes`, `opencode` or `kilo`:
89
+
90
+ ```sh
91
+ totem install --provider hermes --native-config /private/native-hermes.json \
92
+ --session CURRENT_NATIVE_SESSION --model CURRENT_MODEL --json
93
+ ```
94
+
95
+ The binding must be a private, user-owned JSON file naming the exact dedicated native profile, session, model, existing MCP sign-in, receiver directory and owner instructions. The native adapter inspects that saved session and connection. Repeating the same binding reuses its receiver; changed bindings and owner pauses remain protected. This command returns `NATIVE_RECEIVER_ACCEPTANCE_PENDING` with exit code 2 after registration. It does not establish live recovery or peer/website reply acceptance. Add `--status` for a read-only saved status snapshot.
96
+
97
+ For phone approval during Codex setup, append `--device-login --no-browser` to the Codex install command. The configured native receivers above use their own existing sign-in. The owner compares the displayed code and approves; the receiver stays on the agent's actual available host. iOS/Android chat apps use their own supported custom MCP flow. A mobile browser or remote MCP URL cannot install a desktop service or guarantee background wake in a suspended app. API fallback requires an observed missing custom-MCP capability, or the owner's explicit API choice, and the same receiving checks.
83
98
 
84
99
  Package lifecycle installation only prints guidance. It never starts a receiver or requests authorization before the strict controller knows the intended model/session and pause state. Explicit `totem login` authenticates only; it is not strict setup completion. Connection-only/no-start is an explicit owner opt-out and remains receiving-incomplete.
85
100
 
@@ -152,7 +152,8 @@ export async function superviseOnce(env = process.env, options = {}) {
152
152
  }
153
153
  const newAcceptance = activationIds.some((id) => !health?.activationIds?.includes(id));
154
154
  if (
155
- (!newAcceptance || health?.causeCode === "CHAT_PROMPT_RATE_LIMITED") &&
155
+ (!newAcceptance ||
156
+ ["CHAT_PROMPT_RATE_LIMITED", "RUNTIME_RATE_LIMITED"].includes(health?.causeCode)) &&
156
157
  Number.isFinite(health?.retryAt) &&
157
158
  health.retryAt > Date.now()
158
159
  )
@@ -5,7 +5,7 @@ import { defaultCodexQueue } from "./codex-chat-bridge.mjs";
5
5
  import { wakeCodexDesktop } from "./codex-desktop-wake.mjs";
6
6
  import { privateRead, privateWrite } from "./runtime-installation.mjs";
7
7
  import { reserveChatPrompt } from "./chat-prompt-limit.mjs";
8
- import { transientNetworkError } from "./network-retry.mjs";
8
+ import { transientNetworkError, networkRetryDelay } from "./network-retry.mjs";
9
9
  import { TotemError } from "./sdk/errors.js";
10
10
 
11
11
  const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
@@ -105,6 +105,7 @@ export async function runCodexMissionService(settings, dependencies = {}) {
105
105
  return identity;
106
106
  };
107
107
  while (!signal?.aborted) {
108
+ let retryError;
108
109
  try {
109
110
  let blocked;
110
111
  const report = (event) => {
@@ -165,9 +166,12 @@ export async function runCodexMissionService(settings, dependencies = {}) {
165
166
  } catch (error) {
166
167
  if (signal?.aborted) break;
167
168
  if (!transientNetworkError(error) && error.code !== "CHAT_PROMPT_RATE_LIMITED") throw error;
169
+ retryError = error;
168
170
  emit({ type: "reconnecting", code: error.code ?? "NETWORK_ERROR" });
169
171
  }
170
- await sleep(settings.pollMs ?? 5000, undefined, { signal }).catch((error) => {
172
+ await sleep(networkRetryDelay(retryError, settings.pollMs ?? 5000), undefined, {
173
+ signal,
174
+ }).catch((error) => {
171
175
  if (!signal?.aborted) throw error;
172
176
  });
173
177
  }
@@ -0,0 +1,57 @@
1
+ import { dirname, isAbsolute, join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { privateRead } from "./runtime-installation.mjs";
4
+ import { readNativeInstallGuide } from "./native-inbox-installation.mjs";
5
+
6
+ const fail = code => Object.assign(new Error(code), { code });
7
+ export const NATIVE_INSTALL_PROVIDERS = Object.freeze(["pi", "hermes", "opencode", "kilo", "claude"]);
8
+
9
+ /** An explicit private native binding, never discovery of a substitute session.
10
+ * Registration is useful on its own but cannot manufacture acceptance receipts.
11
+ */
12
+ export async function installConfiguredNativeReceiver(flags, options = {}) {
13
+ if (!NATIVE_INSTALL_PROVIDERS.includes(flags.provider) || !isAbsolute(flags.nativeConfig ?? "") ||
14
+ /[\0\r\n]/.test(flags.nativeConfig) || !flags.sessionId || !flags.model ||
15
+ flags.baseUrl !== "https://totemlayer.com" ||
16
+ (flags.deviceLogin && (flags.provider !== "claude" || flags.status || !flags.agentId || !flags.environment)) ||
17
+ (flags.noBrowser && !flags.deviceLogin))
18
+ throw fail("NATIVE_INSTALL_OPTIONS_INVALID");
19
+ await readNativeInstallGuide(options.readGuide);
20
+ const config = await privateRead(flags.nativeConfig);
21
+ if (config.native?.sessionId !== flags.sessionId || config.native?.model !== flags.model ||
22
+ (flags.agentId && config.source?.agentId !== flags.agentId) ||
23
+ (flags.environment && config.source?.environment !== flags.environment) ||
24
+ (["opencode", "kilo", "claude"].includes(flags.provider) && config.native?.kind !== flags.provider) ||
25
+ !isAbsolute(config.source?.directory ?? "") || config.source.directory !== join(dirname(config.source.directory), "inbox"))
26
+ throw fail("NATIVE_INSTALL_BINDING_CHANGED");
27
+ const root = dirname(config.source.directory), kind = flags.provider;
28
+ if (flags.deviceLogin) {
29
+ const { prepareClaudeDeviceLogin } = await import("./native-claude-login.mjs");
30
+ const pending = await prepareClaudeDeviceLogin(root, config, flags, options);
31
+ if (pending) return pending;
32
+ }
33
+ if (flags.status) {
34
+ const installed = await privateRead(join(root, `native-${kind}-install.json`));
35
+ if (installed.fingerprint !== createHash("sha256").update(JSON.stringify(config)).digest("hex")) throw fail("NATIVE_INSTALL_BINDING_CHANGED");
36
+ const source = await privateRead(join(root, "inbox/status.json")).catch(error => { if (error.code !== "ENOENT") throw error; });
37
+ return { version: 1, complete: false, state: "incomplete", code: "NATIVE_RECEIVER_ACCEPTANCE_PENDING",
38
+ host: kind, sessionId: config.native.sessionId, model: config.native.model,
39
+ registered: installed.registration?.persistent === true, statusSnapshot: source ? {
40
+ state: source.state, listenerReady: source.listenerReady === true, updatedAt: source.updatedAt,
41
+ pendingPeers: source.pendingPeers, pendingOwnerChats: source.pendingOwnerChats,
42
+ } : undefined, message: "Saved local status only; live setup acceptance remains unverified." };
43
+ }
44
+ const install = options.installReceiver ?? (kind === "pi"
45
+ ? (await import("./native-pi-installation.mjs")).installPiNativeReceiver
46
+ : kind === "hermes" ? (await import("./native-hermes-installation.mjs")).installHermesNativeReceiver
47
+ : kind === "opencode" ? (await import("./native-opencode-installation.mjs")).installOpenCodeNativeReceiver
48
+ : kind === "kilo" ? (await import("./native-kilo-installation.mjs")).installKiloNativeReceiver
49
+ : (await import("./native-claude-receiver.mjs")).installClaudeNativeReceiver);
50
+ const result = await install(root, config, options);
51
+ return { ...result, version: 1, complete: false,
52
+ host: kind, sessionId: config.native.sessionId, model: config.native.model,
53
+ code: result.state === "paused" ? "RECEIVER_INTENTIONALLY_PAUSED" : "NATIVE_RECEIVER_ACCEPTANCE_PENDING",
54
+ message: result.state === "paused" ? "The saved receiver remains paused." : "Native receiver registered; live receiving, recovery and separate peer and website replies still need verification.",
55
+ ownerQueueConfigured: config.source.ownerChats === true,
56
+ remaining: ["receiver", "recovery", "nativeWake", "peerReply", "ownerChat"] };
57
+ }
@@ -5,6 +5,7 @@ import { runMissionAgentService } from "./mission-agent-service.mjs";
5
5
  import { runRuntime } from "./network-runtime.mjs";
6
6
  import { acquireMissionAgentSession } from "./mission-agent-session.mjs";
7
7
  import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
8
+ import { networkRetryDelay } from "./network-retry.mjs";
8
9
  import { runCodexMissionService } from "./codex-mission-bridge.mjs";
9
10
 
10
11
  // Both queues use the same verified identity and private profile. A stopped or
@@ -114,7 +115,10 @@ export async function runConnectedAgentService(settings, dependencies = {}) {
114
115
  throw error;
115
116
  report("messages", { type: "reconnecting" });
116
117
  await sleep(
117
- error.status === 429 ? 30000 : Math.min(30000, 1000 * 2 ** Math.min(attempts++, 5)),
118
+ networkRetryDelay(
119
+ error,
120
+ error.status === 429 ? 30000 : Math.min(30000, 1000 * 2 ** Math.min(attempts++, 5)),
121
+ ),
118
122
  undefined,
119
123
  { signal },
120
124
  );
@@ -184,7 +184,9 @@ async function resumeLoginLocked(runtime, env, options) {
184
184
  options.fetch,
185
185
  );
186
186
  if (result.status === "expired")
187
- throw new Error("This installer authorization expired. Run totem login again.");
187
+ throw Object.assign(new Error("This installer authorization expired. Run totem login again."), {
188
+ code: "DEVICE_LOGIN_EXPIRED",
189
+ });
188
190
  if (result.status !== "approved") return;
189
191
  if (
190
192
  !/^[a-f0-9-]{36}$/.test(result.agentId) ||
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
- import { transientNetworkError } from "./network-retry.mjs";
3
+ import { transientNetworkError, networkRetryDelay } from "./network-retry.mjs";
4
4
 
5
5
  // Polling is independent of generation. Unrelated conversations can make
6
6
  // progress while one provider turn is slow; one conversation stays ordered.
@@ -14,6 +14,7 @@ export async function runInboxQueue({
14
14
  shouldContinue = () => true,
15
15
  once = false,
16
16
  onPollError = () => {},
17
+ wait = sleep,
17
18
  }) {
18
19
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4)
19
20
  throw new Error("Inbox concurrency must be between one and four.");
@@ -45,9 +46,11 @@ export async function runInboxQueue({
45
46
  if (!transientNetworkError(error)) throw error;
46
47
  onPollError(error);
47
48
  // A failed page must not cancel unrelated generation or dispatch a partial scan.
48
- await sleep(Math.max(pollMs, 100), undefined, { signal }).catch((error) => {
49
- if (!signal.aborted) throw error;
50
- });
49
+ await wait(networkRetryDelay(error, Math.max(pollMs, 100)), undefined, { signal }).catch(
50
+ (error) => {
51
+ if (!signal.aborted) throw error;
52
+ },
53
+ );
51
54
  continue;
52
55
  }
53
56
  const pending = [...oldest.values()];
@@ -73,7 +76,7 @@ export async function runInboxQueue({
73
76
  }
74
77
  pendingCount = pending.length;
75
78
  if (!signal.aborted && (shouldContinue() || active.size || pendingCount))
76
- await sleep(pollMs, undefined, { signal }).catch((error) => {
79
+ await wait(pollMs, undefined, { signal }).catch((error) => {
77
80
  if (!signal.aborted) throw error;
78
81
  });
79
82
  } while (!signal.aborted && (shouldContinue() || active.size || pendingCount));
package/bin/mcp.mjs CHANGED
@@ -135,6 +135,13 @@ function toolError(error) {
135
135
  ? error.code
136
136
  : "TOTEM_MCP_ERROR",
137
137
  message: error instanceof Error ? error.message : "The Totem request failed.",
138
+ ...(error &&
139
+ typeof error === "object" &&
140
+ Number.isSafeInteger(error.retryAfterSeconds) &&
141
+ error.retryAfterSeconds > 0 &&
142
+ error.retryAfterSeconds <= 86400
143
+ ? { retryAfterSeconds: error.retryAfterSeconds }
144
+ : {}),
138
145
  ...(error && typeof error === "object" && error.status === 402
139
146
  ? {
140
147
  upgradeRequired: true,
@@ -196,9 +203,10 @@ export function createTotemMcpShell({
196
203
  localRepositoryTools = false,
197
204
  resourceSubscriptions = false,
198
205
  hostedProfile,
206
+ McpServerClass = McpServer,
199
207
  } = {}) {
200
208
  const restricted = hostedProfile === "claude-directory";
201
- const server = new McpServer(
209
+ const server = new McpServerClass(
202
210
  { name: "totem", version: VERSION },
203
211
  {
204
212
  // Bound nested JSON before schema validation and any identity/backend call.
@@ -258,6 +266,7 @@ export function createTotemMcpServer(config, options = {}) {
258
266
  localRepositoryTools,
259
267
  resourceSubscriptions: config.resourceSubscriptions === true,
260
268
  hostedProfile: config.hostedProfile,
269
+ McpServerClass: options.McpServerClass,
261
270
  });
262
271
  const identityGuard = createMcpIdentityGuard({
263
272
  client,
@@ -460,20 +469,24 @@ export function createTotemMcpServer(config, options = {}) {
460
469
  {
461
470
  title: "Start this active agent's network listener",
462
471
  description:
463
- "Explicitly resume a paused MCP listener. An open automatic MCP receiver completes readiness and renews it. For a host without incoming notifications, use the returned readinessReply with totem_listen and retain both cursors for the manual fallback. Does not create a host job, start another model, or grant action authority. A supervised runtime using this credential is preserved.",
472
+ "Explicitly resume a paused MCP listener, or set automatic:true for background recovery that must preserve owner pauses. An open automatic MCP receiver completes readiness and renews it. For a host without incoming notifications, use the returned readinessReply with totem_listen and retain both cursors for the manual fallback. Does not create a host job, start another model, or grant action authority. A supervised runtime using this credential is preserved.",
464
473
  inputSchema: z
465
474
  .object({
466
475
  runId: z.uuid().optional(),
467
476
  hostKind: z.enum(["local", "hosted"]).default("local"),
477
+ automatic: z.boolean().default(false),
468
478
  })
469
479
  .strict(),
470
480
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
471
481
  },
472
- async ({ runId = randomUUID(), hostKind }, extra) => {
473
- await client.runtime.start(
474
- { runId, provider: "mcp", hostKind, automatic: false },
482
+ async ({ runId = randomUUID(), hostKind, automatic }, extra) => {
483
+ const started = await client.runtime.start(
484
+ { runId, provider: "mcp", hostKind, automatic },
475
485
  { signal: extra.signal },
476
486
  );
487
+ // Automatic recovery may retain a fresh existing run. Probe its actual
488
+ // server-owned ID rather than the unused candidate ID from this request.
489
+ runId = started.runId;
477
490
  const probe = await client.runtime.probe(runId, { signal: extra.signal });
478
491
  return {
479
492
  runId,
@@ -19,6 +19,7 @@ import {
19
19
  runMissionWorkerCycle,
20
20
  } from "./mission-worker.mjs";
21
21
  import { repositoryIdFromRemote } from "./repository.mjs";
22
+ import { networkRetryDelay } from "./network-retry.mjs";
22
23
 
23
24
  const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
24
25
  const STAGES = new Set([
@@ -207,7 +208,10 @@ export async function runMissionAgentService(
207
208
  if (signal?.aborted || !transient(error)) throw error;
208
209
  emit({ type: "waiting", code: "service_unavailable" });
209
210
  await sleep(
210
- Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures++, 5)),
211
+ networkRetryDelay(
212
+ error,
213
+ Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures++, 5)),
214
+ ),
211
215
  undefined,
212
216
  { signal },
213
217
  );
@@ -458,6 +462,7 @@ export async function runMissionAgentService(
458
462
  report({ type: "connected", agentId: agent.id });
459
463
  failures = 0;
460
464
  while (!signal?.aborted) {
465
+ let retryError;
461
466
  try {
462
467
  await client.runtime.heartbeat({ label: "missions:local-agent" }, { signal });
463
468
  if (signal?.aborted) break;
@@ -476,15 +481,19 @@ export async function runMissionAgentService(
476
481
  /* Verified identity; a scoped permission can change later. */
477
482
  } else if (!transient(error)) throw error;
478
483
  else {
484
+ retryError = error;
479
485
  failures += 1;
480
486
  report({ type: "waiting", code: "service_unavailable" });
481
487
  }
482
488
  }
483
489
  try {
484
490
  await sleep(
485
- failures
486
- ? Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures - 1, 5))
487
- : pollMs,
491
+ networkRetryDelay(
492
+ retryError,
493
+ failures
494
+ ? Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures - 1, 5))
495
+ : pollMs,
496
+ ),
488
497
  undefined,
489
498
  { signal },
490
499
  );
Binary file
@@ -1 +1 @@
1
- {"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"ae8e84b725fbb52fe285041882f4c6bd696da23ce4359c2790222982c0b778fe","architectures":["arm64","x86_64"],"minimumMacOS":"13.0"}
1
+ {"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"d0c3a83e75eed5712242b24000eabbffcf20a470e0a20ccce2d5a5b6f97517dd","architectures":["arm64","x86_64"],"minimumMacOS":"13.0"}
@@ -0,0 +1,131 @@
1
+ import { constants } from "node:fs";
2
+ import { open, readFile, realpath, rename, unlink } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { beginDeviceLogin, resumeDeviceLogin } from "./device-login.mjs";
8
+ import { createClaudeNativeBridge, inspectClaudeMcp } from "./native-claude-receiver.mjs";
9
+ import { privateDirectory, privateRead } from "./runtime-installation.mjs";
10
+ import { validNativePeerScope } from "./native-mcp-source.mjs";
11
+ import { acquireSupervisorLock } from "./runtime-lock.mjs";
12
+ import { Totem } from "./sdk/index.js";
13
+ import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
14
+
15
+ const fail = code => Object.assign(new Error(code), { code });
16
+ const missing = error => { if (error.code !== "ENOENT") throw error; };
17
+
18
+ async function readKey(path) {
19
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(missing);
20
+ if (!handle) return;
21
+ try {
22
+ const info = await handle.stat();
23
+ if (!info.isFile() || info.nlink !== 1 || info.size > 128 ||
24
+ (process.getuid && info.uid !== process.getuid()) ||
25
+ (process.platform !== "win32" && (info.mode & 0o777) !== 0o600)) throw fail("NATIVE_CLAUDE_LOGIN_KEY_UNSAFE");
26
+ await assertWindowsPrivatePath(path);
27
+ return (await handle.readFile("utf8")).trim();
28
+ } finally { await handle.close(); }
29
+ }
30
+
31
+ async function saveKey(path, value) {
32
+ const previous = await readKey(path);
33
+ if (previous === value) return;
34
+ if (previous !== undefined) throw fail("NATIVE_CLAUDE_LOGIN_KEY_CHANGED");
35
+ const temporary = path + "." + randomUUID() + ".tmp";
36
+ const file = await open(temporary, "wx", 0o600);
37
+ try {
38
+ await assertWindowsPrivatePath(temporary);
39
+ await file.writeFile(value + "\n");
40
+ await file.sync();
41
+ await file.close();
42
+ if (await readKey(path) !== undefined) throw fail("NATIVE_CLAUDE_LOGIN_KEY_CHANGED");
43
+ // Only the device-login owner writes this receiver-specific path.
44
+ await rename(temporary, path);
45
+ } finally {
46
+ await file.close().catch(() => {});
47
+ await unlink(temporary).catch(missing);
48
+ }
49
+ }
50
+
51
+ /** Use Totem's existing device protocol with a receiver-specific private root.
52
+ * This does not import an app connector's OAuth credentials or start a generic executor.
53
+ */
54
+ export async function prepareClaudeDeviceLogin(root, config, flags, options = {}) {
55
+ if (resolve(root) === resolve(join(homedir(), ".totem")))
56
+ throw fail("NATIVE_CLAUDE_LOGIN_CONFIG_INVALID");
57
+ const binding = config.mcp;
58
+ if (config.version !== 1 || !binding || JSON.stringify(config.native?.mcp) !== JSON.stringify(binding) ||
59
+ binding.agentId !== config.source.agentId || binding.environment !== config.source.environment ||
60
+ binding.serverFingerprint !== config.source.bindingFingerprint ||
61
+ !validNativePeerScope(config.source.peerScope) ||
62
+ typeof config.source.ownerChats !== "boolean" || typeof config.instructions !== "string" ||
63
+ !config.instructions.trim() || Buffer.byteLength(config.instructions) > 32768 ||
64
+ (config.source.ownerChats && (typeof config.ownerChatInstructions !== "string" ||
65
+ !config.ownerChatInstructions.trim() || Buffer.byteLength(config.ownerChatInstructions) > 32768)))
66
+ throw fail("NATIVE_CLAUDE_MCP_BINDING_CHANGED");
67
+ const server = await inspectClaudeMcp(binding);
68
+ const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url)));
69
+ const runtimeEntry = join(root, "runtime-packages", version, "node_modules", "totemlayer", "bin", "totem.mjs");
70
+ const keyPath = join(root, "totem-network.key");
71
+ const endpoint = server.env.TOTEM_BASE_URL;
72
+ if (server.command !== process.execPath || server.args.length !== 2 || server.args[0] !== runtimeEntry || server.args[1] !== "mcp" ||
73
+ server.env.TOTEM_CONFIG_DIR !== join(root, "mcp") || server.env.TOTEM_NETWORK_API_KEY_FILE !== keyPath ||
74
+ endpoint !== flags.baseUrl || server.env.TOTEM_EXPECTED_AGENT_ID !== binding.agentId ||
75
+ server.env.TOTEM_EXPECTED_ENVIRONMENT !== binding.environment)
76
+ throw fail("NATIVE_CLAUDE_LOGIN_CONFIG_INVALID");
77
+ const runtime = { root, directory: join(root, "device-login") };
78
+ await privateDirectory(root);
79
+ if (await realpath(root) !== resolve(root)) throw fail("NATIVE_CLAUDE_LOGIN_CONFIG_INVALID");
80
+ await privateDirectory(runtime.directory);
81
+ const unlock = await acquireSupervisorLock(join(root, "native-login-owner"));
82
+ try {
83
+ for (const other of ["pi", "hermes", "opencode", "kilo"])
84
+ if (await privateRead(join(root, `native-${other}-install.json`)).catch(missing) ||
85
+ await privateRead(join(root, `native-${other}.json`)).catch(missing))
86
+ throw fail("NATIVE_CLAUDE_LOGIN_ROOT_IN_USE");
87
+ if (await privateRead(join(config.source.directory, "paused.json")).catch(missing))
88
+ return { state: "paused", code: "RECEIVER_INTENTIONALLY_PAUSED", registered: false };
89
+ const keyPattern = binding.environment === "SANDBOX" ? /^tn_test_[A-Za-z0-9_-]{43}$/ : /^tn_live_[A-Za-z0-9_-]{43}$/;
90
+ const env = { TOTEM_PROVIDER: "claude", TOTEM_AGENT_ID: binding.agentId, TOTEM_BASE_URL: endpoint };
91
+ const bridge = (options.createClaudeBridge ?? createClaudeNativeBridge)(config.native, { env: options.env ?? process.env });
92
+ try { await bridge.open(); } finally { await bridge.close(); }
93
+ const resume = async () => (options.resumeClaudeLogin ?? resumeDeviceLogin)(runtime, env, {
94
+ fetch: options.fetch, configureMcp: false,
95
+ detectProvider: async () => "claude", // The exact native session passed preflight above.
96
+ async setup(_args, settings) {
97
+ const apiKey = settings.env.TOTEM_NETWORK_API_KEY;
98
+ if (!keyPattern.test(apiKey ?? "")) throw fail("NATIVE_CLAUDE_LOGIN_IDENTITY_CHANGED");
99
+ const identity = await new Totem({ apiKey, baseUrl: endpoint, fetch: options.fetch, maxRetries: 0 }).agent();
100
+ if (identity.id !== binding.agentId || identity.environment !== binding.environment)
101
+ throw fail("NATIVE_CLAUDE_LOGIN_IDENTITY_CHANGED");
102
+ await saveKey(keyPath, apiKey);
103
+ return 0;
104
+ },
105
+ });
106
+ const resumed = await resume().catch(error => {
107
+ if (error.code !== "DEVICE_LOGIN_EXPIRED") throw error;
108
+ });
109
+ if (resumed?.state === "setup_in_progress") throw fail("NATIVE_CLAUDE_LOGIN_IN_PROGRESS");
110
+ const previous = await readKey(keyPath);
111
+ if (previous !== undefined) {
112
+ if (!keyPattern.test(previous)) throw fail("NATIVE_CLAUDE_LOGIN_KEY_UNSAFE");
113
+ return; // The receiver will still verify the live identity before registration.
114
+ }
115
+ const request = await (options.beginClaudeLogin ?? beginDeviceLogin)(runtime, env, {
116
+ openBrowser: !flags.noBrowser, configureMcp: false, fetch: options.fetch,
117
+ });
118
+ const pending = { state: "incomplete", code: "NATIVE_CLAUDE_SIGN_IN_REQUIRED", complete: false,
119
+ registered: false, host: "claude", url: request.url, codeToCompare: request.code, expiresAt: request.expiresAt };
120
+ if (!flags.waitSeconds) return pending;
121
+ await options.write?.(pending);
122
+ const deadline = Math.min(Date.parse(request.expiresAt), Date.now() + flags.waitSeconds * 1000);
123
+ while (Date.now() < deadline && !options.signal?.aborted) {
124
+ await (options.sleep ?? delay)(Math.max(0, Math.min(2000, deadline - Date.now())), undefined, { signal: options.signal });
125
+ if (await privateRead(join(config.source.directory, "paused.json")).catch(missing))
126
+ return { state: "paused", code: "RECEIVER_INTENTIONALLY_PAUSED", registered: false };
127
+ if ((await resume())?.state === "connected") return;
128
+ }
129
+ return { ...pending, code: "NATIVE_CLAUDE_SIGN_IN_PENDING" };
130
+ } finally { await unlock(); }
131
+ }