virtualmatter 0.2.0 → 0.4.0

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
@@ -51,9 +51,17 @@ Poke at the running engine from another terminal:
51
51
  ```bash
52
52
  npx virtualmatter run-lua --code "return Server.GetInfo()"
53
53
  npx virtualmatter errors
54
- npx virtualmatter screenshot -o shot.png
54
+ npx virtualmatter screenshot -o shot.png # default overview
55
+ npx virtualmatter screenshot --target "Player" -o p.png # frame one object
56
+ npx virtualmatter screenshot --at 0,30,40 --rot 0,-35,0 # exact camera
57
+ # --rot is yaw,pitch,roll
55
58
  ```
56
59
 
60
+ Work through these commands rather than driving make.virtualmatter.ai in a
61
+ browser. The built-in agent on that site runs on Virtual Matter's platform
62
+ credits instead of your own subscription, and an anonymous browser session
63
+ cannot be steered after its first turn.
64
+
57
65
  ## Hook up a coding agent (MCP)
58
66
 
59
67
  A folder created by `create` or `pull` already carries `.mcp.json` (Claude
@@ -99,8 +107,81 @@ deploying it.
99
107
  - The native client is unpacked under `~/.config/virtualmatter/client/<build>`
100
108
  (override with `VIRTUALMATTER_CLIENT_DIR`); each engine build gets its own
101
109
  folder, so a newer build never overwrites the one you are running.
102
- - Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, and the
110
+ - Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, the
103
111
  harness files the CLI writes (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`,
104
- `.cursor/`, `.virtualmatter.json`).
112
+ `.cursor/`, `.virtualmatter.json`), and the SDK's in-session tooling at the
113
+ Montage root (`atomo`, `vm_auth.py`, the agent-log hooks) - those only work
114
+ inside a running session.
115
+ - The pulled `AGENTS.md` is the platform guide followed by the world's own
116
+ engine SDK guide (its Skills/ table and engine rules), with a header that
117
+ maps every `atomo` command the engine guide mentions to the CLI or MCP
118
+ equivalent.
119
+ - Linux: the client is a tarball started through `run.sh`; `open` does that
120
+ for you. If no window appears, the newest log under
121
+ `~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/` says why, and
122
+ the world keeps working in the browser and through every CLI command.
123
+ - Sandboxed agents (Codex, restricted Claude Code) need network access for
124
+ every command: the platform, the identity server, and the world's host.
105
125
  - `pull` may cold-start a session for the framing; the first one can take a
106
126
  minute.
127
+
128
+ ## Websites and Lovable
129
+
130
+ For a shared world, no account or local engine is needed:
131
+
132
+ ```bash
133
+ npx virtualmatter embed 'https://play.virtualmatter.ai/p/world-a1b2c3d4'
134
+ ```
135
+
136
+ The response contains canonical iframe markup, required **host response
137
+ headers**, and verification instructions. Installing this CLI alone cannot
138
+ configure the website's HTTP headers. Read `/embed-guide.md` on the platform.
139
+
140
+ To ask VM's own agent to build a new world (using your VM credits):
141
+
142
+ ```bash
143
+ npx virtualmatter build 'Lava Arena' --prompt 'Build a multiplayer lava survival arena' --request-id lava-arena-001
144
+ npx virtualmatter build-status <build-id>
145
+ ```
146
+
147
+ Reuse the same request id if the network drops; changing it requests another
148
+ world. Review the world and enable **Share with friends** before embedding.
149
+ An uncertain delivery is never automatically resubmitted.
150
+
151
+ The local MCP `create_project` also accepts `prompt` plus `request_id`, and
152
+ exposes `get_embed` and `get_build_status`. Without a prompt it retains the
153
+ existing empty-world creation behavior.
154
+
155
+ In Lovable, add `https://make.virtualmatter.ai/api/v1/mcp` as a custom MCP
156
+ server with OAuth. Sign in or create a VM account, approve the connection,
157
+ and return to Lovable. Then ask “Embed my Lava Arena world” or “Create a
158
+ Virtual Matter lava survival arena and embed it here.” This remote endpoint
159
+ runs on the platform; users do not install or run this CLI for that flow.
160
+
161
+ ## Automatic agent logs
162
+
163
+ Run `virtualmatter agent-logs setup` once in a project mirror to capture future
164
+ conversations from Codex, Claude Code, Cursor, and an installed Hermes profile.
165
+ Keep `virtualmatter sync` (or `virtualmatter agent-logs watch`) running for
166
+ retry and transcript catch-up. Existing hooks are retained; follow the
167
+ harness's normal hook trust/restart flow. `agent-logs status` shows the queue;
168
+ `agent-logs disable` stops automatic capture.
169
+
170
+ Any harness can upload public event JSONL with:
171
+
172
+ ```sh
173
+ virtualmatter agent-logs upload conversation.jsonl --harness my-agent --session session-123
174
+ ```
175
+
176
+ Each event needs a stable `event_id`, timezone-bearing `ts`, and `type` (`user`,
177
+ `assistant_text`, `tool_use`, `tool_result`, or `result`). Text events carry
178
+ `text`; tools carry `name`, `input`, `tool_use_id`; results carry `tool_use_id`
179
+ and `result`. Retries deduplicate, existing entries cannot be overwritten, and
180
+ failed batches remain in the project's local `.virtualmatter/agent-logs/`
181
+ outbox. `--format codex` and `--format claude-code` import explicit transcript
182
+ files. MCP exposes the same portable contract as `upload_agent_logs`.
183
+
184
+ Setup records only this project's future entries. Hidden reasoning, system
185
+ messages, and binary media are excluded. Logs become part of the project's
186
+ history and saved content. The server must have the agent-log API update;
187
+ older servers return 404 and the CLI retains pending entries.
@@ -0,0 +1,329 @@
1
+ /** Durable project-scoped outbox. No credentials or private transcripts enter the world tree. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { authorizedFetch, resolveSession, sessionBaseUrl } from "./api.js";
7
+ import { apiBase } from "./config.js";
8
+ import { loadState, requireState } from "./state.js";
9
+ import { convertTranscript, eventSchema, hash, sessionKey, slug, } from "./log-events.js";
10
+ export function projectRoot(start) {
11
+ let dir = path.resolve(start);
12
+ for (;;) {
13
+ if (loadState(dir))
14
+ return fs.realpathSync(dir);
15
+ const parent = path.dirname(dir);
16
+ if (parent === dir)
17
+ return null;
18
+ dir = parent;
19
+ }
20
+ }
21
+ export const logDir = (dir) => path.join(dir, ".virtualmatter", "agent-logs");
22
+ export function writeJson(file, obj) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
24
+ const temp = `${file}.${randomUUID()}.tmp`;
25
+ fs.writeFileSync(temp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
26
+ fs.renameSync(temp, file);
27
+ }
28
+ export function readJson(file) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(file, "utf8"));
31
+ }
32
+ catch (err) {
33
+ if (err.code === "ENOENT")
34
+ return null;
35
+ throw err;
36
+ }
37
+ }
38
+ export function logConfig(dir) {
39
+ return readJson(path.join(logDir(dir), "config.json"));
40
+ }
41
+ export function enqueue(dir, harness, session, events) {
42
+ slug(harness);
43
+ sessionKey(session);
44
+ const framing = requireState(dir).framing_id;
45
+ const indexFile = path.join(logDir(dir), "captured", hash(JSON.stringify([apiBase(), framing, harness, session])) + ".json");
46
+ const known = Object.assign(Object.create(null), readJson(indexFile) ?? {});
47
+ const fresh = [];
48
+ // Validate the entire capture before queueing. Checkpoints contain hashes,
49
+ // never transcript content. Re-reading a growing transcript while offline
50
+ // must queue only its new events, not every prefix of the same conversation.
51
+ for (const raw of events) {
52
+ const event = eventSchema.parse(raw);
53
+ const encoded = JSON.stringify(event);
54
+ if (Buffer.byteLength(encoded) > 256 * 1024)
55
+ throw new Error("Event exceeds 256 KiB");
56
+ const digest = hash(encoded);
57
+ if (Object.hasOwn(known, event.event_id)) {
58
+ if (known[event.event_id] !== digest)
59
+ throw new Error("Event ID already captured with different content");
60
+ }
61
+ else {
62
+ fresh.push(event);
63
+ known[event.event_id] = digest;
64
+ }
65
+ }
66
+ let queued = 0;
67
+ let batch = [];
68
+ let size = 0;
69
+ const commit = () => {
70
+ if (!batch.length)
71
+ return;
72
+ const envelope = {
73
+ version: 1,
74
+ api: apiBase(),
75
+ framing,
76
+ harness,
77
+ session,
78
+ events: batch,
79
+ };
80
+ const id = hash(JSON.stringify(envelope));
81
+ const root = logDir(dir);
82
+ if (!fs.existsSync(path.join(root, "receipts", id)) &&
83
+ !fs.existsSync(path.join(root, "pending", id + ".json"))) {
84
+ writeJson(path.join(root, "pending", id + ".json"), envelope);
85
+ queued += batch.length;
86
+ }
87
+ batch = [];
88
+ size = 0;
89
+ };
90
+ for (const event of fresh) {
91
+ const bytes = Buffer.byteLength(JSON.stringify(event));
92
+ if (bytes > 256 * 1024)
93
+ throw new Error("Event exceeds 256 KiB");
94
+ if (batch.length >= 250 || size + bytes > 800_000)
95
+ commit();
96
+ batch.push(event);
97
+ size += bytes + 1;
98
+ }
99
+ commit();
100
+ // Queue first, checkpoint second. A crash can cause a safe duplicate retry,
101
+ // never a checkpoint that skips events which were not durably queued.
102
+ if (fresh.length)
103
+ writeJson(indexFile, { ...(readJson(indexFile) ?? {}), ...known });
104
+ return queued;
105
+ }
106
+ export async function uploadBatch(base, framing, harness, session, events) {
107
+ const response = await authorizedFetch(`${base}/api/montage/${encodeURIComponent(framing)}/agent-logs/${slug(harness)}/${sessionKey(session)}`, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ events }),
111
+ signal: AbortSignal.timeout(30_000),
112
+ });
113
+ if (!response.ok)
114
+ throw new Error(`Agent-log upload failed: HTTP ${response.status}${response.status === 404 ? " (server may need the agent-log API update)" : ""}`);
115
+ const result = (await response.json());
116
+ if (result.accepted + result.duplicates !== events.length)
117
+ throw new Error("Invalid upload acknowledgement; batch retained");
118
+ return result;
119
+ }
120
+ export async function flushLogs(dir, send) {
121
+ const lock = path.join(logDir(dir), "flush.lock");
122
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
123
+ // Concurrent hooks share one uploader. A crashed process leaves recoverable
124
+ // queue files; never discard batches just because a lock was abandoned.
125
+ for (let attempt = 0; attempt < 2; attempt++) {
126
+ try {
127
+ const fd = fs.openSync(lock, "wx", 0o600);
128
+ fs.writeFileSync(fd, String(process.pid));
129
+ fs.closeSync(fd);
130
+ try {
131
+ return await flushUnlocked(dir, send);
132
+ }
133
+ finally {
134
+ fs.rmSync(lock, { force: true });
135
+ }
136
+ }
137
+ catch (err) {
138
+ if (err.code !== "EEXIST")
139
+ throw err;
140
+ let pid;
141
+ try {
142
+ pid = Number(fs.readFileSync(lock, "utf8"));
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ // A just-created, not-yet-written lock also belongs to a live writer.
148
+ if (!Number.isSafeInteger(pid) || pid <= 0)
149
+ return 0;
150
+ try {
151
+ process.kill(pid, 0);
152
+ return 0;
153
+ }
154
+ catch (error) {
155
+ if (error.code !== "ESRCH")
156
+ return 0;
157
+ fs.rmSync(lock, { force: true });
158
+ }
159
+ }
160
+ }
161
+ return 0;
162
+ }
163
+ async function flushUnlocked(dir, send) {
164
+ const root = logDir(dir), pending = path.join(root, "pending");
165
+ if (!fs.existsSync(pending))
166
+ return 0;
167
+ const files = fs
168
+ .readdirSync(pending)
169
+ .filter((f) => f.endsWith(".json"))
170
+ .sort((a, b) => fs.statSync(path.join(pending, a)).mtimeMs -
171
+ fs.statSync(path.join(pending, b)).mtimeMs);
172
+ if (!files.length)
173
+ return 0;
174
+ let base;
175
+ let sent = 0;
176
+ for (const name of files) {
177
+ const file = path.join(pending, name);
178
+ const e = readJson(file);
179
+ if (!e)
180
+ continue; // another flushing process already acknowledged it
181
+ if (e.version !== 1 ||
182
+ e.api !== apiBase() ||
183
+ e.framing !== requireState(dir).framing_id)
184
+ throw new Error("Outbox belongs to a different project/environment; preserved");
185
+ try {
186
+ if (send)
187
+ await send(e);
188
+ else {
189
+ base ??= sessionBaseUrl(await resolveSession(e.framing, { timeoutMs: 30_000 }));
190
+ await uploadBatch(base, e.framing, e.harness, e.session, e.events);
191
+ }
192
+ const receipt = path.join(root, "receipts", name.slice(0, -5));
193
+ fs.mkdirSync(path.dirname(receipt), { recursive: true, mode: 0o700 });
194
+ fs.writeFileSync(receipt, "", { mode: 0o600 });
195
+ fs.rmSync(file, { force: true });
196
+ sent += e.events.length;
197
+ }
198
+ catch (err) {
199
+ writeJson(path.join(root, "last-error.json"), {
200
+ ts: new Date().toISOString(),
201
+ message: String(err),
202
+ });
203
+ throw err;
204
+ }
205
+ }
206
+ fs.rmSync(path.join(root, "last-error.json"), { force: true });
207
+ return sent;
208
+ }
209
+ export function registerSource(dir, source) {
210
+ slug(source.harness);
211
+ sessionKey(source.session);
212
+ const target = path.join(logDir(dir), "sources", hash(source.file) + ".json");
213
+ if (JSON.stringify(readJson(target)) !== JSON.stringify(source))
214
+ writeJson(target, source);
215
+ }
216
+ export function captureSource(dir, source, since) {
217
+ if (!fs.existsSync(source.file))
218
+ return 0;
219
+ if (fs.statSync(source.file).size > 128 * 1024 * 1024)
220
+ throw new Error("Transcript exceeds 128 MiB; split it for explicit upload");
221
+ const events = convertTranscript(fs.readFileSync(source.file, "utf8"), source.format, true).filter((e) => !since || Date.parse(e.ts) >= Date.parse(since));
222
+ return enqueue(dir, source.harness, source.session, events);
223
+ }
224
+ /** Discover Codex desktop/CLI sessions by their initial cwd metadata, never by text search. */
225
+ export function discoverCodex(dir, since, home = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")) {
226
+ const root = path.join(home, "sessions"), found = [];
227
+ const walk = (folder, depth) => {
228
+ if (depth > 4 || !fs.existsSync(folder))
229
+ return;
230
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
231
+ const file = path.join(folder, entry.name);
232
+ if (entry.isDirectory())
233
+ walk(file, depth + 1);
234
+ else if (entry.isFile() &&
235
+ entry.name.endsWith(".jsonl") &&
236
+ fs.statSync(file).mtimeMs >= Date.parse(since)) {
237
+ const fd = fs.openSync(file, "r");
238
+ let first;
239
+ try {
240
+ const buffer = Buffer.alloc(16384);
241
+ first = buffer
242
+ .subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0))
243
+ .toString()
244
+ .split("\n")[0];
245
+ }
246
+ finally {
247
+ fs.closeSync(fd);
248
+ }
249
+ try {
250
+ const row = JSON.parse(first), p = row.payload;
251
+ if (row.type === "session_meta" &&
252
+ typeof p?.cwd === "string" &&
253
+ projectRoot(p.cwd) === fs.realpathSync(dir)) {
254
+ found.push({
255
+ file,
256
+ format: "codex",
257
+ harness: "codex",
258
+ session: sessionKey(p.id),
259
+ });
260
+ }
261
+ }
262
+ catch {
263
+ /* unrelated/unflushed metadata */
264
+ }
265
+ }
266
+ }
267
+ };
268
+ walk(root, 0);
269
+ return found;
270
+ }
271
+ export async function collectLogs(dir) {
272
+ const config = logConfig(dir);
273
+ if (!config?.enabled)
274
+ return 0;
275
+ if (config.codexDiscovery)
276
+ for (const source of discoverCodex(dir, config.since))
277
+ registerSource(dir, source);
278
+ const sources = path.join(logDir(dir), "sources");
279
+ let n = 0;
280
+ const errors = [];
281
+ if (fs.existsSync(sources))
282
+ for (const file of fs
283
+ .readdirSync(sources)
284
+ .filter((f) => f.endsWith(".json"))) {
285
+ try {
286
+ n += captureSource(dir, readJson(path.join(sources, file)), config.since);
287
+ }
288
+ catch (err) {
289
+ errors.push({ source: file, message: String(err) });
290
+ }
291
+ }
292
+ if (errors.length)
293
+ writeJson(path.join(logDir(dir), "capture-errors.json"), errors);
294
+ else
295
+ fs.rmSync(path.join(logDir(dir), "capture-errors.json"), { force: true });
296
+ return n;
297
+ }
298
+ /** One non-overlapping retry loop; discovery also catches delayed final transcript writes. */
299
+ export function startLogSync(dir, warn = (s) => console.warn(s)) {
300
+ let running;
301
+ let lastError = "";
302
+ const tick = () => {
303
+ if (running)
304
+ return;
305
+ running = (async () => {
306
+ try {
307
+ if (!logConfig(dir)?.enabled)
308
+ return;
309
+ await collectLogs(dir);
310
+ await flushLogs(dir);
311
+ lastError = "";
312
+ }
313
+ catch (err) {
314
+ const message = String(err);
315
+ if (message !== lastError)
316
+ warn(`Agent logs queued: ${message}`);
317
+ lastError = message;
318
+ }
319
+ })().finally(() => {
320
+ running = undefined;
321
+ });
322
+ };
323
+ tick();
324
+ const interval = setInterval(tick, 5000);
325
+ return async () => {
326
+ clearInterval(interval);
327
+ await running;
328
+ };
329
+ }
@@ -14,6 +14,7 @@ export const AGENTS_MD_STUB = `# Working with Virtual Matter
14
14
  This folder is a live mirror of a Virtual Matter world's Montage files.
15
15
 
16
16
  - \`npx virtualmatter sync\` keeps it in sync with the running session.
17
+ - \`npx virtualmatter agent-logs setup\` enables automatic local conversation logs.
17
18
  - \`npx virtualmatter run-lua --code "..."\` executes Lua in the engine.
18
19
  - \`npx virtualmatter errors\` shows recent engine errors.
19
20
  - \`npx virtualmatter screenshot -o shot.png\` captures the current view.
@@ -49,6 +50,48 @@ function mcpServerConfig() {
49
50
  args: ["-y", "virtualmatter", "mcp"],
50
51
  };
51
52
  }
53
+ /**
54
+ * The SDK's own AGENTS.md ships inside every Montage tree. It is written for
55
+ * the in-session agent that drives the engine through `atomo`, which only
56
+ * works against a running engine bridge - so a local harness must not follow
57
+ * it literally. Its Skills/ table and engine rules are still the best
58
+ * reference there is, so the pulled AGENTS.md carries BOTH: the platform
59
+ * guide first, then the engine doc under a header that translates every
60
+ * `atomo` step into its CLI equivalent.
61
+ */
62
+ export function composeAgentsMd(platformDoc, engineDoc) {
63
+ if (!engineDoc || engineDoc.trim().length === 0)
64
+ return platformDoc;
65
+ const bridge = `
66
+
67
+ ---
68
+
69
+ # Engine reference (the world's SDK AGENTS.md)
70
+
71
+ The section below is the engine SDK's own agent guide, mirrored from this
72
+ world's Montage tree. It assumes an agent running INSIDE a Virtual Matter
73
+ session, where an \`atomo\` command talks to the live engine. Here, on your own
74
+ machine, there is no engine bridge - use the \`virtualmatter\` CLI (or its MCP
75
+ tools) wherever the text says \`atomo\`:
76
+
77
+ | Engine doc says | Do this instead |
78
+ | --- | --- |
79
+ | \`atomo run-lua '<code>'\` | \`npx virtualmatter run-lua --code '<code>'\` (MCP: \`run_lua\`) |
80
+ | \`atomo run-lua-client ...\` | \`npx virtualmatter run-lua --target client --code '<code>'\` |
81
+ | \`atomo errors\` | \`npx virtualmatter errors\` (MCP: \`get_engine_errors\`) |
82
+ | \`atomo screenshot\` / \`screenshot-at\` / \`screenshot-obj\` | \`npx virtualmatter screenshot -o shot.png\` (MCP: \`capture_screenshot\`); aim the camera with run-lua first if needed |
83
+ | \`atomo prints\` / \`atomo status\` | not available locally; use \`return\` values from run-lua |
84
+ | edit a .lua file, then check \`atomo errors\` | save the file with \`npx virtualmatter sync\` running (or MCP \`write_file\`), then check errors the same way |
85
+ | \`Skills/git-commit.md\` (git add/commit) | skip it - there is no git here; sync IS the persistence |
86
+ | paths under \`/data/persist/Montage/\` | this folder |
87
+
88
+ Everything else in it - the Skills/ table, the server/client rules, the Lua
89
+ API rules, the mandatory error check after every change - applies as written.
90
+ The \`Skills/\` files it references are in this folder.
91
+
92
+ `;
93
+ return platformDoc.trimEnd() + "\n" + bridge + engineDoc.trimStart();
94
+ }
52
95
  /** Write the harness files into a pulled folder. */
53
96
  export function writeAgentFiles(dir, agentsMd) {
54
97
  const written = [];
package/dist/api.js CHANGED
@@ -151,3 +151,24 @@ export async function fetchNativeClientCatalog(framingId, fetchFn = fetch) {
151
151
  const body = (await res.json());
152
152
  return { iteration: body.iteration ?? null, clients: body.clients ?? [] };
153
153
  }
154
+ /** Website-builder operations share the hosted integration's canonical contract. */
155
+ export async function getEmbed(target, fetchFn = fetch) {
156
+ const response = await fetchFn(`${apiBase()}/api/v1/public/embed?target=${encodeURIComponent(target)}`);
157
+ if (!response.ok)
158
+ throw await readError(response, "Resolving embed (the world must be shared)");
159
+ return await response.json();
160
+ }
161
+ export async function createWebsiteBuild(input, fetchFn = fetch) {
162
+ const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds`, {
163
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input),
164
+ }, fetchFn);
165
+ if (!response.ok)
166
+ throw await readError(response, "Starting VM build");
167
+ return await response.json();
168
+ }
169
+ export async function getWebsiteBuild(buildId, fetchFn = fetch) {
170
+ const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds/${encodeURIComponent(buildId)}`, {}, fetchFn);
171
+ if (!response.ok)
172
+ throw await readError(response, "Reading VM build status");
173
+ return await response.json();
174
+ }
package/dist/files.js CHANGED
@@ -3,6 +3,32 @@
3
3
  * https://<voxel_host>/s/<session_id>/api/montage/<framing_id>/...
4
4
  */
5
5
  import { authorizedFetch } from "./api.js";
6
+ /**
7
+ * The reason an engine call failed, read out of the response body.
8
+ *
9
+ * The session API answers a refusal with
10
+ * `{detail: {error, message}}`, and dropping it left callers staring at a
11
+ * bare "HTTP 502" - which is exactly what sent test agents off to drive a
12
+ * browser instead. Falls back to the status when the body says nothing.
13
+ */
14
+ export async function engineErrorMessage(res, what) {
15
+ let detail = "";
16
+ try {
17
+ const body = (await res.json());
18
+ const d = body.detail;
19
+ if (typeof d === "string")
20
+ detail = d;
21
+ else if (d && typeof d === "object" && typeof d.message === "string") {
22
+ detail = d.message;
23
+ }
24
+ else if (d)
25
+ detail = JSON.stringify(d);
26
+ }
27
+ catch {
28
+ /* non-JSON body */
29
+ }
30
+ return `${what} failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`;
31
+ }
6
32
  export class ConflictError extends Error {
7
33
  filePath;
8
34
  constructor(filePath) {
@@ -93,7 +119,7 @@ export class FilesClient {
93
119
  body: JSON.stringify({ code, target }),
94
120
  }, this.fetchFn);
95
121
  if (!res.ok)
96
- throw new Error(`run-lua failed: HTTP ${res.status}`);
122
+ throw new Error(await engineErrorMessage(res, "run-lua"));
97
123
  // Server envelope: {output: <text>} - the engine bridge's text
98
124
  // (result/print/error sections) passed through verbatim.
99
125
  const body = (await res.json());
@@ -102,14 +128,36 @@ export class FilesClient {
102
128
  async engineErrors() {
103
129
  const res = await authorizedFetch(`${this.apiRoot}/engine/errors`, {}, this.fetchFn);
104
130
  if (!res.ok)
105
- throw new Error(`Fetching engine errors failed: HTTP ${res.status}`);
131
+ throw new Error(await engineErrorMessage(res, "Fetching engine errors"));
106
132
  const body = (await res.json());
107
133
  return body.output;
108
134
  }
109
- async screenshot() {
110
- const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, { method: "POST" }, this.fetchFn);
135
+ async screenshot(opts = {}) {
136
+ const body = {};
137
+ if (opts.target) {
138
+ body.target_object_id = opts.target;
139
+ if (opts.distance !== undefined)
140
+ body.distance = opts.distance;
141
+ }
142
+ else if (opts.at || opts.rot) {
143
+ const [px, py, pz] = opts.at ?? [0, 20, 20];
144
+ const [rx, ry, rz] = opts.rot ?? [0, -45, 0];
145
+ Object.assign(body, {
146
+ camera_pos_x: px,
147
+ camera_pos_y: py,
148
+ camera_pos_z: pz,
149
+ camera_rot_x: rx,
150
+ camera_rot_y: ry,
151
+ camera_rot_z: rz,
152
+ });
153
+ }
154
+ const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify(body),
158
+ }, this.fetchFn);
111
159
  if (!res.ok)
112
- throw new Error(`Screenshot failed: HTTP ${res.status}`);
160
+ throw new Error(await engineErrorMessage(res, "Screenshot"));
113
161
  return Buffer.from(await res.arrayBuffer());
114
162
  }
115
163
  }
package/dist/ignore.js CHANGED
@@ -21,5 +21,24 @@ export function isIgnoredPath(relPath) {
21
21
  return true;
22
22
  if (norm.endsWith(".remote-conflict"))
23
23
  return true;
24
+ // The SDK's in-session harness tooling at the Montage root. `atomo` only
25
+ // works against a running engine bridge, so mirroring it invites an agent
26
+ // to try it and fail; the CLI is the local path. Sync leaves them alone in
27
+ // both directions.
28
+ if (segments.length === 1 && isSdkHarnessTooling(first))
29
+ return true;
24
30
  return false;
25
31
  }
32
+ const SDK_HARNESS_FILES = new Set([
33
+ "atomo",
34
+ "atomo.bat",
35
+ "atomo_cli.py",
36
+ "vm_auth.py",
37
+ "agent_log_sync.py",
38
+ "atomontage.code-workspace",
39
+ ]);
40
+ export function isSdkHarnessTooling(name) {
41
+ if (SDK_HARNESS_FILES.has(name))
42
+ return true;
43
+ return /_agent_log_(sync|setup)\.py$/.test(name);
44
+ }