totemlayer 0.1.96 → 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.
- package/README.md +48 -6
- package/bin/automatic-runtime.mjs +2 -1
- package/bin/cloud-supervisor.mjs +44 -0
- package/bin/codex-mission-bridge.mjs +6 -2
- package/bin/configured-native-installation.mjs +57 -0
- package/bin/connected-agent-service.mjs +5 -1
- package/bin/device-login.mjs +3 -1
- package/bin/inbox-queue.mjs +8 -5
- package/bin/mcp.mjs +18 -5
- package/bin/mission-agent-service.mjs +13 -4
- package/bin/native/darwin-lifetime +0 -0
- package/bin/native/darwin-lifetime.json +1 -1
- package/bin/native-claude-login.mjs +131 -0
- package/bin/native-claude-receiver.mjs +272 -0
- package/bin/native-cli-process.py +105 -0
- package/bin/native-harness-environment.mjs +20 -0
- package/bin/native-hermes-bridge.mjs +99 -0
- package/bin/native-hermes-installation.mjs +9 -0
- package/bin/native-hermes-mcp.mjs +107 -0
- package/bin/native-hermes-mcp.py +205 -0
- package/bin/native-hermes-metadata.py +40 -0
- package/bin/native-hermes-process.py +62 -0
- package/bin/native-hermes-receiver.mjs +28 -0
- package/bin/native-inbox-installation.mjs +144 -0
- package/bin/native-inbox-receiver.mjs +133 -0
- package/bin/native-kilo-installation.mjs +4 -0
- package/bin/native-kilo-receiver.mjs +1 -0
- package/bin/native-mcp-source.mjs +244 -0
- package/bin/native-opencode-bridge.mjs +108 -0
- package/bin/native-opencode-installation.mjs +15 -0
- package/bin/native-opencode-mcp.mjs +112 -0
- package/bin/native-opencode-receiver.mjs +22 -0
- package/bin/native-pi-bridge.mjs +170 -0
- package/bin/native-pi-installation.mjs +9 -0
- package/bin/native-pi-mcp.mjs +139 -0
- package/bin/native-pi-receiver.mjs +28 -0
- package/bin/network-retry.mjs +9 -1
- package/bin/platform-client.mjs +2 -5
- package/bin/runtime-installation.mjs +9 -4
- package/bin/sdk/errors.d.ts +1 -1
- package/bin/sdk/errors.js +1 -1
- package/bin/sdk/listener.d.ts +1 -0
- package/bin/sdk/listener.js +1 -1
- package/bin/sdk/transport.js +1 -1
- package/bin/setup-assets/api-manifest.json +1 -1
- package/bin/setup-assets/manifest.json +1 -1
- package/bin/setup-assets/setup.backup.md +2 -2
- package/bin/setup-assets/setup.md +2 -2
- package/bin/startup-diagnostics.mjs +19 -2
- package/bin/strict-installation.mjs +29 -3
- package/bin/supervisor-watchdog.mjs +44 -0
- package/bin/totem.mjs +6 -0
- package/bin/windows-private-path.mjs +227 -32
- 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.
|
|
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://
|
|
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://
|
|
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,19 +72,61 @@ 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://
|
|
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.
|
|
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
|
|
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
|
|
|
86
101
|
## Check or pause your listener
|
|
87
102
|
|
|
103
|
+
### Always-on owner-managed hosts
|
|
104
|
+
|
|
105
|
+
For an already configured receiver on a cloud machine or container, run
|
|
106
|
+
`totem supervise` in the foreground under that host's process manager. The
|
|
107
|
+
manager must restart it after exit and stop the whole container or control group,
|
|
108
|
+
including detached native children. A process group alone is insufficient. Give
|
|
109
|
+
the same unprivileged user a private persistent `TOTEM_CONFIG_DIR`, native
|
|
110
|
+
harness profiles, and saved sessions. Sign in through the native harness on
|
|
111
|
+
that host; do not transfer laptop sign-in files. Preserve the configured
|
|
112
|
+
profile-location environment variables when launching the manager.
|
|
113
|
+
Containers must use an init process, such as Docker's `--init`; the receiver
|
|
114
|
+
must not run as PID 1. Mount private state persistently across container restarts.
|
|
115
|
+
|
|
116
|
+
This command reuses the existing supervisor, locks, retry rules and owner
|
|
117
|
+
pauses. It does not provision a machine, register startup, sign in, create
|
|
118
|
+
an executor, or certify a harness. Hosted MCP at `https://totemlayer.com/mcp`
|
|
119
|
+
is also available when the cloud harness supports remote MCP; receiving still
|
|
120
|
+
needs that harness's supported wake mechanism. A suspended browser, ephemeral
|
|
121
|
+
job, or stopped container cannot receive automatically.
|
|
122
|
+
|
|
123
|
+
Keep Codex, Claude Code, OpenCode, OpenClaw, Hermes, Pi and Kilo on their native
|
|
124
|
+
session and authentication paths. Before relying on unattended operation,
|
|
125
|
+
verify a delayed message and one correlated reply, restart the process,
|
|
126
|
+
verify that the exact session/model and permissions survive, repeat with an
|
|
127
|
+
interrupted connection, and confirm an owner pause survives restart. Local
|
|
128
|
+
process tests do not establish Linux/cloud deployment or host reboot proof.
|
|
129
|
+
|
|
88
130
|
After a global installation:
|
|
89
131
|
|
|
90
132
|
```sh
|
|
@@ -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 ||
|
|
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
|
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { automaticRuntimeMain } from "./automatic-runtime.mjs";
|
|
3
|
+
|
|
4
|
+
export const CLOUD_SUPERVISOR_HELP = `Run Totem's existing receiver supervisor in the foreground
|
|
5
|
+
|
|
6
|
+
totem supervise
|
|
7
|
+
|
|
8
|
+
For an owner-managed always-on host or container with an external process manager.
|
|
9
|
+
Containers must use an init process (for example Docker --init); do not run as PID 1.
|
|
10
|
+
Uses existing private connections, native profile locations, pauses and journals.
|
|
11
|
+
Does not sign in, provision compute, install a service, or enable paused connections.
|
|
12
|
+
Keep native harness sign-in and persistent private storage on this same host.
|
|
13
|
+
The external manager must restart this process and stop its entire container or
|
|
14
|
+
control group, including detached native children. A process group alone is insufficient.
|
|
15
|
+
Receiver compatibility and readiness still require separate verification.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export async function cloudSupervisorMain(args = [], options = {}) {
|
|
19
|
+
const stdout = options.stdout ?? process.stdout;
|
|
20
|
+
const stderr = options.stderr ?? process.stderr;
|
|
21
|
+
try {
|
|
22
|
+
const { values } = parseArgs({ args, strict: true,
|
|
23
|
+
options: { help: { type: "boolean", short: "h" } } });
|
|
24
|
+
if (values.help) { stdout.write(CLOUD_SUPERVISOR_HELP); return 0; }
|
|
25
|
+
} catch {
|
|
26
|
+
stderr.write("Use totem supervise --help. This command takes no credentials or connection settings.\n");
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
if (process.pid === 1) {
|
|
30
|
+
stderr.write("Start this container with an init process (for example Docker --init), then run totem supervise.\n");
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await (options.run ?? automaticRuntimeMain)(options.env ?? process.env);
|
|
35
|
+
return process.exitCode ?? 0;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "SUPERVISOR_ALREADY_RUNNING") {
|
|
38
|
+
stderr.write("This private Totem runtime already has a supervisor.\n");
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
stderr.write("The Totem supervisor stopped. Inspect this host's private runtime status before retrying.\n");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -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, {
|
|
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
|
-
|
|
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
|
);
|
package/bin/device-login.mjs
CHANGED
|
@@ -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) ||
|
package/bin/inbox-queue.mjs
CHANGED
|
@@ -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
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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":"
|
|
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
|
+
}
|