@dzbot/dzstudio-mcp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +162 -2
  3. package/dist/config/config.js +172 -0
  4. package/dist/config/config.js.map +1 -0
  5. package/dist/config/sessions.js +67 -0
  6. package/dist/config/sessions.js.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/mcp/agent.js +1148 -0
  10. package/dist/mcp/agent.js.map +1 -0
  11. package/dist/mcp/server.js +263 -0
  12. package/dist/mcp/server.js.map +1 -0
  13. package/dist/protocol/client.js +134 -0
  14. package/dist/protocol/client.js.map +1 -0
  15. package/dist/protocol/session.js +837 -0
  16. package/dist/protocol/session.js.map +1 -0
  17. package/dist/protocol/transport.js +66 -0
  18. package/dist/protocol/transport.js.map +1 -0
  19. package/dist/scene/chunks.js +30 -0
  20. package/dist/scene/chunks.js.map +1 -0
  21. package/dist/scene/edits.js +183 -0
  22. package/dist/scene/edits.js.map +1 -0
  23. package/dist/scene/float32.js +167 -0
  24. package/dist/scene/float32.js.map +1 -0
  25. package/dist/scene/ids.js +43 -0
  26. package/dist/scene/ids.js.map +1 -0
  27. package/dist/scene/index.js +9 -0
  28. package/dist/scene/index.js.map +1 -0
  29. package/dist/scene/json.js +294 -0
  30. package/dist/scene/json.js.map +1 -0
  31. package/dist/scene/projectload.js +663 -0
  32. package/dist/scene/projectload.js.map +1 -0
  33. package/dist/scene/scene.js +506 -0
  34. package/dist/scene/scene.js.map +1 -0
  35. package/dist/scene/srcjson.js +297 -0
  36. package/dist/scene/srcjson.js.map +1 -0
  37. package/dist/scene/textform.js +257 -0
  38. package/dist/scene/textform.js.map +1 -0
  39. package/dist/scene/types.js +35 -0
  40. package/dist/scene/types.js.map +1 -0
  41. package/dist/world/data.js +604 -0
  42. package/dist/world/data.js.map +1 -0
  43. package/dist/world/format.js +205 -0
  44. package/dist/world/format.js.map +1 -0
  45. package/dist/world/source.js +267 -0
  46. package/dist/world/source.js.map +1 -0
  47. package/dist/world/tools.js +530 -0
  48. package/dist/world/tools.js.map +1 -0
  49. package/package.json +48 -4
@@ -0,0 +1,1148 @@
1
+ import { join } from 'node:path';
2
+ import { SessionStore } from '../config/sessions.js';
3
+ import { agentClient, CollabClient } from '../protocol/client.js';
4
+ import { AlreadyRunning, CollabSession, displayName, STATUS_MAX_BYTES, } from '../protocol/session.js';
5
+ import { CollabError, HttpTransport, UnreachableError } from '../protocol/transport.js';
6
+ import { BuildError, normalizeAngle, OpBuilder } from '../scene/edits.js';
7
+ import { readFloat32, readVec3, writeFloat32, writeVec3 } from '../scene/float32.js';
8
+ import { PROJECT_ID } from '../scene/ids.js';
9
+ import { loadProject, LoadRefused, neededForLoad } from '../scene/projectload.js';
10
+ export const MAX_HELD = 20_000;
11
+ export const PAGE_SIZE = 500;
12
+ export const IDLE_LEAVE_MS = 30 * 60_000;
13
+ /** The project state of a session is written to disk at most this often while operations arrive. */
14
+ export const STATE_SAVE_MS = 5000;
15
+ const GROUP_COLOURS = ['#60A5FA', '#F87171', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#22D3EE', '#FB923C'];
16
+ /** A failure the agent should read: the message is the tool result. */
17
+ export class ToolError extends Error {
18
+ }
19
+ const PHASE_TEXT = { waiting: 'waiting for work', working: 'working', review: 'waiting for review' };
20
+ function plural(n, word) {
21
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
22
+ }
23
+ function validateStatus(status) {
24
+ if (/[\r\n]/.test(status) || /[\u0000-\u001f\u007f]/.test(status))
25
+ throw new ToolError('Status must be one line');
26
+ if (Buffer.byteLength(status, 'utf8') > STATUS_MAX_BYTES)
27
+ throw new ToolError('Status too long (at most 200 bytes)');
28
+ }
29
+ /** A float32 as the JSON number of its shortest text, so 1234.5679 does not show as 1234.56787109375. */
30
+ function f32(x) {
31
+ return Number(writeFloat32(x));
32
+ }
33
+ function vec(text) {
34
+ if (text === undefined)
35
+ return undefined;
36
+ try {
37
+ return readVec3(text).map(f32);
38
+ }
39
+ catch {
40
+ return text;
41
+ }
42
+ }
43
+ function num(text) {
44
+ if (text === undefined)
45
+ return undefined;
46
+ try {
47
+ return f32(readFloat32(text));
48
+ }
49
+ catch {
50
+ return text;
51
+ }
52
+ }
53
+ /**
54
+ * Everything the tools do, without the MCP wiring: servers and logins, the sessions the agent is in, and the edits
55
+ * it makes in them.
56
+ */
57
+ export class Agent {
58
+ opts;
59
+ sessions = new Map();
60
+ current;
61
+ nextHandle = 1;
62
+ logins = new Map();
63
+ infos = new Map();
64
+ agentEvents = [];
65
+ idleTimer;
66
+ saved;
67
+ /** Settles once the sessions of the last run are restored (or given up); tools wait for it. */
68
+ ready;
69
+ /** The class data, for opening a project from its files; set by the world tools. */
70
+ classes;
71
+ constructor(opts) {
72
+ this.opts = opts;
73
+ this.idleTimer = setInterval(() => void this.leaveIdle(), 60_000);
74
+ this.idleTimer.unref();
75
+ this.saved = new SessionStore(join(opts.store.dir, 'sessions'));
76
+ this.ready = this.restoreAll();
77
+ }
78
+ get store() {
79
+ return this.opts.store;
80
+ }
81
+ client(server) {
82
+ return new CollabClient(this.opts.transport ? this.opts.transport(server.url) : new HttpTransport(server.url));
83
+ }
84
+ credential(server) {
85
+ const c = this.store.credential(server.name);
86
+ if (!c)
87
+ throw new ToolError(`Not signed in to ${server.name} — call login`);
88
+ return c;
89
+ }
90
+ async info(server, fresh = false) {
91
+ const cached = this.infos.get(server.name);
92
+ if (!fresh && cached && Date.now() - cached.at < 60_000)
93
+ return cached.info;
94
+ try {
95
+ const info = await this.client(server).info();
96
+ this.infos.set(server.name, { info, at: Date.now() });
97
+ return info;
98
+ }
99
+ catch (e) {
100
+ throw new ToolError(e instanceof UnreachableError ? 'Server not reachable' : e.message);
101
+ }
102
+ }
103
+ /** Runs a request that needs the login; a rejected token drops the stored one. */
104
+ async authed(server, fn) {
105
+ const cred = this.credential(server);
106
+ try {
107
+ return await fn(this.client(server), cred.token);
108
+ }
109
+ catch (e) {
110
+ if (e instanceof CollabError && ['token_invalid', 'token_expired', 'auth_required'].includes(e.code)) {
111
+ this.store.setCredential(server.name, undefined);
112
+ throw new ToolError(`Not signed in to ${server.name} — call login`);
113
+ }
114
+ if (e instanceof UnreachableError)
115
+ throw new ToolError('Server not reachable');
116
+ throw e;
117
+ }
118
+ }
119
+ close() {
120
+ clearInterval(this.idleTimer);
121
+ for (const r of this.sessions.values())
122
+ clearTimeout(r.stateTimer);
123
+ }
124
+ /**
125
+ * Stops without leaving: every session is saved with its state, and the next start returns to it with `resume`.
126
+ * The server takes the agent out on its own if it does not come back.
127
+ */
128
+ async shutdown() {
129
+ this.close();
130
+ await this.ready.catch(() => undefined);
131
+ for (const r of this.sessions.values()) {
132
+ const c = r.collab;
133
+ if (!c || !c.active || r.idleLeft)
134
+ continue;
135
+ this.persist(r);
136
+ this.writeState(r);
137
+ await c.stop();
138
+ }
139
+ }
140
+ // ------------------------------------------------------------ returning after a restart
141
+ persist(r) {
142
+ const c = r.collab;
143
+ if (!c || !c.participantId)
144
+ return;
145
+ this.saved.save({
146
+ handle: r.handle,
147
+ server: r.server,
148
+ sessionId: r.sessionId,
149
+ projectName: r.projectName,
150
+ map: r.map,
151
+ participantId: c.participantId,
152
+ prefix: c.prefix,
153
+ clientSeq: c.nextClientSeq,
154
+ lockSeq: c.lockSeq,
155
+ phase: c.phase,
156
+ status: c.status,
157
+ lastWorkMs: r.lastWorkMs,
158
+ });
159
+ }
160
+ writeState(r) {
161
+ clearTimeout(r.stateTimer);
162
+ r.stateTimer = undefined;
163
+ const c = r.collab;
164
+ if (c?.active)
165
+ this.saved.saveState(r.server, r.sessionId, c.appliedSeq, c.scene.stateText());
166
+ }
167
+ /** Keeps a session on disk from now on: counters at once, the state when it changed, at most every few seconds. */
168
+ attach(r) {
169
+ const c = r.collab;
170
+ r.saved = undefined;
171
+ c.onPersist = (what) => {
172
+ if (what === 'meta')
173
+ this.persist(r);
174
+ else if (!r.stateTimer) {
175
+ r.stateTimer = setTimeout(() => this.writeState(r), STATE_SAVE_MS);
176
+ r.stateTimer.unref();
177
+ }
178
+ };
179
+ this.persist(r);
180
+ this.writeState(r);
181
+ }
182
+ /** The agent is out of a session for good. */
183
+ drop(r) {
184
+ clearTimeout(r.stateTimer);
185
+ this.saved.remove(r.server, r.sessionId);
186
+ this.sessions.delete(r.handle);
187
+ if (this.current === r.handle)
188
+ this.current = undefined;
189
+ }
190
+ async restoreAll() {
191
+ const limit = this.opts.idleLeaveMs ?? IDLE_LEAVE_MS;
192
+ for (const s of this.saved.list().sort((a, b) => a.handle.localeCompare(b.handle, 'en', { numeric: true }))) {
193
+ const handle = this.sessions.has(s.handle) || !/^s[0-9]+$/.test(s.handle) ? `s${this.nextHandle}` : s.handle;
194
+ this.nextHandle = Math.max(this.nextHandle, Number(handle.slice(1)) + 1);
195
+ const r = {
196
+ handle,
197
+ server: s.server,
198
+ sessionId: s.sessionId,
199
+ projectName: s.projectName,
200
+ map: s.map,
201
+ collab: undefined,
202
+ idleLeft: false,
203
+ lastWorkMs: s.lastWorkMs,
204
+ saved: s,
205
+ };
206
+ this.sessions.set(handle, r);
207
+ if (s.phase === 'waiting' && Date.now() - s.lastWorkMs >= limit) {
208
+ // it would have left by now
209
+ await this.leaveSaved(r, s);
210
+ this.drop(r);
211
+ continue;
212
+ }
213
+ this.current ??= handle;
214
+ await this.restore(r).catch(() => undefined);
215
+ }
216
+ }
217
+ async leaveSaved(r, s) {
218
+ try {
219
+ const server = this.store.server(r.server);
220
+ const token = this.store.credential(server.name)?.token;
221
+ if (token)
222
+ await this.client(server).request('session/leave', { sessionId: s.sessionId, participantId: s.participantId }, token);
223
+ }
224
+ catch {
225
+ // the server takes it out on its own
226
+ }
227
+ }
228
+ /** Back into a saved session: with `resume` from the saved state, otherwise joined anew. */
229
+ async restore(r) {
230
+ const s = r.saved;
231
+ let server;
232
+ let cred;
233
+ try {
234
+ server = this.store.server(r.server);
235
+ cred = this.credential(server);
236
+ }
237
+ catch (e) {
238
+ this.drop(r);
239
+ throw e;
240
+ }
241
+ try {
242
+ const back = await CollabSession.rejoin(this.sessionOptions(server, r, cred), s, this.saved.state(r.server, r.sessionId));
243
+ if (back) {
244
+ r.collab = back;
245
+ this.agentEvents.push(`[${r.handle}] Back in ${r.projectName || r.sessionId} after a restart`);
246
+ }
247
+ else {
248
+ r.collab = await this.connect(server, r, cred);
249
+ r.collab.setPhase(s.phase, s.status);
250
+ this.agentEvents.push(`[${r.handle}] Joined ${r.projectName || r.sessionId} again after a restart (the server could not continue the old place)`);
251
+ }
252
+ this.attach(r);
253
+ }
254
+ catch (e) {
255
+ if (e instanceof UnreachableError || (e instanceof ToolError && e.message === 'Server not reachable'))
256
+ throw e;
257
+ this.drop(r);
258
+ const why = e instanceof CollabError && e.code === 'not_participant' ? 'the agent was removed' : e.message;
259
+ this.agentEvents.push(`[${r.handle}] Could not return to ${r.projectName || r.sessionId} after a restart: ${why}`);
260
+ throw e;
261
+ }
262
+ }
263
+ // ------------------------------------------------------------ events
264
+ /** Events since the last result, of every session; applies what they mean for the phase. */
265
+ collectEvents() {
266
+ const out = this.agentEvents.splice(0);
267
+ for (const r of this.sessions.values()) {
268
+ const c = r.collab;
269
+ if (!c)
270
+ continue;
271
+ for (const e of c.drainEvents()) {
272
+ this.onEvent(r, c, e);
273
+ out.push(`[${r.handle}] ${e.text}`);
274
+ }
275
+ }
276
+ return out;
277
+ }
278
+ onEvent(r, c, e) {
279
+ if (e.kind === 'saved' && c.phase === 'review' && Number(e.data?.savedSeq) >= c.lastOwnSeq)
280
+ c.setPhase('waiting', '');
281
+ if (e.kind === 'ended' || e.kind === 'removed')
282
+ this.drop(r);
283
+ }
284
+ async waitEvents(handle, ms) {
285
+ const first = this.collectEvents();
286
+ if (first.length || ms <= 0)
287
+ return first;
288
+ const records = handle ? [this.record(handle)] : [...this.sessions.values()];
289
+ const live = records.map((r) => r.collab).filter((c) => !!c);
290
+ if (!live.length)
291
+ return [];
292
+ await Promise.race(live.map((c) => c.waitForEvents(ms)));
293
+ return this.collectEvents();
294
+ }
295
+ // ------------------------------------------------------------ servers and login
296
+ async listServers() {
297
+ const servers = this.store.load().servers;
298
+ const rows = await Promise.all(servers.map(async (s) => {
299
+ const cred = this.store.credential(s.name);
300
+ const row = { name: s.name, url: s.url, signedIn: !!cred };
301
+ if (cred)
302
+ row.signedInAs = cred.name;
303
+ try {
304
+ const info = await this.info(s, true);
305
+ row.agents = info.capabilities.includes('agents');
306
+ row.reachable = true;
307
+ }
308
+ catch {
309
+ row.reachable = false;
310
+ }
311
+ return row;
312
+ }));
313
+ return { text: rows.length ? `${plural(rows.length, 'server')}` : 'No server configured — call add_server', data: { servers: rows } };
314
+ }
315
+ async addServer(name, url) {
316
+ if (this.store.load().servers.some((s) => s.name === name))
317
+ throw new ToolError(`A server named ${name} already exists`);
318
+ const entry = { name, url };
319
+ let info;
320
+ try {
321
+ info = await this.client(entry).info();
322
+ }
323
+ catch {
324
+ throw new ToolError('Server not reachable');
325
+ }
326
+ let saved;
327
+ try {
328
+ saved = this.store.addServer(name, url);
329
+ }
330
+ catch (e) {
331
+ throw new ToolError(e.message);
332
+ }
333
+ this.infos.set(name, { info, at: Date.now() });
334
+ const agents = info.capabilities.includes('agents');
335
+ return {
336
+ text: `Added ${name} (${info.name})${agents ? '' : ' — this server knows no agents yet; joining its sessions is not possible'}`,
337
+ data: { name, url: saved.url, serverName: info.name, agents, login: info.auth.map((a) => a.method) },
338
+ };
339
+ }
340
+ async login(serverName, displayName) {
341
+ const server = this.resolveServer(serverName);
342
+ const info = await this.info(server, true);
343
+ const client = this.client(server);
344
+ const methods = info.auth.map((a) => a.method);
345
+ if (!methods.includes('code') || !info.capabilities.includes('auth.code')) {
346
+ if (methods.includes('name') && displayName) {
347
+ const res = await client.nameLogin(displayName, agentClient(this.store.load().agentName));
348
+ this.store.setCredential(server.name, { token: res.token, name: res.name });
349
+ return { text: `Signed in to ${server.name} as ${res.name}` };
350
+ }
351
+ if (methods.includes('name'))
352
+ throw new ToolError(`${server.name} only offers login by name — call login with a name`);
353
+ throw new ToolError(`${server.name} offers no login this MCP can use (code login is needed)`);
354
+ }
355
+ const pending = this.logins.get(server.name);
356
+ if (!pending || pending.expiresAt <= Date.now()) {
357
+ const start = await client.codeStart(agentClient(this.store.load().agentName));
358
+ this.logins.set(server.name, {
359
+ loginCode: start.loginCode,
360
+ url: start.url,
361
+ expiresAt: Date.now() + start.expiresInSeconds * 1000,
362
+ intervalMs: start.intervalSeconds * 1000,
363
+ });
364
+ const code = start.loginCode.length === 8 ? `${start.loginCode.slice(0, 4)}-${start.loginCode.slice(4)}` : start.loginCode;
365
+ return {
366
+ text: `Ask the user to open ${start.url} and confirm the code ${code}. Then call login again to finish.`,
367
+ data: { code, url: start.url, expiresInSeconds: start.expiresInSeconds },
368
+ };
369
+ }
370
+ const deadline = Math.min(pending.expiresAt, Date.now() + (this.opts.loginWaitMs ?? 60_000));
371
+ for (;;) {
372
+ const st = await client.codePoll(pending.loginCode);
373
+ if (st.state === 'confirmed') {
374
+ this.logins.delete(server.name);
375
+ const cred = { token: st.token, name: st.name };
376
+ if (st.expiresInSeconds !== undefined)
377
+ cred.expiresAt = Date.now() + st.expiresInSeconds * 1000;
378
+ this.store.setCredential(server.name, cred);
379
+ return { text: `Signed in as ${st.name}` };
380
+ }
381
+ if (st.state === 'expired') {
382
+ this.logins.delete(server.name);
383
+ throw new ToolError('Code expired');
384
+ }
385
+ if (st.state === 'denied') {
386
+ this.logins.delete(server.name);
387
+ throw new ToolError('Sign-in denied');
388
+ }
389
+ if (Date.now() + pending.intervalMs > deadline)
390
+ break;
391
+ await new Promise((r) => setTimeout(r, pending.intervalMs));
392
+ }
393
+ return { text: `Still waiting for the confirmation at ${pending.url} — call login again after the user confirmed` };
394
+ }
395
+ resolveServer(name) {
396
+ try {
397
+ return this.store.server(name);
398
+ }
399
+ catch (e) {
400
+ throw new ToolError(e.message);
401
+ }
402
+ }
403
+ // ------------------------------------------------------------ projects and sessions
404
+ async listProjects(serverName) {
405
+ const server = this.resolveServer(serverName);
406
+ const info = await this.info(server);
407
+ return this.authed(server, async (client, token) => {
408
+ const sessions = await client.sessionsList(token);
409
+ const projects = info.capabilities.includes('hosted') ? (await client.projectList(token)).projects : [];
410
+ return {
411
+ text: `${plural(projects.length, 'project')}, ${plural(sessions.length, 'session')} to join on ${server.name}`,
412
+ data: {
413
+ projects: projects.map((p) => ({
414
+ key: p.hostedRef?.key,
415
+ name: p.projectName,
416
+ map: p.map,
417
+ sessionRunning: typeof p.sessionId === 'string',
418
+ canSave: !!p.canSave,
419
+ })),
420
+ sessions: sessions.map((s) => ({
421
+ sessionId: s.sessionId,
422
+ kind: s.kind,
423
+ project: s.projectName,
424
+ map: s.map,
425
+ host: s.host?.name,
426
+ participants: s.participants,
427
+ access: s.access,
428
+ })),
429
+ },
430
+ };
431
+ });
432
+ }
433
+ async openProject(serverName, project) {
434
+ const server = this.resolveServer(serverName);
435
+ const info = await this.info(server);
436
+ if (!info.capabilities.includes('hosted'))
437
+ throw new ToolError(`${server.name} has no projects on the server`);
438
+ if (!info.capabilities.includes('agents'))
439
+ throw new ToolError('Server knows no agents');
440
+ const found = await this.authed(server, async (client, token) => {
441
+ const { projects } = await client.projectList(token);
442
+ const want = project.toLowerCase();
443
+ const p = projects.find((x) => x.hostedRef?.key === project) ?? projects.find((x) => String(x.projectName).toLowerCase() === want);
444
+ if (!p)
445
+ throw new ToolError(`No project ${project} on ${server.name}`);
446
+ if (typeof p.sessionId === 'string')
447
+ return { sessionId: p.sessionId };
448
+ const running = await client.sessionsList(token, p.hostedRef);
449
+ if (running[0])
450
+ return { sessionId: running[0].sessionId };
451
+ return { project: p };
452
+ });
453
+ if (found.sessionId)
454
+ return this.joinSession(server.name, found.sessionId);
455
+ return this.openFromFiles(server, found.project);
456
+ }
457
+ /** No session is running: load the files, build the state as dzStudio does, and start a session with it. */
458
+ async openFromFiles(server, p) {
459
+ const notSaver = `No session is running for ${p.projectName}, and only someone who may save can start one. Ask a person to open it in dzStudio.`;
460
+ if (!p.canSave)
461
+ throw new ToolError(notSaver);
462
+ const { head, files } = await this.authed(server, (client, token) => client.projectLoad(token, p.hostedRef, neededForLoad)).catch((e) => {
463
+ if (e instanceof CollabError)
464
+ throw new ToolError(`Cannot open ${p.projectName}: ${e.message}`);
465
+ throw e;
466
+ });
467
+ if (typeof head.sessionId === 'string')
468
+ return this.joinSession(server.name, head.sessionId);
469
+ if (!head.canSave)
470
+ throw new ToolError(notSaver);
471
+ const lookup = this.classes ? await this.classes().catch(() => null) : null;
472
+ const rec = {
473
+ handle: `s${this.nextHandle++}`,
474
+ server: server.name,
475
+ sessionId: '',
476
+ projectName: String(head.projectName ?? p.projectName ?? ''),
477
+ map: String(head.map ?? p.map ?? ''),
478
+ collab: undefined,
479
+ idleLeft: false,
480
+ lastWorkMs: Date.now(),
481
+ };
482
+ const by = this.author(rec);
483
+ let loaded;
484
+ const build = (scene) => {
485
+ loaded = loadProject({ files, ids: scene.ids, ...by, ...(lookup ? { classKnown: (n) => lookup.knows(n) } : {}) });
486
+ const res = scene.apply(loaded.op);
487
+ if (res.result !== 'applied')
488
+ throw new LoadRefused(`The project's state does not open (${res.result})`);
489
+ };
490
+ const cred = this.credential(server);
491
+ try {
492
+ rec.collab = await CollabSession.createHosted(this.sessionOptions(server, rec, cred), { projectName: rec.projectName, map: rec.map, hostedRef: p.hostedRef, hostedRevision: String(head.hostedRevision ?? '') }, build);
493
+ }
494
+ catch (e) {
495
+ if (e instanceof LoadRefused)
496
+ throw new ToolError(`Cannot open ${rec.projectName} from its files: ${e.message}`);
497
+ if (e instanceof AlreadyRunning)
498
+ return this.joinSession(server.name, e.sessionId);
499
+ this.joinError(server, e);
500
+ }
501
+ rec.sessionId = rec.collab.sessionId;
502
+ this.sessions.set(rec.handle, rec);
503
+ this.current = rec.handle;
504
+ this.attach(rec);
505
+ const notes = loaded.notes;
506
+ return {
507
+ text: `Opened ${rec.projectName} from its files (${plural(loaded.groups, 'group')}, ${plural(loaded.objects, 'object')}) ` +
508
+ `and started a session as ${rec.handle}; it is the current session` +
509
+ (notes.length ? `. On opening: ${notes.join('; ')}` : ''),
510
+ data: this.sessionRow(rec),
511
+ };
512
+ }
513
+ async joinSession(serverName, sessionId, code) {
514
+ const server = this.resolveServer(serverName);
515
+ const info = await this.info(server, true);
516
+ if (!info.capabilities.includes('agents'))
517
+ throw new ToolError('Server knows no agents');
518
+ const cred = this.credential(server);
519
+ let target = sessionId;
520
+ if (code) {
521
+ const res = await this.authed(server, (client, token) => client.inviteRedeem(token, code)).catch((e) => {
522
+ if (e instanceof CollabError)
523
+ throw new ToolError(e.code === 'invite_invalid' || e.code === 'invite_used'
524
+ ? 'This code is not valid (any more) — ask for a new one'
525
+ : `Cannot join: ${e.message}`);
526
+ throw e;
527
+ });
528
+ target = res.sessionId;
529
+ }
530
+ if (!target)
531
+ throw new ToolError('Name a session id or an invitation code');
532
+ for (const r of this.sessions.values()) {
533
+ if (r.server === server.name && r.sessionId === target && r.collab?.active) {
534
+ this.current = r.handle;
535
+ return { text: `Already in this session as ${r.handle}`, data: this.sessionRow(r) };
536
+ }
537
+ }
538
+ const listed = await this.authed(server, (client, token) => client.sessionsList(token)).catch(() => []);
539
+ const meta = listed.find((s) => s.sessionId === target);
540
+ const handle = `s${this.nextHandle++}`;
541
+ const rec = {
542
+ handle,
543
+ server: server.name,
544
+ sessionId: target,
545
+ projectName: meta?.projectName ?? '',
546
+ map: meta?.map ?? '',
547
+ collab: undefined,
548
+ idleLeft: false,
549
+ lastWorkMs: Date.now(),
550
+ };
551
+ rec.collab = await this.connect(server, rec, cred);
552
+ this.sessions.set(handle, rec);
553
+ this.current = handle;
554
+ this.attach(rec);
555
+ return { text: `Joined ${rec.projectName || target} as ${handle}; it is the current session`, data: this.sessionRow(rec) };
556
+ }
557
+ sessionOptions(server, rec, cred) {
558
+ return {
559
+ client: this.client(server),
560
+ token: () => this.store.credential(server.name)?.token ?? cred.token,
561
+ clientInfo: agentClient(this.store.load().agentName),
562
+ position: () => this.position(rec),
563
+ ...(this.opts.timing ? { timing: this.opts.timing } : {}),
564
+ };
565
+ }
566
+ /** Turns an error of joining or creating into the message for the agent. */
567
+ joinError(server, e) {
568
+ if (e instanceof CollabError) {
569
+ if (['token_invalid', 'token_expired', 'auth_required'].includes(e.code)) {
570
+ this.store.setCredential(server.name, undefined);
571
+ throw new ToolError(`Not signed in to ${server.name} — call login`);
572
+ }
573
+ if (e.code === 'session_unknown' || e.code === 'session_ended')
574
+ throw new ToolError('This session is already over');
575
+ if (e.code === 'session_full')
576
+ throw new ToolError('Session is full');
577
+ throw new ToolError(`Cannot join: ${e.error.message ?? e.code}`);
578
+ }
579
+ if (e instanceof UnreachableError)
580
+ throw new ToolError('Server not reachable');
581
+ throw e;
582
+ }
583
+ async connect(server, rec, cred) {
584
+ try {
585
+ return await CollabSession.join(this.sessionOptions(server, rec, cred), rec.sessionId);
586
+ }
587
+ catch (e) {
588
+ this.joinError(server, e);
589
+ }
590
+ }
591
+ position(rec) {
592
+ const c = rec.collab;
593
+ if (!c)
594
+ return undefined;
595
+ const ids = c.held.size ? [...c.held] : c.lastTouched;
596
+ return c.view((scene) => {
597
+ let min;
598
+ let max;
599
+ for (const id of ids) {
600
+ const p = vec(scene.get(id)?.fields.get('pos'));
601
+ if (!Array.isArray(p))
602
+ continue;
603
+ min = min ? [Math.min(min[0], p[0]), Math.min(min[1], p[1]), Math.min(min[2], p[2])] : [...p];
604
+ max = max ? [Math.max(max[0], p[0]), Math.max(max[1], p[1]), Math.max(max[2], p[2])] : [...p];
605
+ }
606
+ if (!min || !max)
607
+ return undefined;
608
+ return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2].map((v) => Math.fround(v));
609
+ });
610
+ }
611
+ record(handle) {
612
+ const h = handle ?? this.current;
613
+ if (!h)
614
+ throw new ToolError('Not in a session');
615
+ const r = this.sessions.get(h) ?? [...this.sessions.values()].find((x) => x.sessionId === h);
616
+ if (!r)
617
+ throw new ToolError(`Not in a session ${h}`);
618
+ return r;
619
+ }
620
+ /** The session for a tool that works in it; rejoins one the agent left while idle. */
621
+ async live(handle, work = true) {
622
+ const r = this.record(handle);
623
+ if (r.saved) {
624
+ await this.restore(r).catch((e) => {
625
+ throw e instanceof ToolError ? e : new ToolError(`Not back in the session after a restart: ${e.message}`);
626
+ });
627
+ }
628
+ if (r.idleLeft) {
629
+ const server = this.resolveServer(r.server);
630
+ r.collab = await this.connect(server, r, this.credential(server));
631
+ r.idleLeft = false;
632
+ this.attach(r);
633
+ this.agentEvents.push(`[${r.handle}] Joined again after leaving while idle`);
634
+ }
635
+ const c = r.collab;
636
+ if (!c || !c.active) {
637
+ const text = c?.stateText || 'Not in a session';
638
+ this.drop(r);
639
+ throw new ToolError(text);
640
+ }
641
+ if (work) {
642
+ r.lastWorkMs = Date.now();
643
+ this.persist(r);
644
+ }
645
+ return { r, c };
646
+ }
647
+ async leaveIdle() {
648
+ const limit = this.opts.idleLeaveMs ?? IDLE_LEAVE_MS;
649
+ for (const r of this.sessions.values()) {
650
+ const c = r.collab;
651
+ if (!c || r.idleLeft || !c.active || c.phase !== 'waiting')
652
+ continue;
653
+ if (Date.now() - r.lastWorkMs < limit)
654
+ continue;
655
+ await c.leave();
656
+ r.idleLeft = true;
657
+ clearTimeout(r.stateTimer);
658
+ this.saved.remove(r.server, r.sessionId);
659
+ this.agentEvents.push(`[${r.handle}] Left after ${Math.round(limit / 60_000)} minutes without work; joins again on the next task`);
660
+ }
661
+ }
662
+ /** Exposed for tests: run the idle check now. */
663
+ checkIdle() {
664
+ return this.leaveIdle();
665
+ }
666
+ sessionRow(r) {
667
+ const c = r.collab;
668
+ return {
669
+ session: r.handle,
670
+ sessionId: r.sessionId,
671
+ server: r.server,
672
+ project: r.projectName,
673
+ map: r.map,
674
+ kind: c?.kind,
675
+ phase: c ? PHASE_TEXT[c.phase] : undefined,
676
+ status: c?.status,
677
+ current: this.current === r.handle,
678
+ state: r.idleLeft ? 'left while idle' : r.saved ? 'not back yet after a restart' : c?.state,
679
+ };
680
+ }
681
+ listSessions() {
682
+ const rows = [...this.sessions.values()].map((r) => this.sessionRow(r));
683
+ return { text: rows.length ? plural(rows.length, 'session') : 'Not in any session', data: { sessions: rows } };
684
+ }
685
+ switchSession(handle) {
686
+ const r = this.record(handle);
687
+ this.current = r.handle;
688
+ return { text: `Current session: ${r.handle}`, data: this.sessionRow(r) };
689
+ }
690
+ async leaveSession(handle, confirm) {
691
+ const r = this.record(handle);
692
+ const c = r.collab;
693
+ if (c && c.active) {
694
+ const open = await c.flush(10_000);
695
+ if (open && !confirm)
696
+ throw new ToolError(`${plural(open, 'change')} did not reach the server — call leave_session with confirm to leave anyway`);
697
+ await c.leave();
698
+ }
699
+ this.drop(r);
700
+ return { text: `Left ${r.handle}` };
701
+ }
702
+ async sessionInfo(handle) {
703
+ const { r, c } = await this.live(handle, false);
704
+ const pres = new Map(c.presence.map((p) => [p.participantId, p]));
705
+ const held = new Map(c.lockHolders.map((h) => [h.participantId, (h.elements ?? []).length]));
706
+ const participants = c.participants.map((p) => {
707
+ const pr = pres.get(p.participantId) ?? {};
708
+ const row = {
709
+ name: displayName(p),
710
+ kind: p.kind === 'agent' ? 'agent' : 'human',
711
+ you: p.participantId === c.participantId,
712
+ colour: p.colour,
713
+ role: p.role,
714
+ state: pr.state,
715
+ held: held.get(p.participantId) ?? 0,
716
+ };
717
+ if (pr.position)
718
+ row.location = pr.position;
719
+ if (p.kind === 'agent') {
720
+ row.phase = PHASE_TEXT[pr.phase ?? 'waiting'] ?? pr.phase;
721
+ if (pr.status)
722
+ row.status = pr.status;
723
+ }
724
+ return row;
725
+ });
726
+ const s = c.saving;
727
+ const saving = {};
728
+ if (s) {
729
+ saving.state = s.state;
730
+ if (s.unsavedCount !== undefined)
731
+ saving.unsavedChanges = s.unsavedCount;
732
+ if (s.savedBy)
733
+ saving.lastSavedBy = s.savedBy.name;
734
+ if (s.savedSecondsAgo !== undefined)
735
+ saving.savedSecondsAgo = s.savedSecondsAgo;
736
+ }
737
+ return {
738
+ text: `${r.projectName || r.sessionId}: ${plural(participants.length, 'participant')}, you are ${PHASE_TEXT[c.phase]}`,
739
+ data: {
740
+ ...this.sessionRow(r),
741
+ participants,
742
+ rights: { canSave: c.rights.canSave, canManage: c.rights.canManage, canInvite: c.rights.canInvite },
743
+ selection: c.selection.size,
744
+ saving,
745
+ },
746
+ };
747
+ }
748
+ // ------------------------------------------------------------ reading the scene
749
+ async readScene(handle) {
750
+ const { r, c } = await this.live(handle, false);
751
+ return c.view((scene) => {
752
+ const project = scene.get(PROJECT_ID);
753
+ const groups = scene
754
+ .children(PROJECT_ID)
755
+ .map((id) => scene.get(id))
756
+ .filter((g) => g.kind === 'core:group')
757
+ .map((g) => {
758
+ let min;
759
+ let max;
760
+ const kids = scene.children(g.id);
761
+ for (const k of kids) {
762
+ const p = vec(scene.get(k)?.fields.get('pos'));
763
+ if (!Array.isArray(p))
764
+ continue;
765
+ min = min ? [Math.min(min[0], p[0]), Math.min(min[1], p[2])] : [p[0], p[2]];
766
+ max = max ? [Math.max(max[0], p[0]), Math.max(max[1], p[2])] : [p[0], p[2]];
767
+ }
768
+ const row = {
769
+ id: g.id,
770
+ name: g.fields.get('name') ?? '',
771
+ color: g.fields.get('color'),
772
+ objects: kids.length,
773
+ hidden: g.hidden,
774
+ locked: g.locked,
775
+ };
776
+ if (g.fields.get('unreadable') === '1')
777
+ row.unreadable = true;
778
+ if (min && max)
779
+ row.bounds = { minX: f32(min[0]), minZ: f32(min[1]), maxX: f32(max[0]), maxZ: f32(max[1]) };
780
+ return row;
781
+ });
782
+ const objects = groups.reduce((n, g) => n + g.objects, 0);
783
+ return {
784
+ text: `${r.projectName || 'Project'} on ${r.map || 'unknown map'}: ${plural(groups.length, 'group')}, ${plural(objects, 'object')}`,
785
+ data: { project: r.projectName, map: r.map, loot: project?.fields.get('lootEnabled') === 'true', groups, objects },
786
+ };
787
+ });
788
+ }
789
+ holderNames(c) {
790
+ const byPid = new Map(c.participants.map((p) => [p.participantId, displayName(p)]));
791
+ const out = new Map();
792
+ for (const h of c.lockHolders) {
793
+ if (h.participantId === c.participantId)
794
+ continue;
795
+ for (const el of h.elements ?? [])
796
+ out.set(el, byPid.get(h.participantId) ?? h.participantId);
797
+ }
798
+ return out;
799
+ }
800
+ objectRow(scene, el, holders, mine) {
801
+ const row = {
802
+ id: el.id,
803
+ class: el.fields.get('name') ?? '',
804
+ group: el.parent,
805
+ position: vec(el.fields.get('pos')),
806
+ orientation: vec(el.fields.get('ypr')),
807
+ scale: num(el.fields.get('scale')),
808
+ hidden: el.hidden,
809
+ locked: el.locked,
810
+ };
811
+ if (el.fields.get('persist') === '1')
812
+ row.persist = true;
813
+ const cs = el.fields.get('customString');
814
+ if (cs)
815
+ row.customString = cs;
816
+ if (mine.has(el.id))
817
+ row.heldBy = 'you';
818
+ else if (holders.has(el.id))
819
+ row.heldBy = holders.get(el.id);
820
+ return row;
821
+ }
822
+ async queryObjects(handle, q) {
823
+ const { c } = await this.live(handle, false);
824
+ const holders = this.holderNames(c);
825
+ return c.view((scene) => {
826
+ const out = [];
827
+ const wantClass = q.class?.toLowerCase();
828
+ const candidates = q.ids?.length
829
+ ? q.ids.map((id) => scene.get(id)).filter((e) => !!e && e.kind === 'core:object')
830
+ : [...scene.walk()].filter((e) => e.kind === 'core:object');
831
+ const group = q.group ? this.findGroup(scene, q.group) : undefined;
832
+ for (const el of candidates) {
833
+ if (group && el.parent !== group)
834
+ continue;
835
+ if (wantClass && (el.fields.get('name') ?? '').toLowerCase() !== wantClass)
836
+ continue;
837
+ if (q.near || q.rect) {
838
+ const p = vec(el.fields.get('pos'));
839
+ if (!Array.isArray(p))
840
+ continue;
841
+ if (q.near && Math.hypot(p[0] - q.near.x, p[2] - q.near.z) > q.near.radius)
842
+ continue;
843
+ if (q.rect && (p[0] < q.rect.minX || p[0] > q.rect.maxX || p[2] < q.rect.minZ || p[2] > q.rect.maxZ))
844
+ continue;
845
+ }
846
+ out.push(this.objectRow(scene, el, holders, c.selection));
847
+ }
848
+ const page = Math.max(0, Math.floor(q.page ?? 0));
849
+ const slice = out.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
850
+ const data = { total: out.length, page, objects: slice };
851
+ if ((page + 1) * PAGE_SIZE < out.length)
852
+ data.nextPage = page + 1;
853
+ const missing = q.ids?.filter((id) => !scene.get(id));
854
+ if (missing?.length)
855
+ data.notFound = missing;
856
+ return {
857
+ text: `${plural(out.length, 'object')}${data.nextPage !== undefined ? `, page ${page + 1} of ${Math.ceil(out.length / PAGE_SIZE)}` : ''}`,
858
+ data,
859
+ };
860
+ });
861
+ }
862
+ /** A group by id or by its (unique) name. */
863
+ findGroup(scene, ref) {
864
+ const el = scene.get(ref);
865
+ if (el?.kind === 'core:group')
866
+ return ref;
867
+ const byName = scene
868
+ .children(PROJECT_ID)
869
+ .map((id) => scene.get(id))
870
+ .filter((g) => g.kind === 'core:group' && g.fields.get('name') === ref);
871
+ if (byName.length === 1)
872
+ return byName[0].id;
873
+ if (byName.length > 1)
874
+ throw new ToolError(`Several groups are called ${ref} — use the group id`);
875
+ throw new ToolError(`No group ${ref}`);
876
+ }
877
+ /** Map key and objects of a session for the world tools; undefined when no session is named and none is current. */
878
+ async sessionWorld(handle) {
879
+ if (handle === undefined && !this.current)
880
+ return undefined;
881
+ const { r, c } = await this.live(handle, false);
882
+ const objects = c.view((scene) => {
883
+ const out = [];
884
+ for (const el of scene.walk()) {
885
+ if (el.kind !== 'core:object')
886
+ continue;
887
+ const pos = vec(el.fields.get('pos'));
888
+ if (!Array.isArray(pos))
889
+ continue;
890
+ const ypr = vec(el.fields.get('ypr'));
891
+ const scale = num(el.fields.get('scale'));
892
+ out.push({
893
+ id: el.id,
894
+ class: el.fields.get('name') ?? '',
895
+ position: pos,
896
+ orientation: Array.isArray(ypr) ? ypr : [0, 0, 0],
897
+ scale: typeof scale === 'number' && scale > 0 ? scale : 1,
898
+ });
899
+ }
900
+ return out;
901
+ });
902
+ return { key: `${r.server}/${r.sessionId}`, handle: r.handle, map: r.map, objects };
903
+ }
904
+ // ------------------------------------------------------------ changing the scene
905
+ author(r) {
906
+ const server = this.resolveServer(r.server);
907
+ return { author: this.store.load().installationId, authorName: this.credential(server).name };
908
+ }
909
+ checkConnection(c) {
910
+ if (c.interruptedMs >= 3000)
911
+ throw new ToolError('Connection interrupted — try again when it is back');
912
+ }
913
+ /** Locks the existing objects a step changes and the new ones it creates; changes nothing if one is denied. */
914
+ async lockFor(c, existing, created) {
915
+ const before = [...c.selection];
916
+ const want = new Set([...before, ...existing, ...created]);
917
+ if (want.size > MAX_HELD)
918
+ throw new ToolError('At most 20,000 objects can be edited at once');
919
+ const needed = new Set(existing);
920
+ if (existing.every((id) => c.selection.has(id) && c.held.has(id)) && created.length === 0)
921
+ return;
922
+ const res = await c.setLocks([...want]);
923
+ const denied = res.denied.filter((d) => needed.has(d.element));
924
+ if (!denied.length)
925
+ return;
926
+ await c.setLocks(before.filter((id) => !denied.some((d) => d.element === id)));
927
+ const byPid = new Map(c.participants.map((p) => [p.participantId, displayName(p)]));
928
+ const counts = new Map();
929
+ for (const d of denied) {
930
+ const who = d.holder ? (byPid.get(d.holder) ?? d.holder) : 'nobody (the session holds the most locks it can)';
931
+ counts.set(who, (counts.get(who) ?? 0) + 1);
932
+ }
933
+ throw new ToolError([...counts].map(([who, n]) => `${plural(n, 'object')} ${n === 1 ? 'is' : 'are'} being edited by ${who}`).join('; '));
934
+ }
935
+ /** Builds one operation on the scene with the own unconfirmed steps on top; the scene is unchanged afterwards. */
936
+ build(c, b, fn) {
937
+ try {
938
+ return c.view((scene) => {
939
+ try {
940
+ fn(scene);
941
+ return b.finish();
942
+ }
943
+ catch (e) {
944
+ b.abort();
945
+ throw e;
946
+ }
947
+ });
948
+ }
949
+ catch (e) {
950
+ if (e instanceof BuildError)
951
+ throw new ToolError(e.message);
952
+ throw e;
953
+ }
954
+ }
955
+ stepText(res, opId) {
956
+ switch (res.status) {
957
+ case 'applied':
958
+ return `Done in one step (${opId})`;
959
+ case 'unconfirmed':
960
+ return `Sent (${opId}); not confirmed yet — the outcome arrives in session_events`;
961
+ case 'dropped':
962
+ case 'rejected':
963
+ throw new ToolError(res.message);
964
+ }
965
+ }
966
+ async createObjects(handle, groups, objects) {
967
+ const { r, c } = await this.live(handle);
968
+ this.checkConnection(c);
969
+ if (!groups.length && !objects.length)
970
+ throw new ToolError('Nothing to create');
971
+ const type = groups.length ? 'projects:edit' : 'placement:edit';
972
+ const b = new OpBuilder(c.scene, type, this.author(r));
973
+ const createdObjects = [];
974
+ const refs = new Map();
975
+ const op = this.build(c, b, (scene) => {
976
+ const fileNames = new Set(scene
977
+ .children(PROJECT_ID)
978
+ .map((id) => scene.get(id).fields.get('file'))
979
+ .filter((f) => !!f));
980
+ groups.forEach((g, i) => {
981
+ if (refs.has(g.ref))
982
+ throw new ToolError(`Group ref ${g.ref} is used twice`);
983
+ const color = (g.color ?? GROUP_COLOURS[(scene.childCount(PROJECT_ID) + i) % GROUP_COLOURS.length]).toUpperCase();
984
+ if (!/^#[0-9A-F]{6}$/.test(color))
985
+ throw new ToolError(`Colour must be #RRGGBB: ${g.color}`);
986
+ const base = g.name
987
+ .toLowerCase()
988
+ .replace(/[^a-z0-9]+/g, '-')
989
+ .replace(/^-|-$/g, '') || 'group';
990
+ let file = `${base}.json`;
991
+ for (let n = 2; fileNames.has(file); n++)
992
+ file = `${base}-${n}.json`;
993
+ fileNames.add(file);
994
+ refs.set(g.ref, b.createGroup({ name: g.name, color, file }));
995
+ });
996
+ for (const o of objects) {
997
+ const group = refs.get(o.group) ?? this.findGroup(scene, o.group);
998
+ const spec = { group, name: o.class, pos: o.position };
999
+ if (o.orientation)
1000
+ spec.ypr = o.orientation;
1001
+ if (o.scale !== undefined)
1002
+ spec.scale = o.scale;
1003
+ createdObjects.push(b.createObject(spec));
1004
+ }
1005
+ });
1006
+ await this.lockFor(c, [], createdObjects);
1007
+ const res = await c.submit(op);
1008
+ c.lastTouched = createdObjects;
1009
+ const text = this.stepText(res, op.id);
1010
+ return {
1011
+ text: `${text}: ${plural(groups.length, 'group')}, ${plural(createdObjects.length, 'object')}`,
1012
+ data: { step: op.id, groups: Object.fromEntries(refs), objects: createdObjects },
1013
+ };
1014
+ }
1015
+ async modifyObjects(handle, changes) {
1016
+ const { r, c } = await this.live(handle);
1017
+ this.checkConnection(c);
1018
+ if (!changes.length)
1019
+ throw new ToolError('Nothing to change');
1020
+ const structural = changes.some((ch) => ch.group !== undefined || ch.hidden !== undefined || ch.locked !== undefined || ch.name !== undefined || ch.color !== undefined);
1021
+ const b = new OpBuilder(c.scene, structural ? 'projects:edit' : 'properties:edit', this.author(r));
1022
+ const objects = [];
1023
+ const op = this.build(c, b, (scene) => {
1024
+ for (const ch of changes) {
1025
+ const el = scene.get(ch.id);
1026
+ if (!el)
1027
+ throw new ToolError(`No object or group ${ch.id}`);
1028
+ if (el.kind === 'core:group') {
1029
+ if (ch.position ||
1030
+ ch.orientation ||
1031
+ ch.scale !== undefined ||
1032
+ ch.group ||
1033
+ ch.persist !== undefined ||
1034
+ ch.customString !== undefined) {
1035
+ throw new ToolError(`${ch.id} is a group: only name, color, hidden and locked can change`);
1036
+ }
1037
+ if (ch.name !== undefined)
1038
+ b.setField(ch.id, 'name', ch.name);
1039
+ if (ch.color !== undefined) {
1040
+ const color = ch.color.toUpperCase();
1041
+ if (!/^#[0-9A-F]{6}$/.test(color))
1042
+ throw new ToolError(`Colour must be #RRGGBB: ${ch.color}`);
1043
+ b.setField(ch.id, 'color', color);
1044
+ }
1045
+ }
1046
+ else if (el.kind === 'core:object') {
1047
+ if (ch.name !== undefined || ch.color !== undefined)
1048
+ throw new ToolError(`${ch.id} is an object: name and color belong to groups`);
1049
+ objects.push(ch.id);
1050
+ if (ch.position)
1051
+ b.setField(ch.id, 'pos', writeVec3(ch.position));
1052
+ if (ch.orientation)
1053
+ b.setField(ch.id, 'ypr', writeVec3(ch.orientation.map(normalizeAngle)));
1054
+ if (ch.scale !== undefined)
1055
+ b.setField(ch.id, 'scale', writeFloat32(ch.scale));
1056
+ if (ch.persist !== undefined)
1057
+ b.setField(ch.id, 'persist', ch.persist ? '1' : '0');
1058
+ if (ch.customString !== undefined)
1059
+ b.setField(ch.id, 'customString', ch.customString);
1060
+ if (ch.group !== undefined)
1061
+ b.move(ch.id, this.findGroup(scene, ch.group));
1062
+ }
1063
+ else {
1064
+ throw new ToolError(`${ch.id} cannot be changed by an agent`);
1065
+ }
1066
+ if (ch.hidden !== undefined)
1067
+ b.setFlag(ch.id, 'hidden', ch.hidden);
1068
+ if (ch.locked !== undefined)
1069
+ b.setFlag(ch.id, 'locked', ch.locked);
1070
+ }
1071
+ });
1072
+ if (!op.changes.length)
1073
+ return { text: 'Nothing changed: every value is already as asked' };
1074
+ await this.lockFor(c, objects, []);
1075
+ const res = await c.submit(op);
1076
+ c.lastTouched = objects;
1077
+ return { text: `${this.stepText(res, op.id)}: ${plural(changes.length, 'element')} changed`, data: { step: op.id } };
1078
+ }
1079
+ async deleteObjects(handle, ids) {
1080
+ const { r, c } = await this.live(handle);
1081
+ this.checkConnection(c);
1082
+ if (!ids.length)
1083
+ throw new ToolError('Nothing to delete');
1084
+ const objects = [];
1085
+ let groups = 0;
1086
+ const b = new OpBuilder(c.scene, 'placement:edit', this.author(r));
1087
+ const op = this.build(c, b, (scene) => {
1088
+ const unique = [...new Set(ids)];
1089
+ for (const id of unique) {
1090
+ const el = scene.get(id);
1091
+ if (!el)
1092
+ throw new ToolError(`No object or group ${id}`);
1093
+ if (el.kind === 'core:group') {
1094
+ groups++;
1095
+ objects.push(...scene.children(id));
1096
+ }
1097
+ else if (el.kind === 'core:object')
1098
+ objects.push(id);
1099
+ else
1100
+ throw new ToolError(`${id} cannot be deleted by an agent`);
1101
+ }
1102
+ const inDeletedGroup = new Set(unique.filter((id) => scene.get(id).kind === 'core:group'));
1103
+ for (const id of unique) {
1104
+ const el = scene.get(id);
1105
+ if (!el || inDeletedGroup.has(el.parent))
1106
+ continue;
1107
+ b.deleteTree(id);
1108
+ }
1109
+ });
1110
+ if (groups)
1111
+ op.type = 'projects:edit';
1112
+ await this.lockFor(c, objects, []);
1113
+ const res = await c.submit(op);
1114
+ const text = this.stepText(res, op.id);
1115
+ if (res.status === 'applied')
1116
+ await c.setLocks([...c.selection].filter((id) => !objects.includes(id)));
1117
+ return { text: `${text}: ${plural(groups, 'group')}, ${plural(objects.length, 'object')} deleted`, data: { step: op.id } };
1118
+ }
1119
+ async release(handle, ids) {
1120
+ const { c } = await this.live(handle);
1121
+ const keep = ids?.length ? [...c.selection].filter((id) => !ids.includes(id)) : [];
1122
+ const before = c.selection.size;
1123
+ await c.setLocks(keep);
1124
+ return { text: `Released ${plural(before - c.selection.size, 'object')}; ${c.selection.size} still held` };
1125
+ }
1126
+ async setStatus(handle, phase, status) {
1127
+ if (status !== undefined)
1128
+ validateStatus(status);
1129
+ const { c } = await this.live(handle);
1130
+ c.setPhase(phase, status ?? '');
1131
+ if (phase === 'waiting' && c.selection.size)
1132
+ await c.setLocks([]);
1133
+ return { text: `Phase: ${PHASE_TEXT[phase]}${c.status ? ` — ${c.status}` : ''}` };
1134
+ }
1135
+ async requestReview(handle, status) {
1136
+ if (status !== undefined)
1137
+ validateStatus(status);
1138
+ const { c } = await this.live(handle);
1139
+ const open = await c.flush(10_000);
1140
+ c.setPhase('review', status ?? '');
1141
+ if (c.selection.size)
1142
+ await c.setLocks([]);
1143
+ return {
1144
+ text: `Waiting for review${c.status ? ` — ${c.status}` : ''}${open ? `; ${plural(open, 'change')} not confirmed yet` : ''}. A person saves or discards.`,
1145
+ };
1146
+ }
1147
+ }
1148
+ //# sourceMappingURL=agent.js.map