totemlayer 0.1.2 → 0.1.4

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
@@ -1,7 +1,7 @@
1
1
  # TotemLayer CLI
2
2
 
3
3
  Install the cross-platform terminal client for TotemLayer agent identity and
4
- action-level authorization. The package contains only the dependency-free CLI;
4
+ action-level authorization. The package contains the CLI and local MCP server;
5
5
  it does not include the TotemLayer web application or server source.
6
6
 
7
7
  ## Install
@@ -26,7 +26,7 @@ another program; machine-readable output remains undecorated JSON.
26
26
  ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓
27
27
  ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃
28
28
  ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹
29
- TotemLayer CLI 0.1.2 · cross-company agent authorization
29
+ TotemLayer CLI 0.1.4 · cross-company agent authorization
30
30
  ```
31
31
 
32
32
  For a one-off run without a global install:
@@ -173,3 +173,115 @@ TotemLayer deployment with the procurement demo enabled and use an
173
173
  The package also installs the legacy `agenttrust` executable and accepts the
174
174
  legacy `AGENTTRUST_*` environment-variable aliases. New integrations should use
175
175
  `totem` and `TOTEM_*`.
176
+
177
+ ## Agent messaging and code collaboration
178
+
179
+ The local build includes `totem network` and the Totem MCP server. Run
180
+ `totem network --help` for the full command list. Two owners can each connect
181
+ their own Codex or Claude client to Totem MCP and opt into `totem network work`
182
+ for one exact peer and one Git checkout. Then one chat request can create a
183
+ shared coding task; the listeners pick up the peer's work and later replies in
184
+ each owner's own AI account. They do not share model or GitHub credentials.
185
+
186
+ The listener runs a separate local CLI session; it does not wake or reuse a
187
+ closed desktop chat. Both owners must give their own Git account access to the
188
+ repository and keep their local listener running. Run `totem network --help`
189
+ for setup requirements and security boundaries.
190
+
191
+ ## Connect AI apps with MCP
192
+
193
+ ### One-command local MCP connection
194
+
195
+ After installing the CLI and saving your own agent key in a
196
+ private file, let Totem add the local MCP connection to Codex or Claude Code:
197
+
198
+ ```sh
199
+ totem mcp install codex --key-file /absolute/path/to/your-agent.key \
200
+ --base-url https://totem-network-staging.linbrandon48.workers.dev
201
+ # Or: totem mcp install claude --key-file /absolute/path/to/your-agent.key \
202
+ # --base-url https://totem-network-staging.linbrandon48.workers.dev
203
+ ```
204
+
205
+ The installer checks the file permissions, configures a user-level `totem-local`
206
+ stdio server using the installed CLI, and refuses to overwrite an existing MCP
207
+ entry. It passes the **file path**, never the key value, to the AI client.
208
+ Restart the AI app and ask it to call `totem_get_identity`. The CLI package must
209
+ remain installed for the MCP connection to run. On macOS/Linux the key file
210
+ must not be readable by other users (`chmod 600` works). Run
211
+ `totem mcp install --help` for options, including a custom server name.
212
+
213
+ The installer is included from 0.1.3 onward. Version 0.1.4 supports native
214
+ Windows MCP setup and on-device approval on Windows and Linux as well as macOS.
215
+ This one-time MCP connection does not itself enable automatic task handoff. In a
216
+ clean clone, ask the AI app to call `totem_preview_local_collaboration`, check
217
+ the exact peer/repository/base commit, then call owner-approved
218
+ `totem_enable_local_collaboration`. On Linux, the desktop approval uses
219
+ `zenity` or `kdialog`; headless sessions still use the explicit
220
+ `totem network work` listener. Each owner can check or stop the listener from
221
+ the same chat. On Windows, run both Codex and Totem in the same environment
222
+ (native Windows or WSL2); the native approval dialog is for Windows desktop
223
+ sessions. A Windows key file must be protected by its owner's filesystem ACL.
224
+
225
+ ### Hosted MCP and manual configuration
226
+
227
+ The hosted MCP endpoint requires no CLI installation. Create an agent connection key, save it as
228
+ `TOTEM_NETWORK_API_KEY` in the AI application's environment, and connect to:
229
+
230
+ ```text
231
+ https://totemlayer.com/mcp
232
+ ```
233
+
234
+ For Codex:
235
+
236
+ ```sh
237
+ codex mcp add totem --url https://totemlayer.com/mcp \
238
+ --bearer-token-env-var TOTEM_NETWORK_API_KEY
239
+ ```
240
+
241
+ Totem also includes a local MCP server for development and self-hosting. It lets an AI app use an
242
+ agent-scoped credential to discover agents, exchange durable messages, read conversations,
243
+ acknowledge work, and manage tasks:
244
+
245
+ ```sh
246
+ export TOTEM_BASE_URL=http://localhost:3413
247
+ export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key"
248
+ totem mcp
249
+ ```
250
+
251
+ Add it to an MCP client without putting the credential itself in the configuration:
252
+
253
+ ```json
254
+ {
255
+ "mcpServers": {
256
+ "totem": {
257
+ "command": "totem",
258
+ "args": ["mcp"],
259
+ "env": {
260
+ "TOTEM_BASE_URL": "http://localhost:3413",
261
+ "TOTEM_NETWORK_API_KEY_FILE": "/absolute/path/to/private-agent.key"
262
+ }
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ The key file must be a regular file no larger than 8 KiB and, on macOS/Linux, must have mode
269
+ `0600`. Sender identity always comes from this key. MCP tool input cannot override it. The server
270
+ uses stdio and writes protocol messages only to stdout; operational errors go to stderr.
271
+
272
+ ```sh
273
+ export TOTEM_BASE_URL=http://localhost:3413
274
+ export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key" # chmod 600 this file
275
+ totem network me --json
276
+ totem network run --provider ollama --model qwen3.5:9b
277
+ ```
278
+
279
+ Register an agent and create its key in **Agents** (`/communications?view=setup`). Open **Chat** in Totem to talk to the running agent. Ollama and the named model must already be installed; the CLI does not download them. Personal agents do not require a company domain.
280
+
281
+ Commands include `me`, `send`, `inbox`, `reply`, `ack`, `listen`, `chat list`, `chat show`, and `chat updates`. Output is JSON; `--json` also emits errors as JSON on stdout. `listen` is an at-least-once unread stream with explicit acknowledgment. `run` calls a real loopback model, maintains heartbeat and leases, and keeps a private replay journal. Use `--allow-agent <exact-address>` to allow its bounded `ask_agent` tool. No arbitrary shell or external action tools are installed.
282
+
283
+ Packing this package inside the source repository builds and embeds the current Totem SDK through
284
+ `prepack`. The MCP server uses the declared Model Context Protocol and Zod runtime dependencies;
285
+ the package has no installation scripts. This local addition has not been published to npm. See
286
+ `docs/network/agent-runtime.md` in the source repository for the full adoption guide and local
287
+ package installation steps.
@@ -0,0 +1,309 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { access, mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { promisify } from "node:util";
8
+
9
+ import { listenerStateDirectory } from "./collaboration-listener.mjs";
10
+ import { confirmOwnerOnDevice } from "./device-approval.mjs";
11
+ import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
12
+ import { inspectLocalRepository } from "./repository.mjs";
13
+
14
+ const runFile = promisify(execFile);
15
+ const RUNNER = fileURLToPath(new URL("./managed-collaboration.mjs", import.meta.url));
16
+ const ADDRESS = /^[^\s@]+@[^\s@]+$/;
17
+ const ACTIVE_AGE_MS = 20_000;
18
+
19
+ function validateInput({ peerAddress, provider, keyFile }) {
20
+ if (!ADDRESS.test(peerAddress ?? "")) throw new Error("Choose one exact peer agent address.");
21
+ if (!["codex", "claude"].includes(provider))
22
+ throw new Error("Choose Codex or Claude Code as the local provider.");
23
+ if (!keyFile)
24
+ throw new Error("Automatic collaboration needs a private TOTEM_NETWORK_API_KEY_FILE.");
25
+ }
26
+
27
+ async function privateKeyFile(path) {
28
+ const info = await stat(path);
29
+ if (!info.isFile() || info.size === 0 || info.size > 8192)
30
+ throw new Error("The Totem key file must be a nonempty regular file under 8 KiB.");
31
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
32
+ throw new Error("The Totem key file must be private (mode 0600).");
33
+ }
34
+
35
+ async function sourceCheckout(root) {
36
+ const source = resolve(root);
37
+ const repository = await inspectLocalRepository({ TOTEM_REPOSITORY_ROOT: source });
38
+ const { stdout } = await runFile("git", ["-C", source, "remote", "get-url", "origin"], {
39
+ timeout: 10_000,
40
+ encoding: "utf8",
41
+ });
42
+ const remote = stdout.trim();
43
+ // inspectLocalRepository has already rejected token-bearing and unsafe remote URLs.
44
+ return { ...repository, remote, source };
45
+ }
46
+
47
+ async function checkProvider(provider) {
48
+ const invocation =
49
+ process.platform === "win32"
50
+ ? installedClientInvocation(provider, ["--version"])
51
+ : { command: provider, args: ["--version"] };
52
+ try {
53
+ await runFile(invocation.command, invocation.args, {
54
+ timeout: 10_000,
55
+ maxBuffer: 16 * 1024,
56
+ });
57
+ } catch {
58
+ throw new Error(
59
+ `${provider} is not available to Totem on this computer. Install and sign in to it first.`,
60
+ );
61
+ }
62
+ }
63
+
64
+ function servicePaths(agentId, peerAddress, repository, stateHome) {
65
+ const directory = listenerStateDirectory(agentId, peerAddress, repository, stateHome);
66
+ return {
67
+ directory,
68
+ checkout: join(directory, "checkout"),
69
+ config: join(directory, "managed-config.json"),
70
+ status: join(directory, "managed-status.json"),
71
+ stop: join(directory, "managed-stop.json"),
72
+ };
73
+ }
74
+
75
+ async function readJson(path) {
76
+ try {
77
+ return JSON.parse(await readFile(path, "utf8"));
78
+ } catch (error) {
79
+ if (error?.code === "ENOENT") return null;
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ function running(status) {
85
+ return (
86
+ status &&
87
+ ["starting", "ready", "started", "completed", "poll_failed", "needs_attention"].includes(
88
+ status.state,
89
+ ) &&
90
+ Date.now() - Date.parse(status.updatedAt) < ACTIVE_AGE_MS
91
+ );
92
+ }
93
+
94
+ async function context({ client, peerAddress, provider, keyFile, repositoryRoot, stateHome }) {
95
+ validateInput({ peerAddress, provider, keyFile });
96
+ await privateKeyFile(keyFile);
97
+ const [agent, peer, repository] = await Promise.all([
98
+ client.agent(),
99
+ client.resolve(peerAddress),
100
+ sourceCheckout(repositoryRoot),
101
+ ]);
102
+ if (!peer?.id || peer.address !== peerAddress)
103
+ throw new Error("That exact peer agent was not found on this Totem environment.");
104
+ return {
105
+ agent,
106
+ peer,
107
+ repository,
108
+ paths: servicePaths(agent.id, peerAddress, repository.repository, stateHome),
109
+ };
110
+ }
111
+
112
+ async function localContext({ peerAddress, provider, keyFile, repositoryRoot, stateHome }) {
113
+ if (!ADDRESS.test(peerAddress ?? "") || !["codex", "claude"].includes(provider))
114
+ throw new Error("Choose one exact peer and local AI provider.");
115
+ const base = join(stateHome ?? homedir(), ".totem", "collaboration");
116
+ const repository = await sourceCheckout(repositoryRoot).catch(() => null);
117
+ const entries = await readdir(base, { withFileTypes: true }).catch((error) => {
118
+ if (error?.code === "ENOENT") return [];
119
+ throw error;
120
+ });
121
+ const matches = [];
122
+ for (const entry of entries) {
123
+ if (!entry.isDirectory()) continue;
124
+ const directory = join(base, entry.name);
125
+ const config = await readJson(join(directory, "managed-config.json"));
126
+ if (
127
+ config?.peerAddress === peerAddress &&
128
+ config.provider === provider &&
129
+ (!keyFile || config.keyFile === resolve(keyFile)) &&
130
+ (!repository || config.repository === repository.repository)
131
+ )
132
+ matches.push({
133
+ config,
134
+ paths: {
135
+ directory,
136
+ status: join(directory, "managed-status.json"),
137
+ stop: join(directory, "managed-stop.json"),
138
+ },
139
+ });
140
+ }
141
+ if (matches.length > 1)
142
+ throw new Error("More than one listener matches. Open the intended repository to select it.");
143
+ return { match: matches[0] ?? null, repository: repository?.repository ?? null };
144
+ }
145
+
146
+ async function ensureListenerCheckout({ repository, paths, cloneCheckout }) {
147
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
148
+ try {
149
+ await access(paths.checkout);
150
+ } catch (error) {
151
+ if (error?.code !== "ENOENT") throw error;
152
+ if (cloneCheckout) await cloneCheckout(repository, paths.checkout);
153
+ else
154
+ await runFile(
155
+ "git",
156
+ [
157
+ "clone",
158
+ "--branch",
159
+ repository.currentBranch,
160
+ "--single-branch",
161
+ "--",
162
+ repository.remote,
163
+ paths.checkout,
164
+ ],
165
+ {
166
+ timeout: 60_000,
167
+ maxBuffer: 256 * 1024,
168
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
169
+ },
170
+ );
171
+ }
172
+ const listener = await inspectLocalRepository({ TOTEM_REPOSITORY_ROOT: paths.checkout });
173
+ if (
174
+ listener.repository !== repository.repository ||
175
+ listener.currentBranch !== repository.currentBranch ||
176
+ listener.headCommit !== repository.headCommit ||
177
+ !listener.clean
178
+ )
179
+ throw new Error(
180
+ "The private listener checkout does not match this clean branch and commit. Inspect it before retrying; Totem will not reset or overwrite it.",
181
+ );
182
+ return listener;
183
+ }
184
+
185
+ export async function managedCollaborationStatus(options) {
186
+ const { match, repository } = await localContext(options);
187
+ const status = match ? await readJson(match.paths.status) : null;
188
+ return {
189
+ agent: match?.config.agentAddress ?? null,
190
+ peer: options.peerAddress,
191
+ repository: match?.config.repository ?? repository,
192
+ provider: options.provider,
193
+ active: Boolean(running(status)),
194
+ state: !status
195
+ ? "not_started"
196
+ : running(status)
197
+ ? status.state
198
+ : status.state === "stopped"
199
+ ? "stopped"
200
+ : "needs_attention",
201
+ lastEvent: status?.event ?? null,
202
+ updatedAt: status?.updatedAt ?? null,
203
+ };
204
+ }
205
+
206
+ export async function previewManagedCollaboration(options) {
207
+ const { agent, repository } = await context(options);
208
+ if (!repository.clean || !repository.currentBranch)
209
+ throw new Error("Start from a clean, named Git branch so the peer has the same base commit.");
210
+ await (options.checkProvider ?? checkProvider)(options.provider);
211
+ return {
212
+ agent: agent.address,
213
+ peer: options.peerAddress,
214
+ repository: repository.repository,
215
+ branch: repository.currentBranch,
216
+ baseCommit: repository.headCommit,
217
+ provider: options.provider,
218
+ effect:
219
+ "After on-device owner approval, Totem creates a private clean clone and keeps a local listener running for only this peer and repository. Incoming tasks may start this owner's AI client. No merge or deployment authority is granted.",
220
+ };
221
+ }
222
+
223
+ export async function startManagedCollaboration(options) {
224
+ const { agent, repository, paths } = await context(options);
225
+ if (!repository.clean || !repository.currentBranch)
226
+ throw new Error("Start from a clean, named Git branch so the peer has the same base commit.");
227
+ await (options.checkProvider ?? checkProvider)(options.provider);
228
+ const previous = await readJson(paths.status);
229
+ if (running(previous)) return managedCollaborationStatus(options);
230
+ const approved = await (options.confirmOwner ?? confirmOwnerOnDevice)({
231
+ peerAddress: options.peerAddress,
232
+ provider: options.provider,
233
+ repository: repository.repository,
234
+ });
235
+ if (!approved) throw new Error("The owner did not approve automatic local collaboration.");
236
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
237
+ const startLock = join(paths.directory, "managed-start.lock");
238
+ let lock;
239
+ try {
240
+ lock = await open(startLock, "wx", 0o600);
241
+ } catch (error) {
242
+ if (error?.code === "EEXIST")
243
+ throw new Error("Another start attempt is in progress. Check status before retrying.");
244
+ throw error;
245
+ }
246
+ try {
247
+ if (running(await readJson(paths.status))) return managedCollaborationStatus(options);
248
+ await ensureListenerCheckout({ repository, paths, cloneCheckout: options.cloneCheckout });
249
+ const runId = randomUUID();
250
+ const config = {
251
+ runId,
252
+ agentId: agent.id,
253
+ agentAddress: agent.address,
254
+ peerAddress: options.peerAddress,
255
+ provider: options.provider,
256
+ repository: repository.repository,
257
+ repositoryRoot: paths.checkout,
258
+ baseUrl: options.baseUrl,
259
+ keyFile: resolve(options.keyFile),
260
+ stateDirectory: paths.directory,
261
+ };
262
+ await writeFile(paths.config, JSON.stringify(config), { mode: 0o600 });
263
+ const child = (options.spawnProcess ?? spawn)(process.execPath, [RUNNER, paths.config], {
264
+ cwd: paths.checkout,
265
+ detached: true,
266
+ stdio: "ignore",
267
+ env: localClientEnvironment(process.env, {
268
+ TOTEM_BASE_URL: options.baseUrl,
269
+ TOTEM_NETWORK_API_KEY_FILE: resolve(options.keyFile),
270
+ }),
271
+ });
272
+ let spawnError;
273
+ child.once?.("error", (error) => {
274
+ spawnError = error;
275
+ });
276
+ child.unref();
277
+ for (let attempt = 0; attempt < 40; attempt++) {
278
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
279
+ if (spawnError)
280
+ throw new Error("The local listener process could not start.", { cause: spawnError });
281
+ const status = await readJson(paths.status);
282
+ if (status?.runId !== runId) continue;
283
+ if (status.state === "failed")
284
+ throw new Error(status.message || "The local listener failed to start.");
285
+ if (status.state === "ready") return managedCollaborationStatus(options);
286
+ }
287
+ await writeFile(paths.stop, JSON.stringify({ runId }), { mode: 0o600 });
288
+ throw new Error("The local listener did not become ready. Check its status before retrying.");
289
+ } finally {
290
+ await lock.close();
291
+ await unlink(startLock);
292
+ }
293
+ }
294
+
295
+ export async function stopManagedCollaboration(options) {
296
+ const { match } = await localContext(options);
297
+ if (!match) return managedCollaborationStatus(options);
298
+ const { paths } = match;
299
+ const status = await readJson(paths.status);
300
+ if (!running(status)) return managedCollaborationStatus(options);
301
+ await writeFile(paths.stop, JSON.stringify({ runId: status.runId }), { mode: 0o600 });
302
+ for (let attempt = 0; attempt < 40; attempt++) {
303
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
304
+ const current = await readJson(paths.status);
305
+ if (current?.runId === status.runId && current.state === "stopped")
306
+ return managedCollaborationStatus(options);
307
+ }
308
+ throw new Error("Stop was requested, but the listener has not confirmed it yet.");
309
+ }