totemlayer 0.1.2 → 0.1.3

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.3 · cross-company agent authorization
30
30
  ```
31
31
 
32
32
  For a one-off run without a global install:
@@ -173,3 +173,111 @@ 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 (CLI 0.1.3+)
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
+ This installer is included from 0.1.3 onward. The listener remains a macOS
214
+ preview and needs separate owner approval for one exact peer and repository.
215
+ The setup helper currently supports macOS and Linux; Windows users can use the
216
+ manual MCP configuration below until the installer supports that platform.
217
+ This one-time MCP connection does not itself enable automatic task handoff;
218
+ use `totem_preview_local_collaboration` and owner-approved
219
+ `totem_enable_local_collaboration` afterward on macOS.
220
+
221
+ ### Hosted MCP and manual configuration
222
+
223
+ The hosted MCP endpoint requires no CLI installation. Create an agent connection key, save it as
224
+ `TOTEM_NETWORK_API_KEY` in the AI application's environment, and connect to:
225
+
226
+ ```text
227
+ https://totemlayer.com/mcp
228
+ ```
229
+
230
+ For Codex:
231
+
232
+ ```sh
233
+ codex mcp add totem --url https://totemlayer.com/mcp \
234
+ --bearer-token-env-var TOTEM_NETWORK_API_KEY
235
+ ```
236
+
237
+ Totem also includes a local MCP server for development and self-hosting. It lets an AI app use an
238
+ agent-scoped credential to discover agents, exchange durable messages, read conversations,
239
+ acknowledge work, and manage tasks:
240
+
241
+ ```sh
242
+ export TOTEM_BASE_URL=http://localhost:3413
243
+ export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key"
244
+ totem mcp
245
+ ```
246
+
247
+ Add it to an MCP client without putting the credential itself in the configuration:
248
+
249
+ ```json
250
+ {
251
+ "mcpServers": {
252
+ "totem": {
253
+ "command": "totem",
254
+ "args": ["mcp"],
255
+ "env": {
256
+ "TOTEM_BASE_URL": "http://localhost:3413",
257
+ "TOTEM_NETWORK_API_KEY_FILE": "/absolute/path/to/private-agent.key"
258
+ }
259
+ }
260
+ }
261
+ }
262
+ ```
263
+
264
+ The key file must be a regular file no larger than 8 KiB and, on macOS/Linux, must have mode
265
+ `0600`. Sender identity always comes from this key. MCP tool input cannot override it. The server
266
+ uses stdio and writes protocol messages only to stdout; operational errors go to stderr.
267
+
268
+ ```sh
269
+ export TOTEM_BASE_URL=http://localhost:3413
270
+ export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key" # chmod 600 this file
271
+ totem network me --json
272
+ totem network run --provider ollama --model qwen3.5:9b
273
+ ```
274
+
275
+ 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.
276
+
277
+ 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.
278
+
279
+ Packing this package inside the source repository builds and embeds the current Totem SDK through
280
+ `prepack`. The MCP server uses the declared Model Context Protocol and Zod runtime dependencies;
281
+ the package has no installation scripts. This local addition has not been published to npm. See
282
+ `docs/network/agent-runtime.md` in the source repository for the full adoption guide and local
283
+ package installation steps.
@@ -0,0 +1,328 @@
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 { inspectLocalRepository } from "./repository.mjs";
11
+
12
+ const runFile = promisify(execFile);
13
+ const RUNNER = fileURLToPath(new URL("./managed-collaboration.mjs", import.meta.url));
14
+ const ADDRESS = /^[^\s@]+@[^\s@]+$/;
15
+ const ACTIVE_AGE_MS = 20_000;
16
+
17
+ function validateInput({ peerAddress, provider, keyFile }) {
18
+ if (!ADDRESS.test(peerAddress ?? "")) throw new Error("Choose one exact peer agent address.");
19
+ if (!["codex", "claude"].includes(provider))
20
+ throw new Error("Choose Codex or Claude Code as the local provider.");
21
+ if (!keyFile)
22
+ throw new Error("Automatic collaboration needs a private TOTEM_NETWORK_API_KEY_FILE.");
23
+ }
24
+
25
+ async function privateKeyFile(path) {
26
+ const info = await stat(path);
27
+ if (!info.isFile() || info.size === 0 || info.size > 8192)
28
+ throw new Error("The Totem key file must be a nonempty regular file under 8 KiB.");
29
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
30
+ throw new Error("The Totem key file must be private (mode 0600).");
31
+ }
32
+
33
+ async function sourceCheckout(root) {
34
+ const source = resolve(root);
35
+ const repository = await inspectLocalRepository({ TOTEM_REPOSITORY_ROOT: source });
36
+ const { stdout } = await runFile("git", ["-C", source, "remote", "get-url", "origin"], {
37
+ timeout: 10_000,
38
+ encoding: "utf8",
39
+ });
40
+ const remote = stdout.trim();
41
+ // inspectLocalRepository has already rejected token-bearing and unsafe remote URLs.
42
+ return { ...repository, remote, source };
43
+ }
44
+
45
+ async function confirmOnMac({ peerAddress, provider, repository }) {
46
+ if (process.platform !== "darwin")
47
+ throw new Error("The no-terminal approval dialog currently supports macOS only.");
48
+ const message = `Enable ${provider} to handle incoming coding tasks from ${peerAddress} in ${repository}? Totem will make an isolated checkout. Only allow a peer you trust. No merges or deployments are authorized.`;
49
+ try {
50
+ const { stdout } = await runFile(
51
+ "osascript",
52
+ [
53
+ "-e",
54
+ "on run argv",
55
+ "-e",
56
+ 'display dialog (item 1 of argv) buttons {"Cancel", "Enable"} default button "Cancel" with title "Totem collaboration"',
57
+ "-e",
58
+ "end run",
59
+ message,
60
+ ],
61
+ { timeout: 120_000, encoding: "utf8" },
62
+ );
63
+ return stdout.includes("button returned:Enable");
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async function checkProvider(provider) {
70
+ try {
71
+ await runFile(provider, ["--version"], { timeout: 10_000, maxBuffer: 16 * 1024 });
72
+ } catch {
73
+ throw new Error(
74
+ `${provider} is not available to Totem on this computer. Install and sign in to it first.`,
75
+ );
76
+ }
77
+ }
78
+
79
+ function servicePaths(agentId, peerAddress, repository, stateHome) {
80
+ const directory = listenerStateDirectory(agentId, peerAddress, repository, stateHome);
81
+ return {
82
+ directory,
83
+ checkout: join(directory, "checkout"),
84
+ config: join(directory, "managed-config.json"),
85
+ status: join(directory, "managed-status.json"),
86
+ stop: join(directory, "managed-stop.json"),
87
+ };
88
+ }
89
+
90
+ async function readJson(path) {
91
+ try {
92
+ return JSON.parse(await readFile(path, "utf8"));
93
+ } catch (error) {
94
+ if (error?.code === "ENOENT") return null;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ function running(status) {
100
+ return (
101
+ status &&
102
+ ["starting", "ready", "started", "completed", "poll_failed", "needs_attention"].includes(
103
+ status.state,
104
+ ) &&
105
+ Date.now() - Date.parse(status.updatedAt) < ACTIVE_AGE_MS
106
+ );
107
+ }
108
+
109
+ async function context({ client, peerAddress, provider, keyFile, repositoryRoot, stateHome }) {
110
+ validateInput({ peerAddress, provider, keyFile });
111
+ await privateKeyFile(keyFile);
112
+ const [agent, peer, repository] = await Promise.all([
113
+ client.agent(),
114
+ client.resolve(peerAddress),
115
+ sourceCheckout(repositoryRoot),
116
+ ]);
117
+ if (!peer?.id || peer.address !== peerAddress)
118
+ throw new Error("That exact peer agent was not found on this Totem environment.");
119
+ return {
120
+ agent,
121
+ peer,
122
+ repository,
123
+ paths: servicePaths(agent.id, peerAddress, repository.repository, stateHome),
124
+ };
125
+ }
126
+
127
+ async function localContext({ peerAddress, provider, keyFile, repositoryRoot, stateHome }) {
128
+ if (!ADDRESS.test(peerAddress ?? "") || !["codex", "claude"].includes(provider))
129
+ throw new Error("Choose one exact peer and local AI provider.");
130
+ const base = join(stateHome ?? homedir(), ".totem", "collaboration");
131
+ const repository = await sourceCheckout(repositoryRoot).catch(() => null);
132
+ const entries = await readdir(base, { withFileTypes: true }).catch((error) => {
133
+ if (error?.code === "ENOENT") return [];
134
+ throw error;
135
+ });
136
+ const matches = [];
137
+ for (const entry of entries) {
138
+ if (!entry.isDirectory()) continue;
139
+ const directory = join(base, entry.name);
140
+ const config = await readJson(join(directory, "managed-config.json"));
141
+ if (
142
+ config?.peerAddress === peerAddress &&
143
+ config.provider === provider &&
144
+ (!keyFile || config.keyFile === resolve(keyFile)) &&
145
+ (!repository || config.repository === repository.repository)
146
+ )
147
+ matches.push({
148
+ config,
149
+ paths: {
150
+ directory,
151
+ status: join(directory, "managed-status.json"),
152
+ stop: join(directory, "managed-stop.json"),
153
+ },
154
+ });
155
+ }
156
+ if (matches.length > 1)
157
+ throw new Error("More than one listener matches. Open the intended repository to select it.");
158
+ return { match: matches[0] ?? null, repository: repository?.repository ?? null };
159
+ }
160
+
161
+ async function ensureListenerCheckout({ repository, paths, cloneCheckout }) {
162
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
163
+ try {
164
+ await access(paths.checkout);
165
+ } catch (error) {
166
+ if (error?.code !== "ENOENT") throw error;
167
+ if (cloneCheckout) await cloneCheckout(repository, paths.checkout);
168
+ else
169
+ await runFile(
170
+ "git",
171
+ [
172
+ "clone",
173
+ "--branch",
174
+ repository.currentBranch,
175
+ "--single-branch",
176
+ "--",
177
+ repository.remote,
178
+ paths.checkout,
179
+ ],
180
+ {
181
+ timeout: 60_000,
182
+ maxBuffer: 256 * 1024,
183
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
184
+ },
185
+ );
186
+ }
187
+ const listener = await inspectLocalRepository({ TOTEM_REPOSITORY_ROOT: paths.checkout });
188
+ if (
189
+ listener.repository !== repository.repository ||
190
+ listener.currentBranch !== repository.currentBranch ||
191
+ listener.headCommit !== repository.headCommit ||
192
+ !listener.clean
193
+ )
194
+ throw new Error(
195
+ "The private listener checkout does not match this clean branch and commit. Inspect it before retrying; Totem will not reset or overwrite it.",
196
+ );
197
+ return listener;
198
+ }
199
+
200
+ export async function managedCollaborationStatus(options) {
201
+ const { match, repository } = await localContext(options);
202
+ const status = match ? await readJson(match.paths.status) : null;
203
+ return {
204
+ agent: match?.config.agentAddress ?? null,
205
+ peer: options.peerAddress,
206
+ repository: match?.config.repository ?? repository,
207
+ provider: options.provider,
208
+ active: Boolean(running(status)),
209
+ state: !status
210
+ ? "not_started"
211
+ : running(status)
212
+ ? status.state
213
+ : status.state === "stopped"
214
+ ? "stopped"
215
+ : "needs_attention",
216
+ lastEvent: status?.event ?? null,
217
+ updatedAt: status?.updatedAt ?? null,
218
+ };
219
+ }
220
+
221
+ export async function previewManagedCollaboration(options) {
222
+ const { agent, repository } = await context(options);
223
+ if (!repository.clean || !repository.currentBranch)
224
+ throw new Error("Start from a clean, named Git branch so the peer has the same base commit.");
225
+ await (options.checkProvider ?? checkProvider)(options.provider);
226
+ return {
227
+ agent: agent.address,
228
+ peer: options.peerAddress,
229
+ repository: repository.repository,
230
+ branch: repository.currentBranch,
231
+ baseCommit: repository.headCommit,
232
+ provider: options.provider,
233
+ effect:
234
+ "After native 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.",
235
+ };
236
+ }
237
+
238
+ export async function startManagedCollaboration(options) {
239
+ const { agent, repository, paths } = await context(options);
240
+ if (!repository.clean || !repository.currentBranch)
241
+ throw new Error("Start from a clean, named Git branch so the peer has the same base commit.");
242
+ await (options.checkProvider ?? checkProvider)(options.provider);
243
+ const previous = await readJson(paths.status);
244
+ if (running(previous)) return managedCollaborationStatus(options);
245
+ const approved = await (options.confirmOwner ?? confirmOnMac)({
246
+ peerAddress: options.peerAddress,
247
+ provider: options.provider,
248
+ repository: repository.repository,
249
+ });
250
+ if (!approved) throw new Error("The owner did not approve automatic local collaboration.");
251
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
252
+ const startLock = join(paths.directory, "managed-start.lock");
253
+ let lock;
254
+ try {
255
+ lock = await open(startLock, "wx", 0o600);
256
+ } catch (error) {
257
+ if (error?.code === "EEXIST")
258
+ throw new Error("Another start attempt is in progress. Check status before retrying.");
259
+ throw error;
260
+ }
261
+ try {
262
+ if (running(await readJson(paths.status))) return managedCollaborationStatus(options);
263
+ await ensureListenerCheckout({ repository, paths, cloneCheckout: options.cloneCheckout });
264
+ const runId = randomUUID();
265
+ const config = {
266
+ runId,
267
+ agentId: agent.id,
268
+ agentAddress: agent.address,
269
+ peerAddress: options.peerAddress,
270
+ provider: options.provider,
271
+ repository: repository.repository,
272
+ repositoryRoot: paths.checkout,
273
+ baseUrl: options.baseUrl,
274
+ keyFile: resolve(options.keyFile),
275
+ stateDirectory: paths.directory,
276
+ };
277
+ await writeFile(paths.config, JSON.stringify(config), { mode: 0o600 });
278
+ const child = (options.spawnProcess ?? spawn)(process.execPath, [RUNNER, paths.config], {
279
+ cwd: paths.checkout,
280
+ detached: true,
281
+ stdio: "ignore",
282
+ env: {
283
+ PATH: process.env.PATH,
284
+ HOME: process.env.HOME,
285
+ CODEX_HOME: process.env.CODEX_HOME,
286
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
287
+ TOTEM_BASE_URL: options.baseUrl,
288
+ TOTEM_NETWORK_API_KEY_FILE: resolve(options.keyFile),
289
+ },
290
+ });
291
+ let spawnError;
292
+ child.once?.("error", (error) => {
293
+ spawnError = error;
294
+ });
295
+ child.unref();
296
+ for (let attempt = 0; attempt < 40; attempt++) {
297
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
298
+ if (spawnError)
299
+ throw new Error("The local listener process could not start.", { cause: spawnError });
300
+ const status = await readJson(paths.status);
301
+ if (status?.runId !== runId) continue;
302
+ if (status.state === "failed")
303
+ throw new Error(status.message || "The local listener failed to start.");
304
+ if (status.state === "ready") return managedCollaborationStatus(options);
305
+ }
306
+ await writeFile(paths.stop, JSON.stringify({ runId }), { mode: 0o600 });
307
+ throw new Error("The local listener did not become ready. Check its status before retrying.");
308
+ } finally {
309
+ await lock.close();
310
+ await unlink(startLock);
311
+ }
312
+ }
313
+
314
+ export async function stopManagedCollaboration(options) {
315
+ const { match } = await localContext(options);
316
+ if (!match) return managedCollaborationStatus(options);
317
+ const { paths } = match;
318
+ const status = await readJson(paths.status);
319
+ if (!running(status)) return managedCollaborationStatus(options);
320
+ await writeFile(paths.stop, JSON.stringify({ runId: status.runId }), { mode: 0o600 });
321
+ for (let attempt = 0; attempt < 40; attempt++) {
322
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
323
+ const current = await readJson(paths.status);
324
+ if (current?.runId === status.runId && current.state === "stopped")
325
+ return managedCollaborationStatus(options);
326
+ }
327
+ throw new Error("Stop was requested, but the listener has not confirmed it yet.");
328
+ }