@goodfish/jarvy 0.5.0 → 0.6.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.
@@ -3,7 +3,7 @@
3
3
  // src/server/cli.ts
4
4
  import { existsSync as existsSync2 } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { dirname as dirname6, join as join9 } from "node:path";
6
+ import { dirname as dirname8, join as join11 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  // src/server/api/server.ts
@@ -12,7 +12,7 @@ import { getRequestListener } from "@hono/node-server";
12
12
 
13
13
  // src/server/api/app.ts
14
14
  import { Hono as Hono10 } from "hono";
15
- import { HTTPException as HTTPException9 } from "hono/http-exception";
15
+ import { HTTPException as HTTPException10 } from "hono/http-exception";
16
16
  import { serveStatic } from "@hono/node-server/serve-static";
17
17
 
18
18
  // src/server/api/middleware/security.ts
@@ -184,6 +184,7 @@ function installAuthentication(api, account) {
184
184
 
185
185
  // src/server/api/routes/bots.ts
186
186
  import { Hono as Hono4 } from "hono";
187
+ import { HTTPException as HTTPException7 } from "hono/http-exception";
187
188
 
188
189
  // src/server/api/views/bot-status.ts
189
190
  function botStatusView(status) {
@@ -379,7 +380,18 @@ function botRoutes(bots) {
379
380
  routes.get("/", (c) => c.json({ bots: bots.list() }));
380
381
  routes.post("/", jsonBody, async (c) => {
381
382
  const input = await readJson(c);
382
- return c.json(bots.create(textField(input, "name")), 201);
383
+ return c.json(bots.create(textField(input, "name"), input.environment === void 0 ? void 0 : environmentKind(input.environment)), 201);
384
+ });
385
+ routes.put("/:id/environment", jsonBody, async (c) => {
386
+ const input = await readJson(c), id = c.req.param("id");
387
+ if (input.hostGranted === void 0) return c.json(bots.setHome(id, environmentKind(input.home)));
388
+ if (typeof input.hostGranted !== "boolean") throw new HTTPException7(400, { message: "hostGranted must be true or false" });
389
+ return c.json(bots.grantHost(id, input.hostGranted));
390
+ });
391
+ routes.post("/:id/computer/:action", async (c) => {
392
+ const action = c.req.param("action");
393
+ if (action !== "start" && action !== "stop" && action !== "reset") throw new HTTPException7(404, { message: "Not found" });
394
+ return c.json(await bots.computer(c.req.param("id"), action));
383
395
  });
384
396
  routes.get("/:id", (c) => c.json(bots.describe(c.req.param("id"))));
385
397
  routes.delete("/:id", async (c) => {
@@ -428,10 +440,14 @@ function botRoutes(bots) {
428
440
  routes.route("/", activityRoutes(bots));
429
441
  return routes;
430
442
  }
443
+ function environmentKind(value2) {
444
+ if (value2 !== "computer" && value2 !== "host") throw new HTTPException7(400, { message: "environment must be computer or host" });
445
+ return value2;
446
+ }
431
447
 
432
448
  // src/server/api/routes/chats.ts
433
449
  import { Hono as Hono5 } from "hono";
434
- import { HTTPException as HTTPException7 } from "hono/http-exception";
450
+ import { HTTPException as HTTPException8 } from "hono/http-exception";
435
451
 
436
452
  // src/server/api/views/download.ts
437
453
  var MEDIA_TYPES = /* @__PURE__ */ new Set(["video/webm", "video/mp4", "video/quicktime", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/ogg", "audio/webm", "audio/mp4"]);
@@ -484,16 +500,16 @@ function chatRoutes({ chats, bots }) {
484
500
  try {
485
501
  name2 = decodeURIComponent(c.req.header("x-file-name") ?? "");
486
502
  } catch {
487
- throw new HTTPException7(400, { message: "Invalid filename" });
503
+ throw new HTTPException8(400, { message: "Invalid filename" });
488
504
  }
489
505
  const data = Buffer.from(await c.req.arrayBuffer());
490
- if (!data.length) throw new HTTPException7(400, { message: "Empty attachment" });
506
+ if (!data.length) throw new HTTPException8(400, { message: "Empty attachment" });
491
507
  const mime = (c.req.header("content-type") ?? "application/octet-stream").slice(0, 100);
492
508
  return c.json(chats.upload(c.get("actorId"), c.req.param("id"), name2, mime, data), 201);
493
509
  });
494
510
  routes.delete("/:id", async (c) => {
495
511
  const id = c.req.param("id");
496
- if (!await chats.delete(c.get("actorId"), id)) throw new HTTPException7(404, { message: "Chat not found" });
512
+ if (!await chats.delete(c.get("actorId"), id)) throw new HTTPException8(404, { message: "Chat not found" });
497
513
  await bots.chatDeleted(id);
498
514
  return c.json({ ok: true });
499
515
  });
@@ -503,7 +519,7 @@ function attachmentRoutes(chats) {
503
519
  const routes = new Hono5();
504
520
  routes.get("/:id", (c) => {
505
521
  const attachment = chats.download(c.get("actorId"), c.req.param("id"));
506
- if (!attachment) throw new HTTPException7(404, { message: "Attachment not found" });
522
+ if (!attachment) throw new HTTPException8(404, { message: "Attachment not found" });
507
523
  const size = attachment.data.length, range = byteRange(c.req.header("Range"), size);
508
524
  const headers2 = {
509
525
  "Content-Type": downloadType(attachment.file.mimeType),
@@ -520,14 +536,14 @@ function attachmentRoutes(chats) {
520
536
  });
521
537
  routes.get("/:id/run", (c) => {
522
538
  const attachment = chats.download(c.get("actorId"), c.req.param("id"));
523
- if (!attachment) throw new HTTPException7(404, { message: "Attachment not found" });
539
+ if (!attachment) throw new HTTPException8(404, { message: "Attachment not found" });
524
540
  const { name: name2, mimeType } = attachment.file;
525
- if (!/\.html?$/i.test(name2) && mimeType !== "text/html") throw new HTTPException7(415, { message: "Only HTML attachments can run" });
541
+ if (!/\.html?$/i.test(name2) && mimeType !== "text/html") throw new HTTPException8(415, { message: "Only HTML attachments can run" });
526
542
  c.set("sandboxedDocument", true);
527
543
  return c.body(new Uint8Array(attachment.data), 200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": String(attachment.data.length) });
528
544
  });
529
545
  routes.delete("/:id", (c) => {
530
- if (!chats.removePendingAttachment(c.get("actorId"), c.req.param("id"))) throw new HTTPException7(404, { message: "Pending attachment not found" });
546
+ if (!chats.removePendingAttachment(c.get("actorId"), c.req.param("id"))) throw new HTTPException8(404, { message: "Pending attachment not found" });
531
547
  return c.json({ ok: true });
532
548
  });
533
549
  return routes;
@@ -638,13 +654,13 @@ data: ${JSON.stringify(data)}
638
654
 
639
655
  // src/server/api/routes/channels.ts
640
656
  import { Hono as Hono8 } from "hono";
641
- import { HTTPException as HTTPException8 } from "hono/http-exception";
657
+ import { HTTPException as HTTPException9 } from "hono/http-exception";
642
658
  function channelRoutes({ chats }) {
643
659
  const routes = new Hono8();
644
660
  const channels = chats.channels;
645
661
  const chat = (actorId, id) => {
646
662
  const found = chats.get(actorId, id);
647
- if (!found) throw new HTTPException8(404, { message: "Chat not found" });
663
+ if (!found) throw new HTTPException9(404, { message: "Chat not found" });
648
664
  return chatView(found, (member) => chats.member(member));
649
665
  };
650
666
  routes.get("/members", (c) => c.json({ members: channels.everyone(c.get("actorId")) }));
@@ -665,7 +681,7 @@ function channelRoutes({ chats }) {
665
681
  routes.post("/sections", jsonBody, async (c) => c.json(channels.createSection(c.get("actorId"), await readJson(c)), 201));
666
682
  routes.patch("/sections/:id", jsonBody, async (c) => c.json(channels.updateSection(c.get("actorId"), c.req.param("id"), await readJson(c))));
667
683
  routes.delete("/sections/:id", (c) => {
668
- if (!channels.deleteSection(c.get("actorId"), c.req.param("id"))) throw new HTTPException8(404, { message: "Section not found" });
684
+ if (!channels.deleteSection(c.get("actorId"), c.req.param("id"))) throw new HTTPException9(404, { message: "Section not found" });
669
685
  return c.json({ ok: true });
670
686
  });
671
687
  routes.post("/channels", jsonBody, async (c) => {
@@ -681,7 +697,7 @@ function channelRoutes({ chats }) {
681
697
  routes.get("/chats/:id", (c) => c.json(chat(c.get("actorId"), c.req.param("id"))));
682
698
  routes.get("/messages/:id", (c) => {
683
699
  const found = chats.message(c.get("actorId"), c.req.param("id"));
684
- if (!found) throw new HTTPException8(404, { message: "Message not found" });
700
+ if (!found) throw new HTTPException9(404, { message: "Message not found" });
685
701
  return c.json(messageView(found));
686
702
  });
687
703
  routes.post("/messages/:id/thread", (c) => {
@@ -786,7 +802,7 @@ function createHttpApp(services, webRoot) {
786
802
  api.route("/", eventRoutes(services));
787
803
  api.route("/", liveRoutes(services));
788
804
  api.all("*", () => {
789
- throw new HTTPException9(404, { message: "API route not found" });
805
+ throw new HTTPException10(404, { message: "API route not found" });
790
806
  });
791
807
  app.route("/api", api);
792
808
  app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
@@ -796,8 +812,8 @@ function createHttpApp(services, webRoot) {
796
812
  c.header("Cache-Control", "no-store");
797
813
  } }));
798
814
  app.all("*", (c) => {
799
- if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException9(405, { message: "Method not allowed" });
800
- throw new HTTPException9(404, { message: "Web assets missing; run npm run build" });
815
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException10(405, { message: "Method not allowed" });
816
+ throw new HTTPException10(404, { message: "Web assets missing; run npm run build" });
801
817
  });
802
818
  return app;
803
819
  }
@@ -813,10 +829,10 @@ var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
813
829
  var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
814
830
 
815
831
  // src/server/infra/storage/store.ts
816
- import { createHash, randomBytes, randomInt as randomInt3, randomUUID as randomUUID7, scryptSync, timingSafeEqual } from "node:crypto";
832
+ import { createHash, randomBytes, randomInt as randomInt3, randomUUID as randomUUID8, scryptSync, timingSafeEqual } from "node:crypto";
817
833
  import { chmodSync, mkdirSync } from "node:fs";
818
834
  import { homedir } from "node:os";
819
- import { dirname, join, resolve } from "node:path";
835
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
820
836
  import { DatabaseSync } from "node:sqlite";
821
837
 
822
838
  // src/server/infra/storage/migrations/composition-schema.ts
@@ -938,13 +954,13 @@ function migrateChannels(db) {
938
954
  SELECT cm.chat_id, cm.member_id, COALESCE((SELECT MAX(sequence) FROM messages m WHERE m.chat_id=cm.chat_id), 0) FROM chat_members cm;
939
955
  `);
940
956
  const taken = new Set(db.prepare("SELECT username FROM account").all().map((row) => row.username.toLowerCase()));
941
- const rename2 = db.prepare("UPDATE bots SET name=? WHERE id=?");
957
+ const rename3 = db.prepare("UPDATE bots SET name=? WHERE id=?");
942
958
  for (const bot of db.prepare("SELECT id,name FROM bots ORDER BY created_at,id").all()) {
943
959
  const base = bot.name.replace(/@/g, "").trim() || "Bot";
944
960
  let name2 = base;
945
961
  for (let suffix = 2; taken.has(name2.toLowerCase()); suffix++) name2 = `${base} ${suffix}`;
946
962
  taken.add(name2.toLowerCase());
947
- if (name2 !== bot.name) rename2.run(name2, bot.id);
963
+ if (name2 !== bot.name) rename3.run(name2, bot.id);
948
964
  }
949
965
  db.exec("PRAGMA user_version=12; COMMIT;");
950
966
  } catch (error) {
@@ -1123,6 +1139,33 @@ function migrateAgent(db) {
1123
1139
  }
1124
1140
  }
1125
1141
 
1142
+ // src/server/infra/storage/migrations/environment-schema.ts
1143
+ function migrateEnvironments(db) {
1144
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1145
+ if (version >= 20) return;
1146
+ db.exec("BEGIN IMMEDIATE");
1147
+ try {
1148
+ db.exec(`
1149
+ CREATE TABLE environments(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id),
1150
+ kind TEXT NOT NULL CHECK(kind IN ('host','computer')), name TEXT NOT NULL DEFAULT '',
1151
+ connection TEXT NOT NULL DEFAULT '{}', directory TEXT, bot_id TEXT REFERENCES bots(id),
1152
+ created_at INTEGER NOT NULL, deleted_at INTEGER);
1153
+ CREATE UNIQUE INDEX environments_host ON environments(owner_id) WHERE kind='host' AND deleted_at IS NULL;
1154
+ CREATE UNIQUE INDEX environments_computer ON environments(bot_id) WHERE kind='computer' AND deleted_at IS NULL;
1155
+ CREATE TABLE environment_grants(environment_id TEXT NOT NULL REFERENCES environments(id),
1156
+ bot_id TEXT NOT NULL REFERENCES bots(id), granted_by TEXT NOT NULL REFERENCES members(id),
1157
+ granted_at INTEGER NOT NULL, PRIMARY KEY(environment_id, bot_id));
1158
+ ALTER TABLE bots ADD COLUMN home_environment_id TEXT REFERENCES environments(id);
1159
+ ALTER TABLE bots ADD COLUMN current_environment_id TEXT REFERENCES environments(id);
1160
+ PRAGMA user_version=20;
1161
+ `);
1162
+ db.exec("COMMIT");
1163
+ } catch (error) {
1164
+ db.exec("ROLLBACK");
1165
+ throw error;
1166
+ }
1167
+ }
1168
+
1126
1169
  // src/server/infra/storage/migrations/schema.ts
1127
1170
  import { randomUUID as randomUUID2 } from "node:crypto";
1128
1171
 
@@ -1245,7 +1288,7 @@ function migrateRuntime(db) {
1245
1288
  // src/server/infra/storage/migrations/schema.ts
1246
1289
  function migrateDatabase(db) {
1247
1290
  const version = db.prepare("PRAGMA user_version").get().user_version;
1248
- if (version > 19) throw new Error(`Unsupported database version: ${version}`);
1291
+ if (version > 20) throw new Error(`Unsupported database version: ${version}`);
1249
1292
  migratePrototype(db);
1250
1293
  if (version >= 5) {
1251
1294
  migrateRuntime(db);
@@ -1262,6 +1305,7 @@ function migrateDatabase(db) {
1262
1305
  migrateQuotes(db);
1263
1306
  migrateTitles(db);
1264
1307
  migrateAgent(db);
1308
+ migrateEnvironments(db);
1265
1309
  return;
1266
1310
  }
1267
1311
  db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
@@ -1336,6 +1380,7 @@ function migrateDatabase(db) {
1336
1380
  migrateQuotes(db);
1337
1381
  migrateTitles(db);
1338
1382
  migrateAgent(db);
1383
+ migrateEnvironments(db);
1339
1384
  }
1340
1385
 
1341
1386
  // src/server/infra/storage/chat-repository.ts
@@ -1915,7 +1960,7 @@ var ChatRepository = class {
1915
1960
  return rows2.map(({ historyId, toolCallId, ...row }) => ({ ...row, source: historyId === null ? null : { historyId, toolCallId } }));
1916
1961
  }
1917
1962
  messageFromRow(row) {
1918
- const attachments = this.db.prepare("SELECT id,name,mime_type AS mimeType,size FROM attachments WHERE message_id=? ORDER BY created_at,id").all(row.id);
1963
+ const attachments = this.db.prepare("SELECT id,name,mime_type AS mimeType,size FROM attachments WHERE message_id=? ORDER BY created_at,rowid").all(row.id);
1919
1964
  return {
1920
1965
  id: row.id,
1921
1966
  chatId: row.chatId,
@@ -2176,9 +2221,112 @@ var RuntimeRepository = class {
2176
2221
  };
2177
2222
 
2178
2223
  // src/server/infra/pi/catalog.ts
2179
- import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
2180
- function providerCatalog() {
2181
- return builtinProviders().flatMap((provider) => {
2224
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2225
+ import { dirname, join } from "node:path";
2226
+ import { builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
2227
+ var CATALOG_URL = "https://pi.dev/api/models";
2228
+ var REFRESH_MS = 4 * 60 * 60 * 1e3;
2229
+ var TIMEOUT_MS = 15e3;
2230
+ var modelApis = new Set(builtinProviders().flatMap((provider) => provider.getModels().map((model) => model.api)));
2231
+ var ModelCatalog = class {
2232
+ path;
2233
+ kept;
2234
+ loaded = false;
2235
+ overlaid;
2236
+ loading;
2237
+ constructor(dataDir) {
2238
+ this.path = join(dataDir, "pi", "models.json");
2239
+ }
2240
+ providers() {
2241
+ this.refresh();
2242
+ return this.overlaid ?? builtinProviders();
2243
+ }
2244
+ /** Newer entries for one provider's models (a connection's catalog comes from Pi with its credentials). */
2245
+ overlay(providerId, models) {
2246
+ return merge(models, this.entries(providerId));
2247
+ }
2248
+ /** Loads the kept catalog, then checks pi.dev when the last check is 4 hours old; never waits. */
2249
+ refresh() {
2250
+ const due = process.env.PI_OFFLINE === void 0 && Date.now() - (this.kept?.checkedAt ?? 0) >= REFRESH_MS;
2251
+ if (this.loading || this.loaded && !due) return;
2252
+ this.loading = (async () => {
2253
+ if (!this.loaded) {
2254
+ this.kept = await this.read();
2255
+ this.loaded = true;
2256
+ this.apply();
2257
+ }
2258
+ if (!due) return;
2259
+ await this.fetch();
2260
+ this.apply();
2261
+ })().catch((error) => console.warn("[models] catalog refresh failed", { error: error instanceof Error ? error.message : String(error) })).finally(() => {
2262
+ this.loading = void 0;
2263
+ });
2264
+ }
2265
+ async fetch() {
2266
+ const previous = this.kept;
2267
+ const checkedAt = Date.now();
2268
+ try {
2269
+ const response = await fetch(CATALOG_URL, {
2270
+ signal: AbortSignal.timeout(TIMEOUT_MS),
2271
+ headers: { accept: "application/json", "user-agent": "Jarvy", ...previous?.etag ? { "if-none-match": previous.etag } : {} }
2272
+ });
2273
+ if (response.status === 304 && previous) {
2274
+ this.kept = { ...previous, checkedAt };
2275
+ return;
2276
+ }
2277
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2278
+ const body = await response.json();
2279
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("not a catalog");
2280
+ const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
2281
+ this.kept = { models: body, etag: response.headers.get("etag") ?? void 0, lastModified: Number.isNaN(lastModified) ? 0 : lastModified, checkedAt };
2282
+ } catch (error) {
2283
+ this.kept = { ...previous ?? { models: {}, lastModified: 0 }, checkedAt };
2284
+ throw error;
2285
+ } finally {
2286
+ await this.write().catch((error) => console.warn("[models] catalog not saved", { error: error instanceof Error ? error.message : String(error) }));
2287
+ }
2288
+ }
2289
+ apply() {
2290
+ this.overlaid = builtinProviders().map((provider) => {
2291
+ const entries = this.entries(provider.id);
2292
+ if (!entries.length) return provider;
2293
+ const models = merge(provider.getModels(), entries);
2294
+ return { ...provider, getModels: () => models };
2295
+ });
2296
+ }
2297
+ entries(providerId) {
2298
+ const kept = this.kept, generatedAt = getBuiltinModelDataGeneratedAt();
2299
+ if (!kept || generatedAt !== void 0 && kept.lastModified <= generatedAt) return [];
2300
+ const models = kept.models[providerId];
2301
+ if (!models || typeof models !== "object") return [];
2302
+ return Object.values(models).filter((model) => typeof model?.id === "string" && typeof model.api === "string" && modelApis.has(model.api) && (model.type === void 0 || model.type === "chat")).map((model) => ({ ...model, provider: providerId }));
2303
+ }
2304
+ async read() {
2305
+ try {
2306
+ const kept = JSON.parse(await readFile(this.path, "utf8"));
2307
+ return kept && typeof kept.models === "object" ? kept : void 0;
2308
+ } catch {
2309
+ return void 0;
2310
+ }
2311
+ }
2312
+ async write() {
2313
+ await mkdir(dirname(this.path), { recursive: true, mode: 448 });
2314
+ const next = `${this.path}.tmp`;
2315
+ await writeFile(next, JSON.stringify(this.kept), { mode: 384 });
2316
+ await rename(next, this.path);
2317
+ }
2318
+ };
2319
+ function merge(models, newer) {
2320
+ const merged = [...models];
2321
+ for (const model of newer) {
2322
+ const index = merged.findIndex((entry) => entry.id === model.id);
2323
+ if (index >= 0) merged[index] = model;
2324
+ else merged.push(model);
2325
+ }
2326
+ return merged;
2327
+ }
2328
+ function providerCatalog(providers) {
2329
+ return providers.flatMap((provider) => {
2182
2330
  const models = provider.getModels();
2183
2331
  if (!models.length && provider.getAllModels?.().length) return [];
2184
2332
  const common = { id: provider.id, api: models[0]?.api ?? "openai-completions", modelCount: models.length };
@@ -2188,7 +2336,6 @@ function providerCatalog() {
2188
2336
  ];
2189
2337
  }).sort((a, b) => a.name.localeCompare(b.name));
2190
2338
  }
2191
- var modelApis = new Set(builtinProviders().flatMap((provider) => provider.getModels().map((model) => model.api)));
2192
2339
 
2193
2340
  // src/server/infra/storage/credential-repository.ts
2194
2341
  function credentialValue(value2) {
@@ -2273,8 +2420,92 @@ var AgentRepository = class {
2273
2420
  }
2274
2421
  };
2275
2422
 
2423
+ // src/server/infra/storage/environment-repository.ts
2424
+ import { randomUUID as randomUUID7 } from "node:crypto";
2425
+ var COLUMNS = "id, owner_id AS ownerId, kind, bot_id AS botId, connection";
2426
+ var record = (row) => ({ id: row.id, ownerId: row.ownerId, kind: row.kind, botId: row.botId, spec: JSON.parse(row.connection) });
2427
+ var EnvironmentRepository = class {
2428
+ constructor(db, ownerId) {
2429
+ this.db = db;
2430
+ this.ownerId = ownerId;
2431
+ }
2432
+ get(id) {
2433
+ const row = this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE id=? AND deleted_at IS NULL`).get(id);
2434
+ return row ? record(row) : null;
2435
+ }
2436
+ /** The owner's computer. */
2437
+ host() {
2438
+ const owner = this.ownerId();
2439
+ const find = () => this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE owner_id=? AND kind='host' AND deleted_at IS NULL`).get(owner);
2440
+ const found = find();
2441
+ if (found) return record(found);
2442
+ this.db.prepare("INSERT OR IGNORE INTO environments(id,owner_id,kind,created_at) VALUES(?,?,'host',?)").run(randomUUID7(), owner, Date.now());
2443
+ return record(find());
2444
+ }
2445
+ computerOf(botId) {
2446
+ const row = this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE bot_id=? AND kind='computer' AND deleted_at IS NULL`).get(botId);
2447
+ return row ? record(row) : null;
2448
+ }
2449
+ /** Every live Bot's computer. */
2450
+ computers() {
2451
+ return this.db.prepare(`SELECT e.id, e.owner_id AS ownerId, e.kind, e.bot_id AS botId, e.connection FROM environments e JOIN bots b ON b.id=e.bot_id
2452
+ WHERE e.kind='computer' AND e.deleted_at IS NULL AND b.deleted_at IS NULL`).all().map(record);
2453
+ }
2454
+ /** Gives the Bot a computer, reached by the container's name. */
2455
+ addComputer(botId, container) {
2456
+ const existing = this.computerOf(botId);
2457
+ if (existing) return existing;
2458
+ const id = randomUUID7();
2459
+ this.db.prepare("INSERT INTO environments(id,owner_id,kind,connection,directory,bot_id,created_at) VALUES(?,?,'computer',?,'/workspace',?,?)").run(id, this.ownerId(), JSON.stringify({ container }), botId, Date.now());
2460
+ return this.get(id);
2461
+ }
2462
+ /** The Bot's computer is gone: whoever was placed on it is back on the owner's computer. */
2463
+ dropComputer(botId) {
2464
+ const computer = this.computerOf(botId);
2465
+ if (!computer) return;
2466
+ this.db.exec("BEGIN IMMEDIATE");
2467
+ try {
2468
+ this.db.prepare("UPDATE bots SET home_environment_id=NULL WHERE home_environment_id=?").run(computer.id);
2469
+ this.db.prepare("UPDATE bots SET current_environment_id=NULL WHERE current_environment_id=?").run(computer.id);
2470
+ this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(computer.id);
2471
+ this.db.prepare("UPDATE environments SET deleted_at=? WHERE id=?").run(Date.now(), computer.id);
2472
+ this.db.exec("COMMIT");
2473
+ } catch (error) {
2474
+ this.db.exec("ROLLBACK");
2475
+ throw error;
2476
+ }
2477
+ }
2478
+ /** The Bot's default environment and the one it works in now. */
2479
+ placement(botId) {
2480
+ const row = this.db.prepare("SELECT home_environment_id AS home, current_environment_id AS current FROM bots WHERE id=?").get(botId);
2481
+ const home = row?.home && this.get(row.home) || this.host();
2482
+ const current = row?.current && this.get(row.current) || home;
2483
+ return { home, current };
2484
+ }
2485
+ /** Makes an environment the Bot's default and moves the Bot there. */
2486
+ setHome(botId, environmentId) {
2487
+ this.db.prepare("UPDATE bots SET home_environment_id=?, current_environment_id=? WHERE id=?").run(environmentId, environmentId, botId);
2488
+ }
2489
+ /** Moves the Bot without changing its default. */
2490
+ setCurrent(botId, environmentId) {
2491
+ this.db.prepare("UPDATE bots SET current_environment_id=? WHERE id=?").run(environmentId, botId);
2492
+ }
2493
+ /** The environments the owner granted the Bot, oldest grant first. */
2494
+ grants(botId) {
2495
+ return this.db.prepare(`SELECT e.id, e.owner_id AS ownerId, e.kind, e.bot_id AS botId, e.connection FROM environment_grants g JOIN environments e ON e.id=g.environment_id
2496
+ WHERE g.bot_id=? AND e.deleted_at IS NULL ORDER BY g.granted_at, e.id`).all(botId).map(record);
2497
+ }
2498
+ /** A grant lasts until it is revoked (A50); false when nothing changed. */
2499
+ grant(environmentId, botId, by) {
2500
+ return Number(this.db.prepare("INSERT OR IGNORE INTO environment_grants(environment_id,bot_id,granted_by,granted_at) VALUES(?,?,?,?)").run(environmentId, botId, by, Date.now()).changes) > 0;
2501
+ }
2502
+ revoke(environmentId, botId) {
2503
+ return Number(this.db.prepare("DELETE FROM environment_grants WHERE environment_id=? AND bot_id=?").run(environmentId, botId).changes) > 0;
2504
+ }
2505
+ };
2506
+
2276
2507
  // src/server/infra/storage/store.ts
2277
- var defaultDataDir = () => process.env.JARVY_DATA_DIR || join(homedir(), ".jarvy");
2508
+ var defaultDataDir = () => process.env.JARVY_DATA_DIR || join2(homedir(), ".jarvy");
2278
2509
  var DATABASE_FILENAME = "jarvy.db";
2279
2510
  var SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1e3;
2280
2511
  function passwordHash(password, salt) {
@@ -2343,10 +2574,11 @@ var Store = class {
2343
2574
  runtime;
2344
2575
  credentials;
2345
2576
  agent;
2577
+ environments;
2346
2578
  constructor(dataDir = defaultDataDir()) {
2347
2579
  const root = resolve(dataDir);
2348
2580
  mkdirSync(root, { recursive: true, mode: 448 });
2349
- this.path = join(root, DATABASE_FILENAME);
2581
+ this.path = join2(root, DATABASE_FILENAME);
2350
2582
  this.db = new DatabaseSync(this.path);
2351
2583
  chmodSync(this.path, 384);
2352
2584
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
@@ -2360,6 +2592,7 @@ var Store = class {
2360
2592
  this.runtime = new RuntimeRepository(this.db);
2361
2593
  this.credentials = new CredentialRepository(this.db);
2362
2594
  this.agent = new AgentRepository(this.db, () => this.userMemberId());
2595
+ this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
2363
2596
  this.agent.ensure();
2364
2597
  }
2365
2598
  close() {
@@ -2369,7 +2602,7 @@ var Store = class {
2369
2602
  installationDeviceId() {
2370
2603
  const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
2371
2604
  if (row) return row.device_id;
2372
- const id = randomUUID7();
2605
+ const id = randomUUID8();
2373
2606
  this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
2374
2607
  return id;
2375
2608
  }
@@ -2417,8 +2650,8 @@ var Store = class {
2417
2650
  if (token) this.db.prepare("DELETE FROM auth_sessions WHERE token_hash=?").run(tokenHash(token));
2418
2651
  }
2419
2652
  botFromRow(row) {
2420
- const workspacePath = join(dirname(this.path), "bots", row.id, "workspace");
2421
- mkdirSync(join(workspacePath, "temp"), { recursive: true, mode: 448 });
2653
+ const workspacePath = join2(dirname2(this.path), "bots", row.id, "workspace");
2654
+ mkdirSync(join2(workspacePath, "temp"), { recursive: true, mode: 448 });
2422
2655
  return { ...row, workspacePath };
2423
2656
  }
2424
2657
  listBots() {
@@ -2445,6 +2678,7 @@ var Store = class {
2445
2678
  this.db.prepare("DELETE FROM chats WHERE type='dm' AND id IN (SELECT chat_id FROM chat_members WHERE member_id=?)").run(id);
2446
2679
  for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
2447
2680
  this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
2681
+ this.db.prepare("DELETE FROM environment_grants WHERE bot_id=?").run(id);
2448
2682
  this.db.exec("COMMIT");
2449
2683
  } catch (error) {
2450
2684
  this.db.exec("ROLLBACK");
@@ -2461,7 +2695,7 @@ var Store = class {
2461
2695
  /** The caller names the Bot (A45: the interface picks a free default in its own language). */
2462
2696
  createBot(name2) {
2463
2697
  const clean = this.botName(name2);
2464
- const id = randomUUID7(), createdAt = Date.now();
2698
+ const id = randomUUID8(), createdAt = Date.now();
2465
2699
  this.db.exec("BEGIN IMMEDIATE");
2466
2700
  try {
2467
2701
  this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
@@ -2533,7 +2767,7 @@ var Store = class {
2533
2767
  }
2534
2768
  const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
2535
2769
  if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
2536
- const id = randomUUID7(), now = Date.now();
2770
+ const id = randomUUID8(), now = Date.now();
2537
2771
  this.db.exec("BEGIN IMMEDIATE");
2538
2772
  try {
2539
2773
  this.db.prepare("INSERT INTO provider_connections(id,provider_name,display_name,kind,auth_type,base_url,api,created_at,updated_at,owner_id) VALUES(?,?,?,?,?,?,?,?,?,?)").run(id, providerName, displayName, kind, authType, baseUrl, api, now, now, this.userMemberId());
@@ -2667,7 +2901,6 @@ var AccountService = class {
2667
2901
  };
2668
2902
 
2669
2903
  // src/server/infra/pi/model.ts
2670
- import { builtinProviders as builtinProviders2 } from "@earendil-works/pi-ai/providers/all";
2671
2904
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2672
2905
  var FORMATS = /* @__PURE__ */ new Set(["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"]);
2673
2906
  function installModel(runtime, connection, configured2, headers2 = {}) {
@@ -2702,12 +2935,12 @@ function installModel(runtime, connection, configured2, headers2 = {}) {
2702
2935
  model.headers = { ...model.headers, ...headers2 };
2703
2936
  return model;
2704
2937
  }
2705
- async function modelCatalog(credentials, providerName) {
2938
+ async function modelCatalog(credentials, providerName, catalog) {
2706
2939
  const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false, refreshOnCreate: false });
2707
- return runtime.getModels(providerName).map(publicModel);
2940
+ return catalog.overlay(providerName, runtime.getModels(providerName)).map(publicModel);
2708
2941
  }
2709
- function builtinModelCatalog(providerName) {
2710
- return (builtinProviders2().find((provider) => provider.id === providerName)?.getModels() ?? []).map(publicModel);
2942
+ function providerModels(providers, providerName) {
2943
+ return (providers.find((provider) => provider.id === providerName)?.getModels() ?? []).map(publicModel);
2711
2944
  }
2712
2945
  function catalogCompat(model) {
2713
2946
  const { thinkingFormat: _format, ...compat } = model.compat ?? {};
@@ -2749,12 +2982,12 @@ var CredentialCoordinator = class {
2749
2982
  if (this.tails.get(connectionId) === tail) this.tails.delete(connectionId);
2750
2983
  });
2751
2984
  if (!signal) return operation;
2752
- return new Promise((resolve6, reject) => {
2985
+ return new Promise((resolve5, reject) => {
2753
2986
  const aborted = () => reject(signal.reason);
2754
2987
  signal.addEventListener("abort", aborted, { once: true });
2755
2988
  operation.then((value2) => {
2756
2989
  signal.removeEventListener("abort", aborted);
2757
- resolve6(value2);
2990
+ resolve5(value2);
2758
2991
  }, (error) => {
2759
2992
  signal.removeEventListener("abort", aborted);
2760
2993
  reject(error);
@@ -2837,7 +3070,6 @@ function resolveThinking(model, choice) {
2837
3070
  }
2838
3071
 
2839
3072
  // src/server/infra/pi/autoconfig.ts
2840
- import { builtinProviders as builtinProviders3 } from "@earendil-works/pi-ai/providers/all";
2841
3073
  var VENDORS = [
2842
3074
  [/^glm/i, ["zai"]],
2843
3075
  [/^deepseek/i, ["deepseek"]],
@@ -2858,23 +3090,20 @@ function officialScore(provider, id) {
2858
3090
  return (id.includes("/") ? 0 : 1) + (words.some((word) => provider.includes(word)) ? 1 : 0);
2859
3091
  }
2860
3092
  var strip = (id) => id.toLowerCase().replace(/^.*\//, "").replace(/-(\d{6,8})$/, "");
2861
- var rows;
2862
- function catalog() {
2863
- rows ??= builtinProviders3().flatMap((provider) => provider.getModels().map((model) => ({ provider: provider.id, model, official: officialScore(provider.id, model.id) })));
2864
- return rows;
3093
+ function rows(providers) {
3094
+ return providers.flatMap((provider) => provider.getModels().map((model) => ({ provider: provider.id, model, official: officialScore(provider.id, model.id) })));
2865
3095
  }
2866
3096
  var source = (row) => ({ provider: row.provider, modelId: row.model.id, name: row.model.name, api: row.model.api, official: row.official === 2 });
2867
3097
  var completeness = (row) => Object.values(row.model.thinkingLevelMap ?? {}).filter((value2) => value2 !== void 0).length + Object.keys(row.model.compat ?? {}).length + (row.model.cost.input > 0 ? 1 : 0);
2868
- function candidates(query) {
3098
+ function candidates(all, query) {
2869
3099
  const q = query.trim().toLowerCase();
2870
3100
  if (!q) return [];
2871
- const all = catalog();
2872
3101
  const same3 = all.filter((row) => row.model.id.toLowerCase() === q || strip(row.model.id) === strip(q));
2873
3102
  return same3.length ? same3 : all.filter((row) => row.model.id.toLowerCase().includes(strip(q)));
2874
3103
  }
2875
- function similar(query) {
3104
+ function similar(all, query) {
2876
3105
  const q = strip(query);
2877
- return catalog().map((row) => ({ row, ...similarity(strip(row.model.id), q) })).filter((item) => item.prefix >= 3).sort((a, b) => b.score - a.score || b.row.official - a.row.official).slice(0, 5).map((item) => item.row);
3106
+ return all.map((row) => ({ row, ...similarity(strip(row.model.id), q) })).filter((item) => item.prefix >= 3).sort((a, b) => b.score - a.score || b.row.official - a.row.official).slice(0, 5).map((item) => item.row);
2878
3107
  }
2879
3108
  function similarity(a, b) {
2880
3109
  let prefix = 0;
@@ -2911,14 +3140,14 @@ function matched(row, api, alternatives, reseller) {
2911
3140
  alternatives: alternatives.filter((item) => item !== row).slice(0, 8).map(source)
2912
3141
  };
2913
3142
  }
2914
- function autoconfigure(modelId, api, choice) {
2915
- const hits = candidates(modelId);
3143
+ function autoconfigure(providers, modelId, api, choice) {
3144
+ const all = rows(providers), hits = candidates(all, modelId);
2916
3145
  if (choice) {
2917
- const row = catalog().find((item) => item.provider === choice.provider && item.model.id === choice.modelId);
3146
+ const row = all.find((item) => item.provider === choice.provider && item.model.id === choice.modelId);
2918
3147
  if (row) return matched(row, api, hits, row.official < 2);
2919
3148
  }
2920
3149
  if (!hits.length) {
2921
- const options = similar(modelId);
3150
+ const options = similar(all, modelId);
2922
3151
  return options.length ? { status: "choose", reason: "similar", options: options.map(source) } : { status: "none" };
2923
3152
  }
2924
3153
  const official = hits.filter((row) => row.official === 2);
@@ -3002,11 +3231,12 @@ async function discoverModels(input, signal) {
3002
3231
 
3003
3232
  // src/server/service/models/service.ts
3004
3233
  var ModelService = class {
3005
- constructor(store, credentials, logins, changed) {
3234
+ constructor(store, credentials, logins, changed, models) {
3006
3235
  this.store = store;
3007
3236
  this.credentials = credentials;
3008
3237
  this.logins = logins;
3009
3238
  this.changed = changed;
3239
+ this.models = models;
3010
3240
  }
3011
3241
  required(value2) {
3012
3242
  if (!value2) throw new RuntimeFault("connection_missing", "Connection not found", 404);
@@ -3041,15 +3271,19 @@ var ModelService = class {
3041
3271
  return this.required(this.store.deleteConnectionModel(id, modelId));
3042
3272
  }
3043
3273
  providers() {
3044
- return providerCatalog();
3274
+ return providerCatalog(this.models.providers());
3275
+ }
3276
+ /** Brings pi.dev's newer catalog entries in the background (A49). */
3277
+ refreshCatalog() {
3278
+ this.models.refresh();
3045
3279
  }
3046
3280
  builtinModels(provider) {
3047
- return builtinModelCatalog(provider);
3281
+ return providerModels(this.models.providers(), provider);
3048
3282
  }
3049
3283
  async catalog(id) {
3050
3284
  const connection = this.required(this.store.getModelConnection(id));
3051
3285
  const credentials = new ConnectionCredentials(this.store.credentials, this.credentials, this.store.userMemberId(), id, connection.providerName);
3052
- return modelCatalog(credentials, connection.providerName);
3286
+ return modelCatalog(credentials, connection.providerName, this.models);
3053
3287
  }
3054
3288
  discover(input, signal) {
3055
3289
  if (typeof input.baseUrl !== "string") throw new RuntimeFault("discovery_url_missing", "Enter the API URL.", 400);
@@ -3062,7 +3296,7 @@ var ModelService = class {
3062
3296
  if (!key) throw new RuntimeFault("discovery_key", "Enter the API key.", 400);
3063
3297
  return discoverModels({ api: String(input.api ?? "openai-completions"), baseUrl: input.baseUrl, key }, signal);
3064
3298
  }
3065
- /** Suggests model fields from the local Pi catalog; the caller decides whether to save them. */
3299
+ /** Suggests model fields from Pi's catalog (with pi.dev's newer entries, A49); the caller decides whether to save them. */
3066
3300
  autoconfigure(input) {
3067
3301
  const text3 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
3068
3302
  const modelId = text3(input.modelId), api = text3(input.api);
@@ -3074,7 +3308,7 @@ var ModelService = class {
3074
3308
  if (!provider || !chosen) throw new RuntimeFault("autoconfig_source", "The setup source isn't valid.", 400);
3075
3309
  choice = { provider, modelId: chosen };
3076
3310
  }
3077
- return autoconfigure(modelId, api, choice);
3311
+ return autoconfigure(this.models.providers(), modelId, api, choice);
3078
3312
  }
3079
3313
  options() {
3080
3314
  return this.list().filter((connection) => connection.models.length).map((connection) => ({
@@ -3101,7 +3335,7 @@ var ModelService = class {
3101
3335
  };
3102
3336
 
3103
3337
  // src/server/infra/pi/auth.ts
3104
- import { randomUUID as randomUUID8 } from "node:crypto";
3338
+ import { randomUUID as randomUUID9 } from "node:crypto";
3105
3339
  import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
3106
3340
  var CredentialLogins = class {
3107
3341
  constructor(store, credentials, changed) {
@@ -3149,7 +3383,7 @@ var CredentialLogins = class {
3149
3383
  prompt(flow, prompt) {
3150
3384
  const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
3151
3385
  signal.throwIfAborted();
3152
- const id = randomUUID8();
3386
+ const id = randomUUID9();
3153
3387
  flow.state.prompt = {
3154
3388
  id,
3155
3389
  type: prompt.type,
@@ -3157,7 +3391,7 @@ var CredentialLogins = class {
3157
3391
  ..."placeholder" in prompt ? { placeholder: prompt.placeholder } : {},
3158
3392
  ...prompt.type === "select" ? { options: prompt.options } : {}
3159
3393
  };
3160
- return new Promise((resolve6, reject) => {
3394
+ return new Promise((resolve5, reject) => {
3161
3395
  const clear = () => {
3162
3396
  signal.removeEventListener("abort", aborted);
3163
3397
  flow.answer = void 0;
@@ -3169,7 +3403,7 @@ var CredentialLogins = class {
3169
3403
  };
3170
3404
  flow.answer = (value2) => {
3171
3405
  clear();
3172
- resolve6(value2);
3406
+ resolve5(value2);
3173
3407
  };
3174
3408
  signal.addEventListener("abort", aborted, { once: true });
3175
3409
  if (signal.aborted) aborted();
@@ -3234,9 +3468,9 @@ var User = class extends Member {
3234
3468
  };
3235
3469
 
3236
3470
  // src/server/application.ts
3237
- import { createHash as createHash4 } from "node:crypto";
3238
- import { mkdir as mkdir4, realpath as realpath2 } from "node:fs/promises";
3239
- import { join as join7, resolve as resolve4 } from "node:path";
3471
+ import { createHash as createHash6 } from "node:crypto";
3472
+ import { mkdir as mkdir5, realpath } from "node:fs/promises";
3473
+ import { join as join9, resolve as resolve3 } from "node:path";
3240
3474
  import lockfile from "proper-lockfile";
3241
3475
 
3242
3476
  // src/server/service/chat/mentions.ts
@@ -3244,12 +3478,12 @@ var WORD = /[\p{Script=Latin}\p{Nd}_]/u;
3244
3478
  function findMentions(source2, candidates2) {
3245
3479
  const text3 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
3246
3480
  const lower = text3.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
3247
- for (let at = lower.indexOf("@"); at >= 0; at = lower.indexOf("@", at + 1)) {
3248
- if (at > 0 && WORD.test(text3[at - 1])) continue;
3481
+ for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
3482
+ if (at2 > 0 && WORD.test(text3[at2 - 1])) continue;
3249
3483
  const match = sorted.find((candidate) => {
3250
3484
  const name2 = candidate.name.toLowerCase();
3251
- if (!name2 || !lower.startsWith(name2, at + 1)) return false;
3252
- const next = text3[at + 1 + name2.length];
3485
+ if (!name2 || !lower.startsWith(name2, at2 + 1)) return false;
3486
+ const next = text3[at2 + 1 + name2.length];
3253
3487
  return !(next && WORD.test(next) && WORD.test(name2.at(-1)));
3254
3488
  });
3255
3489
  if (match && !found.includes(match)) found.push(match);
@@ -3288,8 +3522,10 @@ var POLICY = {
3288
3522
  note: { member: "queue", follower: "queue" },
3289
3523
  // The owner just gave the agent Bot its first model: it should welcome them now (A48).
3290
3524
  onboard: { setup: "interrupt" },
3291
- // What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it.
3292
- change: { agent: "digest" }
3525
+ // What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
3526
+ // a Bot to another work environment stops its running turn like an @, so it doesn't go on in the old place (A50); a
3527
+ // Bot not running, and a change of access alone, hear of it with their next batch instead (fish m1012-m1015).
3528
+ change: { agent: "digest", owner: "interrupt" }
3293
3529
  };
3294
3530
  function urgencyOf(kind, reason) {
3295
3531
  return POLICY[kind][reason];
@@ -3513,7 +3749,7 @@ var ChatQuestions = class {
3513
3749
  if (question.state === "answered") return Promise.resolve(resultOf(question, question.answer));
3514
3750
  if (question.state === "dismissed") return Promise.resolve({ status: "dismissed" });
3515
3751
  if (question.state === "ignored") return Promise.reject(new ChatError(409, "The question has expired", "question_expired"));
3516
- return new Promise((resolve6, reject) => {
3752
+ return new Promise((resolve5, reject) => {
3517
3753
  const stop = () => {
3518
3754
  this.waiting.delete(message.id);
3519
3755
  if (this.repository.settleQuestion(message.id, "ignored", null)) this.changed(message.chatId);
@@ -3526,7 +3762,7 @@ var ChatQuestions = class {
3526
3762
  signal?.addEventListener("abort", stop, { once: true });
3527
3763
  this.waiting.set(message.id, (result) => {
3528
3764
  signal?.removeEventListener("abort", stop);
3529
- resolve6(result);
3765
+ resolve5(result);
3530
3766
  });
3531
3767
  });
3532
3768
  }
@@ -3884,13 +4120,13 @@ var ChatService = class {
3884
4120
  return this.repository.getChat(actorId, chatId);
3885
4121
  }
3886
4122
  /** The message with up to `context` messages before and after it (read position unchanged). */
3887
- around(actorId, messageId, context) {
4123
+ around(actorId, messageId, context2) {
3888
4124
  const message = this.repository.message(actorId, messageId);
3889
4125
  if (!message) throw new ChatError(404, "Message not found");
3890
4126
  return [
3891
- ...this.repository.messagesBefore(actorId, message.chatId, message.sequence, context),
4127
+ ...this.repository.messagesBefore(actorId, message.chatId, message.sequence, context2),
3892
4128
  message,
3893
- ...this.repository.messages(actorId, message.chatId, message.sequence, context)
4129
+ ...this.repository.messages(actorId, message.chatId, message.sequence, context2)
3894
4130
  ];
3895
4131
  }
3896
4132
  /** Exactly the unread messages of a chat, oldest first; the caller moves the read position. */
@@ -3907,10 +4143,10 @@ var ChatService = class {
3907
4143
  find(actorId, query, { within, limit = 50 } = {}) {
3908
4144
  const messages = this.searchAll(actorId, query, limit, within);
3909
4145
  const chats = within ? [] : this.repository.searchChatIds(actorId, query.trim(), 30).map((id) => this.repository.getChat(actorId, id)).filter((chat) => chat !== null);
3910
- const where = /* @__PURE__ */ new Map();
4146
+ const where2 = /* @__PURE__ */ new Map();
3911
4147
  const chatOf = (id) => {
3912
- if (!where.has(id)) where.set(id, this.repository.getChat(actorId, id));
3913
- return where.get(id);
4148
+ if (!where2.has(id)) where2.set(id, this.repository.getChat(actorId, id));
4149
+ return where2.get(id);
3914
4150
  };
3915
4151
  return { chats, messages: messages.flatMap((message) => {
3916
4152
  const chat = chatOf(message.chatId);
@@ -4002,70 +4238,11 @@ var ChatService = class {
4002
4238
  }
4003
4239
  };
4004
4240
 
4005
- // src/server/infra/files/attachments.ts
4006
- import { constants } from "node:fs";
4007
- import { mkdir, open, realpath, rename, rm } from "node:fs/promises";
4008
- import { join as join2, resolve as resolve2 } from "node:path";
4009
- import { randomUUID as randomUUID9 } from "node:crypto";
4010
- var MAX_FILE = 10 * 1024 * 1024;
4011
- var AttachmentFiles = class {
4012
- constructor(dataDir) {
4013
- this.dataDir = dataDir;
4014
- }
4015
- async materialize(file, signal) {
4016
- const dir = join2(this.dataDir, "attachments", file.id);
4017
- await mkdir(dir, { recursive: true, mode: 448 });
4018
- if (await realpath(dir) !== resolve2(dir)) throw new ChatError(400, "Invalid attachment directory");
4019
- const path = join2(dir, file.name), temporary = join2(dir, `.${randomUUID9()}.tmp`);
4020
- try {
4021
- const handle = await open(temporary, "wx", 384);
4022
- try {
4023
- await handle.writeFile(file.data, { signal });
4024
- } finally {
4025
- await handle.close();
4026
- }
4027
- signal?.throwIfAborted();
4028
- await rename(temporary, path);
4029
- } finally {
4030
- await rm(temporary, { force: true });
4031
- }
4032
- return { path, name: file.name };
4033
- }
4034
- async readOutgoing(workspace, items, signal) {
4035
- if (items.length > 5) throw new ChatError(400, "Too many attachments");
4036
- const result = [];
4037
- for (const item of items) {
4038
- signal?.throwIfAborted();
4039
- const path = await realpath(resolve2(workspace, item.path));
4040
- const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
4041
- try {
4042
- const stat = await handle.stat();
4043
- if (!stat.isFile() || !stat.size || stat.size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
4044
- const data = Buffer.alloc(Math.min(MAX_FILE + 1, stat.size + 1));
4045
- let bytes = 0;
4046
- while (bytes < data.length) {
4047
- signal?.throwIfAborted();
4048
- const read = await handle.read(data, bytes, data.length - bytes, bytes);
4049
- if (!read.bytesRead) break;
4050
- bytes += read.bytesRead;
4051
- }
4052
- if (bytes !== stat.size) throw new ChatError(409, "Attachment changed while reading");
4053
- result.push({ name: item.name, mimeType: "application/octet-stream", data: data.subarray(0, bytes) });
4054
- } finally {
4055
- await handle.close();
4056
- }
4057
- }
4058
- return result;
4059
- }
4060
- };
4061
-
4062
4241
  // src/server/service/chat/files.ts
4063
4242
  var ChatFiles = class {
4064
- constructor(dataDir, chats) {
4243
+ constructor(chats) {
4065
4244
  this.chats = chats;
4066
- this.files = new AttachmentFiles(dataDir);
4067
4245
  }
4068
- files;
4069
4246
  async envelope(botId, messageIds, signal) {
4070
4247
  const output = [];
4071
4248
  for (const id of messageIds) {
@@ -4089,7 +4266,7 @@ var ChatFiles = class {
4089
4266
  sender: message.sender,
4090
4267
  content: expandQuotes(message.content, message.quotes, (memberId) => this.chats.member(memberId)?.name ?? memberId),
4091
4268
  createdAt: message.createdAt,
4092
- attachments: await this.paths(botId, message, signal),
4269
+ attachments: listing(message),
4093
4270
  ...this.reply(message)
4094
4271
  });
4095
4272
  }
@@ -4100,20 +4277,21 @@ var ChatFiles = class {
4100
4277
  const sender = message.replyTo ? this.chats.member(message.replyTo.senderId) : null;
4101
4278
  return message.replyTo && sender ? { replyTo: { id: message.replyTo.id, sender, text: replyExcerpt(message.replyTo) } } : {};
4102
4279
  }
4103
- async paths(actorId, message, signal) {
4104
- const paths = [];
4105
- for (const item of message.attachments) {
4106
- signal?.throwIfAborted();
4107
- const attachment = this.chats.download(actorId, item.id);
4108
- if (!attachment) throw new ChatError(404, "Attachment not found");
4109
- paths.push(await this.files.materialize({ ...attachment, id: item.id, name: item.name }, signal));
4110
- }
4111
- return paths;
4112
- }
4113
- readOutgoing(workspace, items, signal) {
4114
- return this.files.readOutgoing(workspace, items, signal);
4280
+ /** A file of a message the actor can read, with its contents. */
4281
+ open(actorId, attachmentId) {
4282
+ const found = this.chats.download(actorId, attachmentId);
4283
+ if (!found) throw new ChatError(404, "File not found, or in a message you can't read");
4284
+ return found;
4115
4285
  }
4116
4286
  };
4287
+ function listing(message) {
4288
+ return message.attachments.map((file) => ({ id: file.id, name: file.name, type: file.mimeType || "application/octet-stream", size: byteSize(file.size) }));
4289
+ }
4290
+ function byteSize(bytes) {
4291
+ if (bytes < 1024) return `${bytes} B`;
4292
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
4293
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
4294
+ }
4117
4295
 
4118
4296
  // src/server/service/events.ts
4119
4297
  var Changes = class {
@@ -4138,8 +4316,8 @@ var Changes = class {
4138
4316
  // src/server/service/bot/activity.ts
4139
4317
  function activityPage(messages, history, limit) {
4140
4318
  const candidates2 = [
4141
- ...messages.map((record2) => ({ kind: "chat", record: record2 })),
4142
- ...history.map((record2) => ({ kind: "history", record: record2 }))
4319
+ ...messages.map((record3) => ({ kind: "chat", record: record3 })),
4320
+ ...history.map((record3) => ({ kind: "history", record: record3 }))
4143
4321
  ];
4144
4322
  candidates2.sort((a, b) => {
4145
4323
  if (a.record.timestamp !== b.record.timestamp) return b.record.timestamp - a.record.timestamp;
@@ -4152,8 +4330,8 @@ function activityPage(messages, history, limit) {
4152
4330
  }
4153
4331
 
4154
4332
  // src/server/service/bot/service.ts
4155
- import { dirname as dirname5, join as join6 } from "node:path";
4156
- import { rm as rm2 } from "node:fs/promises";
4333
+ import { dirname as dirname7, join as join8 } from "node:path";
4334
+ import { rm as rm3 } from "node:fs/promises";
4157
4335
 
4158
4336
  // src/server/infra/pi/history.ts
4159
4337
  var value = (input) => JSON.parse(JSON.stringify(input));
@@ -4270,7 +4448,7 @@ var PiHistoryAdapter = class {
4270
4448
  // src/server/infra/pi/session.ts
4271
4449
  import { existsSync, readFileSync } from "node:fs";
4272
4450
  import { mkdir as mkdir2 } from "node:fs/promises";
4273
- import { dirname as dirname2, join as join3, resolve as resolve3, sep } from "node:path";
4451
+ import { dirname as dirname3, join as join3, resolve as resolve2, sep } from "node:path";
4274
4452
  import { createAgentSession, createCodemodeExtension, DefaultResourceLoader, ModelRuntime as ModelRuntime3, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4275
4453
 
4276
4454
  // src/server/infra/pi/http.ts
@@ -4296,6 +4474,9 @@ function configureHttp(timeoutMs) {
4296
4474
  // src/server/infra/pi/session.ts
4297
4475
  import { Type } from "typebox";
4298
4476
 
4477
+ // src/server/shared/tools.ts
4478
+ var isToolMedia = (value2) => !!value2 && typeof value2 === "object" && value2.kind === "media" && Array.isArray(value2.images);
4479
+
4299
4480
  // src/server/infra/pi/live.ts
4300
4481
  var LiveTranslator = class {
4301
4482
  message = 0;
@@ -4358,6 +4539,130 @@ function resultText(result) {
4358
4539
  }
4359
4540
  }
4360
4541
 
4542
+ // src/server/infra/pi/environment-tools.ts
4543
+ import { AsyncLocalStorage } from "node:async_hooks";
4544
+ import { readFile as readLocal, rm } from "node:fs/promises";
4545
+ import { basename } from "node:path";
4546
+ import { createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition } from "@earendil-works/pi-coding-agent";
4547
+
4548
+ // src/server/infra/pi/image-kind.ts
4549
+ var IMAGE_SNIFF_BYTES = 4100;
4550
+ function imageKind(bytes) {
4551
+ if (startsWith(bytes, [255, 216, 255])) return bytes[3] === 247 ? null : "image/jpeg";
4552
+ if (startsWith(bytes, PNG)) return isPng(bytes) && !isAnimatedPng(bytes) ? "image/png" : null;
4553
+ if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif";
4554
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp";
4555
+ if (ascii(bytes, 0, "BM") && isBmp(bytes)) return "image/bmp";
4556
+ return null;
4557
+ }
4558
+ var PNG = [137, 80, 78, 71, 13, 10, 26, 10];
4559
+ var isPng = (bytes) => bytes.length >= 16 && u32be(bytes, PNG.length) === 13 && ascii(bytes, 12, "IHDR");
4560
+ function isAnimatedPng(bytes) {
4561
+ let offset = PNG.length;
4562
+ while (offset + 8 <= bytes.length) {
4563
+ const length = u32be(bytes, offset), type = offset + 4;
4564
+ if (ascii(bytes, type, "acTL")) return true;
4565
+ if (ascii(bytes, type, "IDAT")) return false;
4566
+ const next = offset + 8 + length + 4;
4567
+ if (next <= offset || next > bytes.length) return false;
4568
+ offset = next;
4569
+ }
4570
+ return false;
4571
+ }
4572
+ function isBmp(bytes) {
4573
+ if (bytes.length < 26) return false;
4574
+ const size = u32le(bytes, 2), pixels = u32le(bytes, 10), header = u32le(bytes, 14);
4575
+ if (size !== 0 && size < 26) return false;
4576
+ if (pixels < 14 + header) return false;
4577
+ if (size !== 0 && pixels >= size) return false;
4578
+ let planes, bits;
4579
+ if (header === 12) {
4580
+ planes = u16le(bytes, 22);
4581
+ bits = u16le(bytes, 24);
4582
+ } else if (header >= 40 && header <= 124) {
4583
+ if (bytes.length < 30) return false;
4584
+ planes = u16le(bytes, 26);
4585
+ bits = u16le(bytes, 28);
4586
+ } else return false;
4587
+ return planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits);
4588
+ }
4589
+ var at = (bytes, offset) => bytes[offset] ?? 0;
4590
+ var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
4591
+ var u32be = (bytes, offset) => at(bytes, offset) * 16777216 + (at(bytes, offset + 1) << 16) + (at(bytes, offset + 2) << 8) + at(bytes, offset + 3);
4592
+ var u32le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8) + (at(bytes, offset + 2) << 16) + at(bytes, offset + 3) * 16777216;
4593
+ var startsWith = (bytes, prefix) => bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte);
4594
+ function ascii(bytes, offset, text3) {
4595
+ if (bytes.length < offset + text3.length) return false;
4596
+ for (let index = 0; index < text3.length; index++) if (bytes[offset + index] !== text3.charCodeAt(index)) return false;
4597
+ return true;
4598
+ }
4599
+
4600
+ // src/server/infra/pi/environment-tools.ts
4601
+ function environmentTools(lookup) {
4602
+ const pinned = new AsyncLocalStorage();
4603
+ const current = () => pinned.getStore() ?? lookup();
4604
+ const access = async (path) => {
4605
+ if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
4606
+ };
4607
+ const readFile3 = (path) => current().readFile(path), writeFile4 = (path, content2) => current().writeFile(path, content2);
4608
+ const cwd = current().cwd;
4609
+ const definitions2 = [
4610
+ relocated(createBashToolDefinition(cwd, { operations: {
4611
+ exec: (command, dir, { onData, signal, timeout, env }) => current().exec(command, {
4612
+ cwd: dir,
4613
+ onData,
4614
+ ...signal ? { signal } : {},
4615
+ ...timeout ? { timeout } : {},
4616
+ ...env ? { env: defined(env) } : {}
4617
+ })
4618
+ } }), current),
4619
+ // Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
4620
+ createReadToolDefinition(cwd, { operations: { readFile: readFile3, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
4621
+ createWriteToolDefinition(cwd, { operations: { writeFile: writeFile4, mkdir: (path) => current().mkdir(path) } }),
4622
+ createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile: writeFile4, access } })
4623
+ ];
4624
+ return definitions2.map((definition) => inside(definition, lookup, pinned));
4625
+ }
4626
+ function inside(definition, lookup, pinned) {
4627
+ const execute = definition.execute.bind(definition);
4628
+ return { ...definition, execute: ((...args) => {
4629
+ const env = lookup(), ctx = args[4];
4630
+ if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target2, key) => key === "cwd" ? env.cwd : Reflect.get(target2, key, target2) });
4631
+ return pinned.run(env, () => execute(...args));
4632
+ }) };
4633
+ }
4634
+ function relocated(definition, current) {
4635
+ const execute = definition.execute.bind(definition);
4636
+ return { ...definition, execute: (async (...args) => {
4637
+ const env = current();
4638
+ if (env.kind === "host") return execute(...args);
4639
+ try {
4640
+ const result = await execute(...args), path = result.details?.fullOutputPath;
4641
+ if (typeof path !== "string") return result;
4642
+ const target2 = await moveOut(path, env), swap = (text3) => text3?.replaceAll(path, target2);
4643
+ return {
4644
+ ...result,
4645
+ content: result.content.map((part) => part.type === "text" ? { ...part, text: swap(part.text) } : part),
4646
+ details: { ...result.details, fullOutputPath: target2 },
4647
+ ...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target2 } } : {}
4648
+ };
4649
+ } catch (error) {
4650
+ const path = error instanceof Error ? /Full output: (\S+?)\]/.exec(error.message)?.[1] : void 0;
4651
+ if (!path) throw error;
4652
+ throw new Error(error.message.replaceAll(path, await moveOut(path, env)));
4653
+ }
4654
+ }) };
4655
+ }
4656
+ async function moveOut(path, env) {
4657
+ const target2 = `${env.cwd}/temp/${basename(path)}`;
4658
+ await env.writeFile(target2, await readLocal(path));
4659
+ await rm(path, { force: true });
4660
+ return target2;
4661
+ }
4662
+ function defined(vars) {
4663
+ return Object.fromEntries(Object.entries(vars).filter((entry) => entry[1] !== void 0));
4664
+ }
4665
+
4361
4666
  // src/server/infra/pi/session.ts
4362
4667
  var HTTP_IDLE_TIMEOUT_MS = 9e5;
4363
4668
  function compactionReserve(contextWindow) {
@@ -4398,10 +4703,10 @@ var PiSession = class _PiSession {
4398
4703
  }
4399
4704
  static async create(options) {
4400
4705
  const { bot, store, reference, selection } = options;
4401
- const agentDir = join3(dirname2(bot.workspacePath), "agent", "pi"), sessionsDir = join3(agentDir, "sessions");
4706
+ const agentDir = join3(dirname3(bot.workspacePath), "agent", "pi"), sessionsDir = join3(agentDir, "sessions");
4402
4707
  await mkdir2(sessionsDir, { recursive: true, mode: 448 });
4403
- const expected = resolve3(sessionsDir) + sep;
4404
- if (!resolve3(reference.file).startsWith(expected) || dirname2(resolve3(reference.file)) !== resolve3(sessionsDir))
4708
+ const expected = resolve2(sessionsDir) + sep;
4709
+ if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
4405
4710
  throw new RuntimeFault("session_path", "The session file path isn't valid.");
4406
4711
  const exists = existsSync(reference.file);
4407
4712
  if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run) => run.state === "unknown")))
@@ -4452,6 +4757,10 @@ var PiSession = class _PiSession {
4452
4757
  executionMode: "sequential",
4453
4758
  execute: async (toolCallId, args, signal) => {
4454
4759
  const output = await tool.execute(args, { toolCallId, signal });
4760
+ if (isToolMedia(output)) return { content: [
4761
+ { type: "text", text: output.text },
4762
+ ...output.images.map((image) => ({ type: "image", data: image.data, mimeType: image.mimeType }))
4763
+ ], details: {} };
4455
4764
  return { content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }], details: {} };
4456
4765
  }
4457
4766
  });
@@ -4468,7 +4777,8 @@ var PiSession = class _PiSession {
4468
4777
  modelRuntime: models,
4469
4778
  model,
4470
4779
  thinkingLevel: resolveThinking(model, selection.thinkingLevel),
4471
- tools
4780
+ tools,
4781
+ customTools: environmentTools(options.environment)
4472
4782
  });
4473
4783
  adapter.session = result.session;
4474
4784
  adapter.settings = settingsManager;
@@ -4485,8 +4795,8 @@ var PiSession = class _PiSession {
4485
4795
  for (let index = entries.length - 1; index >= 0; index--) {
4486
4796
  const entry = entries[index];
4487
4797
  if (entry.type !== "message" || entry.message !== event.message) continue;
4488
- const record2 = history.convert(entry);
4489
- if (record2) await options.onHistory(record2);
4798
+ const record3 = history.convert(entry);
4799
+ if (record3) await options.onHistory(record3);
4490
4800
  break;
4491
4801
  }
4492
4802
  });
@@ -4533,7 +4843,7 @@ var PiSession = class _PiSession {
4533
4843
  }
4534
4844
  /** The last assistant message decides the prompt (a later retry success replaces an earlier error).
4535
4845
  * pi-ai providers and Agent.handleRunFailure report a failure as "aborted" when the run's signal was
4536
- * cancelled, but Pi 0.99.1's lazyStream reports request-setup failures as "error" regardless — e.g. the
4846
+ * cancelled, but Pi 1.2.0's lazyStream reports request-setup failures as "error" regardless — e.g. the
4537
4847
  * request that follows a tool cancelled mid-run. Apply the same signal rule to those. */
4538
4848
  classify(message) {
4539
4849
  if (message.stopReason === "aborted" || message.stopReason === "error" && this.session.agent.signal?.aborted) return { outcome: "interrupted" };
@@ -4569,8 +4879,8 @@ var PiSession = class _PiSession {
4569
4879
  this.wantsAbort = false;
4570
4880
  this.abortFailure = void 0;
4571
4881
  this.result = { outcome: "completed" };
4572
- const settled = new Promise((resolve6) => {
4573
- this.settledResolve = resolve6;
4882
+ const settled = new Promise((resolve5) => {
4883
+ this.settledResolve = resolve5;
4574
4884
  });
4575
4885
  this.options.live?.({ type: "start" });
4576
4886
  let outcome = "failed";
@@ -4625,10 +4935,617 @@ var PiSession = class _PiSession {
4625
4935
  }
4626
4936
  };
4627
4937
 
4938
+ // src/server/infra/env/docker.ts
4939
+ import { execFile, spawn } from "node:child_process";
4940
+ import { createHash as createHash3 } from "node:crypto";
4941
+ import { createWriteStream } from "node:fs";
4942
+ import { mkdir as mkdir3, mkdtemp, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
4943
+ import { tmpdir } from "node:os";
4944
+ import { join as join4 } from "node:path";
4945
+ import { Connection, RemoteExecutionEnv, packagedDaemon } from "@earendil-works/pi-env";
4946
+
4947
+ // src/server/infra/env/computer-image.ts
4948
+ var COMPUTER_DOCKERFILE = `FROM mcr.microsoft.com/playwright:v1.61.1-noble
4949
+ ARG UID=1000
4950
+ ARG GID=1000
4951
+ RUN apt-get update \\
4952
+ && apt-get install -y --no-install-recommends build-essential ca-certificates curl fonts-noto-cjk git python3 python3-dev python3-pip python3-venv sudo \\
4953
+ && rm -rf /var/lib/apt/lists/* \\
4954
+ && npm install -g playwright@1.61.1 && npm cache clean --force \\
4955
+ && taken="$(getent passwd "$UID" | cut -d: -f1)"; if [ -n "$taken" ]; then userdel -r "$taken" 2>/dev/null || userdel "$taken"; fi \\
4956
+ && (getent group "$GID" > /dev/null || groupadd -g "$GID" bot) \\
4957
+ && useradd -m -d /workspace -u "$UID" -g "$GID" -s /bin/bash bot \\
4958
+ && install -d -o "$UID" -g "$GID" /home/bot/.npm-global /home/bot/.npm-global/bin /home/bot/.npm-global/lib \\
4959
+ && echo "bot ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/bot && chmod 0440 /etc/sudoers.d/bot \\
4960
+ && mkdir -p /workspace && chown "$UID:$GID" /workspace
4961
+ ENV NPM_CONFIG_PREFIX=/home/bot/.npm-global PATH=/home/bot/.npm-global/bin:$PATH \\
4962
+ NODE_PATH=/home/bot/.npm-global/lib/node_modules:/usr/lib/node_modules
4963
+ USER bot
4964
+ WORKDIR /workspace
4965
+ `;
4966
+
4967
+ // src/server/infra/env/durable.ts
4968
+ import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
4969
+ function overDurable(id, kind, env, options) {
4970
+ let closed = false;
4971
+ return {
4972
+ id,
4973
+ kind,
4974
+ cwd: env.cwd,
4975
+ async exec(command, exec) {
4976
+ const result = await env.exec(command, {
4977
+ cwd: exec.cwd,
4978
+ ...options.passEnv && exec.env ? { env: exec.env, inheritEnv: false } : { inheritEnv: true },
4979
+ ...exec.timeout ? { timeout: exec.timeout } : {},
4980
+ onOutput: (text3) => exec.onData(Buffer.from(text3))
4981
+ }, context(exec.signal));
4982
+ if (result.ok) return { exitCode: result.value.exitCode };
4983
+ if (result.error.code === "aborted") throw new Error("aborted");
4984
+ if (result.error.code === "timeout") throw new Error(`timeout:${exec.timeout}`);
4985
+ throw fault(result.error);
4986
+ },
4987
+ async readFile(path) {
4988
+ return Buffer.from(unwrap(await env.readBinaryFile(path, BACKGROUND_CONTEXT)));
4989
+ },
4990
+ async head(path, bytes) {
4991
+ const reader = unwrap(await env.openBinaryReader(path, void 0, BACKGROUND_CONTEXT));
4992
+ try {
4993
+ return Buffer.from(unwrap(await reader.read(0, bytes, BACKGROUND_CONTEXT)));
4994
+ } finally {
4995
+ await reader.close(BACKGROUND_CONTEXT);
4996
+ }
4997
+ },
4998
+ async writeFile(path, content2) {
4999
+ unwrap(await env.writeFile(path, content2, BACKGROUND_CONTEXT));
5000
+ },
5001
+ async mkdir(path) {
5002
+ unwrap(await env.createDir(path, { recursive: true }, BACKGROUND_CONTEXT));
5003
+ },
5004
+ async exists(path) {
5005
+ return unwrap(await env.exists(path, BACKGROUND_CONTEXT));
5006
+ },
5007
+ async fileSize(path) {
5008
+ const real = await env.canonicalPath(path, BACKGROUND_CONTEXT);
5009
+ if (!real.ok) {
5010
+ if (real.error.code === "not_found") return null;
5011
+ throw fault(real.error);
5012
+ }
5013
+ const info = unwrap(await env.fileInfo(real.value, BACKGROUND_CONTEXT));
5014
+ return info.kind === "file" ? info.size : null;
5015
+ },
5016
+ async close() {
5017
+ if (closed) return;
5018
+ closed = true;
5019
+ await options.close?.();
5020
+ }
5021
+ };
5022
+ }
5023
+ var context = (signal) => signal ? withAbortSignal(signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT;
5024
+ function unwrap(result) {
5025
+ if (result.ok) return result.value;
5026
+ throw fault(result.error);
5027
+ }
5028
+ function fault(error) {
5029
+ return Object.assign(new Error(error.message), { code: error.code });
5030
+ }
5031
+
5032
+ // src/server/infra/env/docker.ts
5033
+ var COMPUTER_CWD = "/workspace";
5034
+ var DAEMON_DIR = "/opt/jarvy";
5035
+ function docker(args, timeoutMs = 6e4) {
5036
+ return new Promise((resolve5, reject) => execFile("docker", args, { timeout: timeoutMs, maxBuffer: 8 << 20 }, (error, stdout, stderr) => {
5037
+ if (error) reject(Object.assign(new Error((stderr || error.message).trim().split("\n").at(-1) ?? "docker failed"), { code: "docker_failed" }));
5038
+ else resolve5(stdout.trim());
5039
+ }));
5040
+ }
5041
+ async function dockerAvailable() {
5042
+ try {
5043
+ await docker(["version", "--format", "{{.Server.Version}}"], 1e4);
5044
+ return true;
5045
+ } catch {
5046
+ return false;
5047
+ }
5048
+ }
5049
+ function computerImage(uid, gid) {
5050
+ return `jarvy-computer:${createHash3("sha256").update(`${COMPUTER_DOCKERFILE}
5051
+ ${uid}:${gid}`).digest("hex").slice(0, 12)}`;
5052
+ }
5053
+ async function pruneImages(keep) {
5054
+ const tags = (await docker(["images", "--filter", "label=jarvy.computer=1", "--format", "{{.Repository}}:{{.Tag}}"])).split("\n").filter(Boolean);
5055
+ const removed = [];
5056
+ for (const tag of tags) if (tag !== keep) {
5057
+ try {
5058
+ await docker(["image", "rm", tag], 12e4);
5059
+ removed.push(tag);
5060
+ } catch {
5061
+ }
5062
+ }
5063
+ return removed;
5064
+ }
5065
+ async function imageExists(tag) {
5066
+ try {
5067
+ await docker(["image", "inspect", "--format", "{{.Id}}", tag]);
5068
+ return true;
5069
+ } catch {
5070
+ return false;
5071
+ }
5072
+ }
5073
+ async function buildImage(tag, uid, gid, logFile) {
5074
+ const dir = await mkdtemp(join4(tmpdir(), "jarvy-computer-"));
5075
+ try {
5076
+ await writeFile2(join4(dir, "Dockerfile"), COMPUTER_DOCKERFILE);
5077
+ const log = createWriteStream(logFile, { flags: "w", mode: 384 });
5078
+ const code = await new Promise((resolve5, reject) => {
5079
+ const child = spawn(
5080
+ "docker",
5081
+ ["build", "-t", tag, "--label", "jarvy.computer=1", "--build-arg", `UID=${uid}`, "--build-arg", `GID=${gid}`, dir],
5082
+ { stdio: ["ignore", "pipe", "pipe"] }
5083
+ );
5084
+ child.stdout.pipe(log, { end: false });
5085
+ child.stderr.pipe(log, { end: false });
5086
+ child.on("error", reject);
5087
+ child.on("close", resolve5);
5088
+ }).finally(() => log.end());
5089
+ if (code !== 0) throw Object.assign(new Error(`docker build exited with ${code}`), { code: "docker_failed" });
5090
+ } finally {
5091
+ await rm2(dir, { recursive: true, force: true });
5092
+ }
5093
+ }
5094
+ async function containerStates(label) {
5095
+ const out = await docker(["ps", "-a", "--filter", `label=${label}`, "--format", "{{.Names}} {{.State}}"]);
5096
+ const states = /* @__PURE__ */ new Map();
5097
+ for (const line of out.split("\n").filter(Boolean)) {
5098
+ const [name2, state] = line.split(" ");
5099
+ if (name2) states.set(name2, state === "running" ? "running" : "stopped");
5100
+ }
5101
+ return states;
5102
+ }
5103
+ async function containerState(name2) {
5104
+ try {
5105
+ return await docker(["inspect", "--format", "{{.State.Running}}", name2]) === "true" ? "running" : "stopped";
5106
+ } catch (error) {
5107
+ if (/no such (object|container)/i.test(error.message)) return "missing";
5108
+ throw error;
5109
+ }
5110
+ }
5111
+ async function runContainer(spec) {
5112
+ await docker([
5113
+ "run",
5114
+ "-d",
5115
+ "--name",
5116
+ spec.name,
5117
+ "--init",
5118
+ "--user",
5119
+ `${spec.uid}:${spec.gid}`,
5120
+ "--shm-size",
5121
+ "1g",
5122
+ ...Object.entries(spec.labels).flatMap(([key, value2]) => ["--label", `${key}=${value2}`]),
5123
+ "-v",
5124
+ `${spec.workspace}:${COMPUTER_CWD}`,
5125
+ "-v",
5126
+ `${spec.daemonDir}:${DAEMON_DIR}:ro`,
5127
+ "-w",
5128
+ COMPUTER_CWD,
5129
+ spec.image,
5130
+ "sleep",
5131
+ "infinity"
5132
+ ], 12e4);
5133
+ }
5134
+ async function startContainer(name2) {
5135
+ await docker(["start", name2], 6e4);
5136
+ }
5137
+ async function homeAtWorkspace(name2) {
5138
+ const home = await docker(["exec", name2, "sh", "-c", "getent passwd bot | cut -d: -f6"], 3e4);
5139
+ if (home !== COMPUTER_CWD) await docker(["exec", "-u", "root", name2, "usermod", "-d", COMPUTER_CWD, "bot"], 3e4);
5140
+ }
5141
+ async function stopContainer(name2) {
5142
+ await docker(["stop", "--time", "5", name2], 6e4);
5143
+ }
5144
+ async function removeContainer(name2) {
5145
+ try {
5146
+ await docker(["rm", "--force", name2], 6e4);
5147
+ } catch (error) {
5148
+ if (!/no such (object|container)/i.test(error.message)) throw error;
5149
+ }
5150
+ }
5151
+ async function syncDaemon(dir) {
5152
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
5153
+ const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
5154
+ const target2 = join4(dir, "pi-env");
5155
+ const current = await readFile2(target2).catch(() => null);
5156
+ if (current?.equals(wanted)) return;
5157
+ await mkdir3(dir, { recursive: true, mode: 448 });
5158
+ const temp = `${target2}.${process.pid}.tmp`;
5159
+ await writeFile2(temp, wanted, { mode: 493 });
5160
+ await rename2(temp, target2);
5161
+ }
5162
+ function containerEnvironment(id, container, log) {
5163
+ const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text3) => log(text3.trim()) });
5164
+ return overDurable(id, "computer", new RemoteExecutionEnv({ connection, id, cwd: COMPUTER_CWD }), { passEnv: false, close: () => connection.close() });
5165
+ }
5166
+
5167
+ // src/server/infra/env/host.ts
5168
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
5169
+ function hostEnvironment(cwd) {
5170
+ return overDurable("host", "host", new NodeExecutionEnv({ cwd }), { passEnv: true });
5171
+ }
5172
+
5173
+ // src/server/service/bot/environments.ts
5174
+ var ABOUT = {
5175
+ computer: "A Linux container only you use, with Node, Python, git, build tools, and Playwright with its browsers. You can install software there with sudo; it stays until the owner resets your sandbox, and /workspace, your home there, is kept when it is reset.",
5176
+ host: "The machine running Jarvy. Your tools run there as your owner and can reach their files: work in your directory, and leave other members' workspaces, live data directories (such as ~/.jarvy), main repository checkouts and system directories alone unless the task needs it; say in your reply what you changed there."
5177
+ };
5178
+ var BotEnvironments = class {
5179
+ constructor(store, computers) {
5180
+ this.store = store;
5181
+ this.computers = computers;
5182
+ }
5183
+ open = /* @__PURE__ */ new Map();
5184
+ current(botId, workspacePath) {
5185
+ const target2 = this.store.environments.placement(botId).current;
5186
+ const open = this.open.get(botId);
5187
+ if (open?.id === target2.id) return open.view;
5188
+ if (open) this.retire(open);
5189
+ const opened = this.track(target2.id, target2.kind === "computer" ? this.computers.environment(target2.botId ?? botId, target2) : hostEnvironment(workspacePath));
5190
+ this.open.set(botId, opened);
5191
+ return opened.view;
5192
+ }
5193
+ placement(botId) {
5194
+ const environments = this.store.environments, { home, current } = environments.placement(botId);
5195
+ const hostId = environments.host().id;
5196
+ return {
5197
+ home: home.kind,
5198
+ current: current.kind,
5199
+ computer: this.computers.status(botId),
5200
+ docker: this.computers.available,
5201
+ hostGranted: environments.grants(botId).some((grant) => grant.id === hostId)
5202
+ };
5203
+ }
5204
+ /** The environments the Bot may be in: its sandbox, its default, and the ones its owner granted it. */
5205
+ usable(botId) {
5206
+ const environments = this.store.environments;
5207
+ const all = [environments.computerOf(botId), environments.placement(botId).home, ...environments.grants(botId)];
5208
+ return [...new Map(all.filter((record3) => !!record3).map((record3) => [record3.id, record3])).values()];
5209
+ }
5210
+ place(botId, record3, workspacePath) {
5211
+ if (record3.kind === "host") return { id: record3.id, kind: "host", name: "your owner's computer", directory: workspacePath };
5212
+ return { id: record3.id, kind: "sandbox", name: record3.botId === botId ? "your sandbox" : "another Bot's sandbox", directory: COMPUTER_CWD };
5213
+ }
5214
+ /** A Bot's environments as other Bots see them in bots.list. */
5215
+ summary(botId) {
5216
+ const name2 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
5217
+ const { home, current } = this.store.environments.placement(botId);
5218
+ return { default: name2(home), current: name2(current), usable: this.usable(botId).map(name2) };
5219
+ }
5220
+ /** The Bot's default environment, which its instructions describe. */
5221
+ home(botId, workspacePath) {
5222
+ return this.place(botId, this.store.environments.placement(botId).home, workspacePath);
5223
+ }
5224
+ /** Where the Bot works now, for the end of each batch. */
5225
+ here(botId, workspacePath) {
5226
+ return this.place(botId, this.store.environments.placement(botId).current, workspacePath);
5227
+ }
5228
+ /** environments.list: where the Bot is and where else it may go. */
5229
+ list(botId, workspacePath) {
5230
+ const { home, current } = this.store.environments.placement(botId);
5231
+ return { current: current.id, environments: this.usable(botId).map((record3) => ({
5232
+ ...this.place(botId, record3, workspacePath),
5233
+ ...record3.id === home.id ? { default: true } : {},
5234
+ ...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {}
5235
+ })) };
5236
+ }
5237
+ /** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first; if it
5238
+ * can't start, the Bot stays where it was. */
5239
+ async switch(botId, environmentId, workspacePath) {
5240
+ const target2 = this.usable(botId).find((record3) => record3.id === environmentId);
5241
+ if (!target2) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
5242
+ const already = this.store.environments.placement(botId).current.id === target2.id;
5243
+ if (target2.kind === "computer") await this.computers.ensure(target2.botId ?? botId);
5244
+ this.store.environments.setCurrent(botId, target2.id);
5245
+ return { ...this.place(botId, target2, workspacePath), about: ABOUT[target2.kind], ...already ? { note: "You were already here." } : {} };
5246
+ }
5247
+ /** The owner makes the Bot's sandbox (given it on first use) or their computer its default, and moves it there. */
5248
+ setHome(botId, kind, workspacePath) {
5249
+ const environments = this.store.environments, from = this.here(botId, workspacePath);
5250
+ if (kind === "host") environments.setHome(botId, environments.host().id);
5251
+ else {
5252
+ const computer = environments.computerOf(botId);
5253
+ if (computer) {
5254
+ environments.setHome(botId, computer.id);
5255
+ void this.computers.ensure(botId).catch(() => {
5256
+ });
5257
+ } else this.computers.create(botId);
5258
+ }
5259
+ const to = this.here(botId, workspacePath);
5260
+ return from.id === to.id ? null : { from, to };
5261
+ }
5262
+ /** The owner grants or takes back their computer (A50); taking it back while the Bot works there sends it home. */
5263
+ grantHost(botId, granted, by, workspacePath) {
5264
+ const environments = this.store.environments, host = environments.host();
5265
+ if (granted) return { changed: environments.grant(host.id, botId, by), moved: null };
5266
+ const changed = environments.revoke(host.id, botId), { home, current } = environments.placement(botId);
5267
+ if (current.id !== host.id || home.id === host.id) return { changed, moved: null };
5268
+ const from = this.here(botId, workspacePath);
5269
+ environments.setCurrent(botId, home.id);
5270
+ return { changed, moved: { from, to: this.here(botId, workspacePath) } };
5271
+ }
5272
+ async release(botId) {
5273
+ const open = this.open.get(botId);
5274
+ this.open.delete(botId);
5275
+ await open?.env.close();
5276
+ await this.computers.remove(botId);
5277
+ }
5278
+ async close() {
5279
+ const all = [...this.open.values()];
5280
+ this.open.clear();
5281
+ await Promise.all(all.map((open) => open.env.close()));
5282
+ await this.computers.close();
5283
+ }
5284
+ retire(open) {
5285
+ open.retired = true;
5286
+ if (!open.busy) void open.env.close();
5287
+ }
5288
+ /** Counts the operations running on an environment, so one the Bot has left closes only when they are done. */
5289
+ track(id, env) {
5290
+ const opened = { id, env, view: env, busy: 0, retired: false };
5291
+ const run = async (work) => {
5292
+ opened.busy++;
5293
+ try {
5294
+ return await work();
5295
+ } finally {
5296
+ if (--opened.busy === 0 && opened.retired) void env.close();
5297
+ }
5298
+ };
5299
+ opened.view = {
5300
+ id: env.id,
5301
+ kind: env.kind,
5302
+ cwd: env.cwd,
5303
+ exec: (command, options) => run(() => env.exec(command, options)),
5304
+ readFile: (path) => run(() => env.readFile(path)),
5305
+ head: (path, bytes) => run(() => env.head(path, bytes)),
5306
+ writeFile: (path, content2) => run(() => env.writeFile(path, content2)),
5307
+ mkdir: (path) => run(() => env.mkdir(path)),
5308
+ exists: (path) => run(() => env.exists(path)),
5309
+ fileSize: (path) => run(() => env.fileSize(path)),
5310
+ close: () => env.close()
5311
+ };
5312
+ return opened;
5313
+ }
5314
+ };
5315
+
5316
+ // src/server/service/bot/computers.ts
5317
+ import { createHash as createHash4 } from "node:crypto";
5318
+ import { dirname as dirname4, join as join5 } from "node:path";
5319
+ var SWEEP_MS = 6e4;
5320
+ var Computers = class {
5321
+ constructor(deps) {
5322
+ this.deps = deps;
5323
+ this.dataDir = dirname4(deps.store.path);
5324
+ this.tag = createHash4("sha256").update(this.dataDir).digest("hex").slice(0, 12);
5325
+ }
5326
+ docker = null;
5327
+ probing = null;
5328
+ image = null;
5329
+ timer = null;
5330
+ states = /* @__PURE__ */ new Map();
5331
+ preparing = /* @__PURE__ */ new Map();
5332
+ connections = /* @__PURE__ */ new Map();
5333
+ /** Containers whose home was checked since Jarvy started (`homeAtWorkspace`). */
5334
+ homes = /* @__PURE__ */ new Map();
5335
+ dataDir;
5336
+ /** Tells this Jarvy's containers from those of other data directories on the same Docker. */
5337
+ tag;
5338
+ uid = process.getuid?.() ?? 1e3;
5339
+ gid = process.getgid?.() ?? 1e3;
5340
+ /** Looks for Docker and learns the state of every computer, once; every computer waits for it. */
5341
+ start() {
5342
+ this.probing ??= this.probe();
5343
+ return this.probing;
5344
+ }
5345
+ async probe() {
5346
+ this.docker = await dockerAvailable();
5347
+ if (!this.docker) return;
5348
+ try {
5349
+ await syncDaemon(join5(this.dataDir, "computers", "bin"));
5350
+ } catch (error) {
5351
+ this.docker = false;
5352
+ this.log(`computers disabled: ${error.message}`);
5353
+ return;
5354
+ }
5355
+ await this.sweep().catch((error) => this.log(`computer sweep failed: ${error.message}`));
5356
+ this.timer = setInterval(() => void this.sweep().catch((error) => this.log(`computer sweep failed: ${error.message}`)), SWEEP_MS);
5357
+ this.timer.unref();
5358
+ }
5359
+ /** Whether new computers can be made here; false until `start` found Docker. */
5360
+ get available() {
5361
+ return this.docker === true;
5362
+ }
5363
+ status(botId) {
5364
+ if (!this.deps.store.environments.computerOf(botId)) return null;
5365
+ if (!this.available) return { state: "unavailable" };
5366
+ return this.states.get(botId) ?? { state: "stopped" };
5367
+ }
5368
+ /** Gives a Bot its computer and makes it the Bot's default environment; the container is made in the background. */
5369
+ create(botId) {
5370
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
5371
+ const computer = this.deps.store.environments.addComputer(botId, `jarvy-${this.tag.slice(0, 6)}-${botId.slice(0, 8)}`);
5372
+ this.deps.store.environments.setHome(botId, computer.id);
5373
+ void this.ensure(botId).catch(() => {
5374
+ });
5375
+ }
5376
+ /** What the Bot's tools use while it works on its computer: every call first makes sure the container runs. */
5377
+ environment(botId, record3) {
5378
+ const run = async (op) => {
5379
+ try {
5380
+ return await op(await this.connect(botId, record3));
5381
+ } catch (error) {
5382
+ if (error instanceof RuntimeFault || await containerState(record3.spec.container).catch(() => "missing") === "running") throw error;
5383
+ await this.disconnect(botId);
5384
+ this.set(botId, { state: "stopped" });
5385
+ return op(await this.connect(botId, record3));
5386
+ }
5387
+ };
5388
+ return {
5389
+ id: record3.id,
5390
+ kind: "computer",
5391
+ cwd: COMPUTER_CWD,
5392
+ exec: (command, options) => run((env) => env.exec(command, options)),
5393
+ readFile: (path) => run((env) => env.readFile(path)),
5394
+ head: (path, bytes) => run((env) => env.head(path, bytes)),
5395
+ writeFile: (path, content2) => run((env) => env.writeFile(path, content2)),
5396
+ mkdir: (path) => run((env) => env.mkdir(path)),
5397
+ exists: (path) => run((env) => env.exists(path)),
5398
+ fileSize: (path) => run((env) => env.fileSize(path)),
5399
+ close: () => this.disconnect(botId)
5400
+ };
5401
+ }
5402
+ async connect(botId, record3) {
5403
+ const container = await this.ensure(botId);
5404
+ let home = this.homes.get(container);
5405
+ if (!home) {
5406
+ home = homeAtWorkspace(container).catch((error) => this.log(`home of ${botId} not moved: ${error.message}`));
5407
+ this.homes.set(container, home);
5408
+ }
5409
+ await home;
5410
+ let env = this.connections.get(botId);
5411
+ if (!env) {
5412
+ env = containerEnvironment(record3.id, container, (text3) => this.log(`[${botId.slice(0, 8)}] ${text3}`));
5413
+ this.connections.set(botId, env);
5414
+ }
5415
+ return env;
5416
+ }
5417
+ /** Makes sure the container exists and runs, building the image first when needed; returns its name. */
5418
+ ensure(botId) {
5419
+ const pending = this.preparing.get(botId);
5420
+ if (pending) return pending;
5421
+ const computer = this.deps.store.environments.computerOf(botId);
5422
+ if (!computer) return Promise.reject(new RuntimeFault("computer_missing", "This Bot has no sandbox.", 404));
5423
+ const container = computer.spec.container;
5424
+ if (this.states.get(botId)?.state === "running") return Promise.resolve(container);
5425
+ if (this.docker !== false) this.set(botId, { state: "preparing" });
5426
+ const task = (async () => {
5427
+ try {
5428
+ await this.start();
5429
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 503);
5430
+ const image = await this.imageReady();
5431
+ const state = await containerState(container);
5432
+ if (state === "missing") await runContainer({
5433
+ name: container,
5434
+ image,
5435
+ workspace: join5(this.dataDir, "bots", botId, "workspace"),
5436
+ daemonDir: join5(this.dataDir, "computers", "bin"),
5437
+ uid: this.uid,
5438
+ gid: this.gid,
5439
+ labels: { "jarvy.data": this.tag, "jarvy.bot": botId }
5440
+ });
5441
+ else if (state === "stopped") await startContainer(container);
5442
+ this.set(botId, { state: "running" });
5443
+ return container;
5444
+ } catch (error) {
5445
+ this.set(botId, { state: "failed", error: error instanceof RuntimeFault ? error.code : "computer_failed" });
5446
+ this.log(`computer of ${botId} failed: ${error.message}`);
5447
+ if (error instanceof RuntimeFault) throw error;
5448
+ throw new RuntimeFault("computer_failed", `Your sandbox could not start: ${error.message}`, 503);
5449
+ }
5450
+ })().finally(() => this.preparing.delete(botId));
5451
+ this.preparing.set(botId, task);
5452
+ return task;
5453
+ }
5454
+ async stop(botId) {
5455
+ const computer = this.need(botId);
5456
+ await this.disconnect(botId);
5457
+ await stopContainer(computer.spec.container);
5458
+ this.set(botId, { state: "stopped" });
5459
+ }
5460
+ /** Removes the container, so the next start makes a fresh one from the image: what was installed goes, the
5461
+ * workspace stays. */
5462
+ async reset(botId) {
5463
+ const computer = this.need(botId);
5464
+ await this.disconnect(botId);
5465
+ await removeContainer(computer.spec.container);
5466
+ this.homes.delete(computer.spec.container);
5467
+ this.set(botId, { state: "stopped" });
5468
+ }
5469
+ /** The Bot is being deleted: its container goes with it. */
5470
+ async remove(botId) {
5471
+ const computer = this.deps.store.environments.computerOf(botId);
5472
+ if (!computer) return;
5473
+ await this.disconnect(botId);
5474
+ if (this.available) await removeContainer(computer.spec.container);
5475
+ this.deps.store.environments.dropComputer(botId);
5476
+ this.states.delete(botId);
5477
+ }
5478
+ /** Closes the connections; containers keep running. */
5479
+ async close() {
5480
+ if (this.timer) clearInterval(this.timer);
5481
+ this.timer = null;
5482
+ await Promise.all([...this.connections.keys()].map((botId) => this.disconnect(botId)));
5483
+ }
5484
+ need(botId) {
5485
+ const computer = this.deps.store.environments.computerOf(botId);
5486
+ if (!computer) throw new RuntimeFault("computer_missing", "This Bot has no sandbox.", 404);
5487
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
5488
+ return computer;
5489
+ }
5490
+ imageReady() {
5491
+ this.image ??= (async () => {
5492
+ const tag = computerImage(this.uid, this.gid);
5493
+ if (!await imageExists(tag)) {
5494
+ this.log(`building ${tag}`);
5495
+ await buildImage(tag, this.uid, this.gid, join5(this.dataDir, "computers", "build.log"));
5496
+ void pruneImages(tag).then((removed) => {
5497
+ if (removed.length) this.log(`removed ${removed.join(", ")}`);
5498
+ }).catch(() => {
5499
+ });
5500
+ }
5501
+ return tag;
5502
+ })().catch((error) => {
5503
+ this.image = null;
5504
+ throw error;
5505
+ });
5506
+ return this.image;
5507
+ }
5508
+ async disconnect(botId) {
5509
+ const env = this.connections.get(botId);
5510
+ this.connections.delete(botId);
5511
+ await env?.close();
5512
+ }
5513
+ set(botId, status) {
5514
+ const before = this.states.get(botId);
5515
+ this.states.set(botId, status);
5516
+ if (before?.state !== status.state || before?.error !== status.error) this.deps.changed();
5517
+ }
5518
+ /** Learns what happened to containers outside Jarvy, and removes containers of this Jarvy that no Bot has any more
5519
+ * (a Bot deleted while Docker was out of reach). */
5520
+ async sweep() {
5521
+ const states = await containerStates(`jarvy.data=${this.tag}`);
5522
+ const computers = this.deps.store.environments.computers();
5523
+ const owned = new Set(computers.map((computer) => computer.spec.container));
5524
+ for (const name2 of states.keys()) if (!owned.has(name2)) {
5525
+ this.log(`removing ${name2}, which no Bot has`);
5526
+ await removeContainer(name2).catch((error) => this.log(`removing ${name2} failed: ${error.message}`));
5527
+ }
5528
+ for (const computer of computers) {
5529
+ const botId = computer.botId;
5530
+ if (this.preparing.has(botId)) continue;
5531
+ const running = states.get(computer.spec.container) === "running";
5532
+ if (!running) {
5533
+ if (this.states.get(botId)?.state !== "failed") this.set(botId, { state: "stopped" });
5534
+ await this.disconnect(botId);
5535
+ continue;
5536
+ }
5537
+ this.set(botId, { state: "running" });
5538
+ }
5539
+ }
5540
+ log(text3) {
5541
+ (this.deps.log ?? console.log)(`[computers] ${text3}`);
5542
+ }
5543
+ };
5544
+
4628
5545
  // src/server/infra/storage/history-repository.ts
4629
5546
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
4630
5547
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
4631
- import { dirname as dirname3 } from "node:path";
5548
+ import { dirname as dirname5 } from "node:path";
4632
5549
  var json = (value2) => value2 == null ? null : JSON.stringify(value2);
4633
5550
  var CALL = "EXISTS (SELECT 1 FROM json_each(history.message, '$.content') b WHERE json_extract(b.value, '$.type')=? AND json_extract(b.value, '$.tool_call_id')=?)";
4634
5551
  var BLOCKS = "FROM json_each(history.message,'$.content') b WHERE json_extract(b.value,'$.type')";
@@ -4640,7 +5557,7 @@ var SUMMARY = `SELECT id,timestamp,role,model,
4640
5557
  var HistoryRepository = class {
4641
5558
  db;
4642
5559
  constructor(path) {
4643
- mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
5560
+ mkdirSync2(dirname5(path), { recursive: true, mode: 448 });
4644
5561
  this.db = new DatabaseSync2(path);
4645
5562
  chmodSync2(path, 384);
4646
5563
  const version = this.db.prepare("PRAGMA user_version").get().user_version;
@@ -4696,17 +5613,17 @@ var HistoryRepository = class {
4696
5613
  const row = this.db.prepare("SELECT * FROM history WHERE id=?").get(id);
4697
5614
  return row ? this.decode(row) : null;
4698
5615
  }
4699
- links(record2) {
5616
+ links(record3) {
4700
5617
  const links = [];
4701
- for (const block of record2.message.content) {
5618
+ for (const block of record3.message.content) {
4702
5619
  if (block.type !== "tool_call" && block.type !== "tool_result") continue;
4703
5620
  const callId = block.tool_call_id;
4704
5621
  if (block.type === "tool_result") {
4705
- const call = this.db.prepare(`SELECT id,role FROM history WHERE id<? AND ${CALL} ORDER BY id DESC LIMIT 1`).get(record2.id, "tool_call", callId);
5622
+ const call = this.db.prepare(`SELECT id,role FROM history WHERE id<? AND ${CALL} ORDER BY id DESC LIMIT 1`).get(record3.id, "tool_call", callId);
4706
5623
  if (call) links.push({ ...call, tool_call_id: callId });
4707
5624
  } else {
4708
- const next = this.db.prepare(`SELECT id FROM history WHERE id>? AND ${CALL} ORDER BY id LIMIT 1`).get(record2.id, "tool_call", callId);
4709
- const results = this.db.prepare(`SELECT id,role FROM history WHERE id>? AND id<? AND ${CALL} ORDER BY id`).all(record2.id, next?.id ?? Number.MAX_SAFE_INTEGER, "tool_result", callId);
5625
+ const next = this.db.prepare(`SELECT id FROM history WHERE id>? AND ${CALL} ORDER BY id LIMIT 1`).get(record3.id, "tool_call", callId);
5626
+ const results = this.db.prepare(`SELECT id,role FROM history WHERE id>? AND id<? AND ${CALL} ORDER BY id`).all(record3.id, next?.id ?? Number.MAX_SAFE_INTEGER, "tool_result", callId);
4710
5627
  links.push(...results.map((result) => ({ ...result, tool_call_id: callId })));
4711
5628
  }
4712
5629
  }
@@ -4750,14 +5667,14 @@ var Memory = class {
4750
5667
  return this.history().usage(since);
4751
5668
  }
4752
5669
  read(id) {
4753
- const record2 = this.history().read(id);
4754
- return record2 ? { record: record2, links: this.history().links(record2) } : null;
5670
+ const record3 = this.history().read(id);
5671
+ return record3 ? { record: record3, links: this.history().links(record3) } : null;
4755
5672
  }
4756
5673
  readMany(ids, mode) {
4757
5674
  const found = mode === "summary" ? this.history().summaries(ids).map((item) => item && summaryLine(item)) : this.history().details(ids);
4758
5675
  const result = { mode, records: [], missingIds: [] };
4759
- found.forEach((record2, index) => {
4760
- if (record2) result.records.push(record2);
5676
+ found.forEach((record3, index) => {
5677
+ if (record3) result.records.push(record3);
4761
5678
  else result.missingIds.push(ids[index]);
4762
5679
  });
4763
5680
  return result;
@@ -4769,7 +5686,7 @@ var Memory = class {
4769
5686
  };
4770
5687
 
4771
5688
  // src/server/service/bot/bot.ts
4772
- import { createHash as createHash3 } from "node:crypto";
5689
+ import { createHash as createHash5 } from "node:crypto";
4773
5690
  var Bot = class extends Member {
4774
5691
  kind = "bot";
4775
5692
  identity;
@@ -4860,7 +5777,7 @@ var Bot = class extends Member {
4860
5777
  ...message.question ? { question: message.question } : {},
4861
5778
  ...message.alsoToChannel ? { alsoToChannel: true } : {},
4862
5779
  ...message.replyTo ? { replyTo: message.replyTo } : {},
4863
- clientMessageId: source2 ? createHash3("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
5780
+ clientMessageId: source2 ? createHash5("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
4864
5781
  });
4865
5782
  }
4866
5783
  /** 由 Bot 协调配置与运行实例;配置在应用成功后提交。执行中切换从下一次模型请求生效(A28)。 */
@@ -4883,19 +5800,20 @@ var Bot = class extends Member {
4883
5800
  identity: () => this.identity.snapshot(),
4884
5801
  model: () => this.model.current(),
4885
5802
  workspace: this.workspace.location(),
5803
+ home: () => this.workspace.home(),
4886
5804
  tools: this.tools.definitions(),
4887
5805
  onStatus: (status) => this.updateState(status),
4888
- onHistory: (record2) => this.recordHistory(record2)
5806
+ onHistory: (record3) => this.recordHistory(record3)
4889
5807
  };
4890
5808
  }
4891
5809
  updateState(status) {
4892
5810
  this.state.update(status);
4893
5811
  }
4894
- async recordHistory(record2) {
4895
- const historyId = await this.memory.append(record2);
4896
- if (record2.role !== "assistant") return;
5812
+ async recordHistory(record3) {
5813
+ const historyId = await this.memory.append(record3);
5814
+ if (record3.role !== "assistant") return;
4897
5815
  this.callHistory.clear();
4898
- for (const block of record2.message.content) {
5816
+ for (const block of record3.message.content) {
4899
5817
  if (block.type === "tool_call") this.callHistory.set(block.tool_call_id, historyId);
4900
5818
  }
4901
5819
  }
@@ -4926,10 +5844,10 @@ var Identity = class {
4926
5844
 
4927
5845
  // src/server/service/bot/model.ts
4928
5846
  var Model = class {
4929
- constructor(botId, store, resolve6) {
5847
+ constructor(botId, store, resolve5) {
4930
5848
  this.botId = botId;
4931
5849
  this.store = store;
4932
- this.resolve = resolve6;
5850
+ this.resolve = resolve5;
4933
5851
  }
4934
5852
  current() {
4935
5853
  const bot = this.store.getBot(this.botId);
@@ -4950,43 +5868,86 @@ var Model = class {
4950
5868
  }
4951
5869
  };
4952
5870
 
5871
+ // src/server/service/bot/workspace.ts
5872
+ import { posix } from "node:path";
5873
+
4953
5874
  // src/server/infra/files/workspace.ts
4954
- import { mkdir as mkdir3, writeFile } from "node:fs/promises";
4955
- import { join as join4 } from "node:path";
5875
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
5876
+ import { join as join6 } from "node:path";
4956
5877
  var LocalWorkspace = class {
4957
5878
  constructor(cwd) {
4958
5879
  this.cwd = cwd;
4959
5880
  }
4960
5881
  get tempDir() {
4961
- return join4(this.cwd, "temp");
5882
+ return join6(this.cwd, "temp");
4962
5883
  }
4963
5884
  async prepare() {
4964
- await mkdir3(this.tempDir, { recursive: true, mode: 448 });
5885
+ await mkdir4(this.tempDir, { recursive: true, mode: 448 });
4965
5886
  }
4966
5887
  async writeTempFile(name2, content2, signal) {
4967
5888
  await this.prepare();
4968
- const path = join4(this.tempDir, name2);
4969
- await writeFile(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
5889
+ const path = join6(this.tempDir, name2);
5890
+ await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
4970
5891
  return path;
4971
5892
  }
4972
5893
  };
4973
5894
 
4974
5895
  // src/server/service/bot/workspace.ts
5896
+ var MAX_FILE = 10 * 1024 * 1024;
4975
5897
  var Workspace = class {
4976
- local;
4977
- constructor(cwd) {
5898
+ constructor(cwd, current, home) {
5899
+ this.current = current;
5900
+ this.home = home;
4978
5901
  this.local = new LocalWorkspace(cwd);
4979
5902
  }
5903
+ local;
4980
5904
  prepare() {
4981
5905
  return this.local.prepare();
4982
5906
  }
4983
- writeTempFile(name2, content2, signal) {
4984
- return this.local.writeTempFile(name2, content2, signal);
5907
+ /** A file in the temporary directory of the environment the Bot works in, by the path it sees there. */
5908
+ async writeTempFile(name2, content2, signal) {
5909
+ const env = this.current();
5910
+ if (env.kind === "host") return this.local.writeTempFile(name2, content2, signal);
5911
+ const path = `${env.cwd}/temp/${name2}`;
5912
+ await env.writeFile(path, content2);
5913
+ return path;
5914
+ }
5915
+ /** Puts a chat file into the Bot's environment: at `path`, or in temp/files/ under its own name (with part of its
5916
+ * id when another file has that name). Returns where it went. */
5917
+ async put(name2, id, data, path) {
5918
+ const env = this.current();
5919
+ let target2 = path ? posix.resolve(env.cwd, path) : `${env.cwd}/temp/files/${plain(name2)}`;
5920
+ if (!path && await env.exists(target2)) {
5921
+ const dot = plain(name2).lastIndexOf("."), stem = dot > 0 ? plain(name2).slice(0, dot) : plain(name2), extension = dot > 0 ? plain(name2).slice(dot) : "";
5922
+ target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${extension}`;
5923
+ }
5924
+ await env.mkdir(posix.dirname(target2));
5925
+ await env.writeFile(target2, data);
5926
+ return target2;
5927
+ }
5928
+ /** The files a Bot attaches to a message, read from its environment: paths there, relative to its directory. */
5929
+ async readOutgoing(items, signal) {
5930
+ if (items.length > 5) throw new ChatError(400, "Too many attachments");
5931
+ const env = this.current(), files = [];
5932
+ for (const item of items) {
5933
+ signal?.throwIfAborted();
5934
+ const path = posix.resolve(env.cwd, item.path), size = await env.fileSize(path);
5935
+ if (size === null) throw new ChatError(400, `No file at ${path} in your environment`);
5936
+ if (!size || size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
5937
+ const data = await env.readFile(path);
5938
+ if (data.length !== size) throw new ChatError(409, "Attachment changed while reading");
5939
+ files.push({ name: item.name, mimeType: "application/octet-stream", data });
5940
+ }
5941
+ return files;
4985
5942
  }
4986
5943
  location() {
4987
5944
  return { type: "local", cwd: this.local.cwd, tempDir: this.local.tempDir };
4988
5945
  }
4989
5946
  };
5947
+ function plain(name2) {
5948
+ const cleaned = name2.replace(/[/\\\0]/g, "_").trim();
5949
+ return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
5950
+ }
4990
5951
 
4991
5952
  // src/server/service/bot/state.ts
4992
5953
  var BotState = class {
@@ -5054,20 +6015,20 @@ ${content(block.result.content, heading + "#")}`;
5054
6015
  function formatHistory(result, format) {
5055
6016
  if (format === "json") return JSON.stringify(result, null, 2);
5056
6017
  const sections = [`# History (${result.mode})`];
5057
- for (const record2 of result.records) {
5058
- sections.push(`## #${record2.id} \xB7 ${record2.role}`, `Time: ${new Date(record2.timestamp).toISOString()}`);
5059
- if (record2.model) sections.push(`Model:
6018
+ for (const record3 of result.records) {
6019
+ sections.push(`## #${record3.id} \xB7 ${record3.role}`, `Time: ${new Date(record3.timestamp).toISOString()}`);
6020
+ if (record3.model) sections.push(`Model:
5060
6021
 
5061
- ${jsonBlock(record2.model)}`);
5062
- if ("preview" in record2) {
5063
- sections.push(fence(record2.preview));
6022
+ ${jsonBlock(record3.model)}`);
6023
+ if ("preview" in record3) {
6024
+ sections.push(fence(record3.preview));
5064
6025
  continue;
5065
6026
  }
5066
- sections.push(content(record2.message.content));
5067
- if (record2.message.tools !== void 0) sections.push("### Tool definitions", jsonBlock(record2.message.tools));
5068
- if (record2.message.tools_removed !== void 0) sections.push("### Tools removed", jsonBlock(record2.message.tools_removed));
5069
- if (record2.message.stop_reason !== void 0) sections.push("### Stop reason", fence(record2.message.stop_reason));
5070
- if (record2.message.error !== void 0) sections.push("### Error", fence(record2.message.error));
6027
+ sections.push(content(record3.message.content));
6028
+ if (record3.message.tools !== void 0) sections.push("### Tool definitions", jsonBlock(record3.message.tools));
6029
+ if (record3.message.tools_removed !== void 0) sections.push("### Tools removed", jsonBlock(record3.message.tools_removed));
6030
+ if (record3.message.stop_reason !== void 0) sections.push("### Stop reason", fence(record3.message.stop_reason));
6031
+ if (record3.message.error !== void 0) sections.push("### Error", fence(record3.message.error));
5071
6032
  }
5072
6033
  if (result.missingIds.length) sections.push(`## Missing IDs
5073
6034
 
@@ -5093,6 +6054,38 @@ async function readHistory(memory, workspace, input, signal) {
5093
6054
  return workspace.writeTempFile(name2, rendered, signal);
5094
6055
  }
5095
6056
 
6057
+ // src/server/service/bot/file-view.ts
6058
+ var TEXT_LIMIT = 64 * 1024;
6059
+ var PICTURE_LIMIT = 4 * 1024 * 1024;
6060
+ function viewFile(file, data) {
6061
+ const head = `${file.name} (${file.mimeType || "application/octet-stream"}, ${byteSize(file.size)})`;
6062
+ const picture = pictureType(data);
6063
+ if (picture) {
6064
+ if (data.length > PICTURE_LIMIT) throw new ChatError(400, "This picture is over 4 MB; use files.get and look at a smaller copy");
6065
+ return { kind: "media", text: head, images: [{ data: data.toString("base64"), mimeType: picture }] };
6066
+ }
6067
+ const text3 = data.length <= TEXT_LIMIT ? asText(data) : null;
6068
+ if (text3 !== null) return `${head}
6069
+
6070
+ ${text3}`;
6071
+ throw new ChatError(400, "files.view shows pictures and text files up to 64 KB; use files.get to work on this file with your tools");
6072
+ }
6073
+ function pictureType(data) {
6074
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
6075
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
6076
+ if (data.length >= 6 && /^GIF8[79]a$/.test(data.subarray(0, 6).toString("latin1"))) return "image/gif";
6077
+ if (data.length >= 12 && data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
6078
+ return null;
6079
+ }
6080
+ function asText(data) {
6081
+ if (data.includes(0)) return null;
6082
+ try {
6083
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
6084
+ } catch {
6085
+ return null;
6086
+ }
6087
+ }
6088
+
5096
6089
  // src/server/service/bot/chat-views.ts
5097
6090
  var iso = (time) => new Date(time).toISOString();
5098
6091
  var name = (chats) => (memberId) => chats.member(memberId)?.name ?? memberId;
@@ -5318,7 +6311,8 @@ var ChatTools = class {
5318
6311
  return { messages: await this.rows(page, signal), ...messages.length > limit ? { hasMore: true } : {} };
5319
6312
  }
5320
6313
  async rows(messages, signal) {
5321
- return Promise.all(messages.map(async (message) => messageRow(this.chats, this.bot, message, await this.scope.files.paths(this.bot, message, signal))));
6314
+ signal?.throwIfAborted();
6315
+ return messages.map((message) => messageRow(this.chats, this.bot, message, listing(message)));
5322
6316
  }
5323
6317
  search(input) {
5324
6318
  const found = input.chatId === void 0 ? this.chats.searchAll(this.bot, input.query, input.limit ?? 30) : this.chats.search(this.bot, this.chat(input.chatId).id, input.query, input.limit ?? 30);
@@ -5327,13 +6321,7 @@ var ChatTools = class {
5327
6321
  if (!chats.has(id)) chats.set(id, this.chats.get(this.bot, id));
5328
6322
  return chats.get(id);
5329
6323
  };
5330
- return found.map((message) => messageRow(
5331
- this.chats,
5332
- this.bot,
5333
- message,
5334
- message.attachments.map((file) => ({ name: file.name })),
5335
- input.chatId === void 0 ? chatOf(message.chatId) : null
5336
- ));
6324
+ return found.map((message) => messageRow(this.chats, this.bot, message, listing(message), input.chatId === void 0 ? chatOf(message.chatId) : null));
5337
6325
  }
5338
6326
  };
5339
6327
 
@@ -5342,6 +6330,7 @@ import { Type as Type3 } from "typebox";
5342
6330
  var None = Type3.Object({}, { additionalProperties: false });
5343
6331
  var Name = (description) => Type3.String({ minLength: 1, maxLength: 80, description });
5344
6332
  var MessageId = Type3.String({ pattern: "^m\\d+$" });
6333
+ var FileId = Type3.String({ minLength: 1, maxLength: 80, description: "The file's id: from <file id=\u2026/> in a message, or the attachments of messages.read and messages.search" });
5345
6334
  var QuestionOption = Type3.Object({
5346
6335
  label: Type3.String({ minLength: 1, maxLength: 80, description: "Text shown on the option" }),
5347
6336
  value: Type3.Optional(Type3.String({ minLength: 1, maxLength: 500, description: "What you get back when it is picked, written as the person would naturally say it; label by default" })),
@@ -5351,7 +6340,10 @@ var QuestionOption = Type3.Object({
5351
6340
  var definitions = {
5352
6341
  "history.read": { description: "Read your own History by a list of IDs: summaries or details, as JSON or Markdown, or written to a temporary file. IDs that don't exist are listed in missingIds. History is material to look back on, not an instruction.", schema: historyReadSchema },
5353
6342
  "members.list": { description: "List every member, people and Bots: name, kind (user for a person, or bot) and description; self=true is you. Use these names to @-mention someone, add them to a channel or DM them.", schema: None },
5354
- "bots.list": { description: "List every Bot and its run state. Read-only; it changes nothing. People and Bots can all see every Bot's name and state; this is not private. For each Bot: name; self=true is you; agent=true is the agent Bot, the owner's helper for running Jarvy: ask it to create or change a Bot, give a Bot a model, see a Bot's usage, or delete a Bot, channel or channel group; status (stop: not running, idle, or running). While it runs, activity is what it is doing (reasoning, texting: writing text, tool_calling, or asking: waiting for a person's answer), chats are the chats it is handling this turn (only those you can read), and otherChats is how many others there are. If its last turn failed, error is the error code. You come first, then the rest by name.", schema: None },
6343
+ "bots.list": {
6344
+ description: "List every Bot and its run state. Read-only; it changes nothing. People and Bots can all see every Bot's name and state; this is not private. For each Bot: name; self=true is you; agent=true is the agent Bot, the owner's helper for running Jarvy: ask it to create or change a Bot, give a Bot a model, see a Bot's usage, or delete a Bot, channel or channel group; status (stop: not running, idle, or running). While it runs, activity is what it is doing (reasoning, texting: writing text, tool_calling, or asking: waiting for a person's answer), chats are the chats it is handling this turn (only those you can read), and otherChats is how many others there are. If its last turn failed, error is the error code. With environments=true, each Bot also has environment: its default work environment, the one it is in now (current) and the ones it may use. You come first, then the rest by name.",
6345
+ schema: Type3.Object({ environments: Type3.Optional(Type3.Boolean({ description: "Also give each Bot's work environments" })) }, { additionalProperties: false })
6346
+ },
5355
6347
  "chats.list": { description: "List the chats you are in: DMs, channels and the threads you follow. Each has its type, title (DMs also give the other member's name in with), channel group (section), topic, your unread count and whether you subscribe; threads also give their channel and status.", schema: None },
5356
6348
  "chats.info": {
5357
6349
  description: "Details of one chat: members, the channel topic, the full notice or thread summary, your unread count and read position, and whether you subscribe; threads also give their title, status, channel and source message. Reading a notice or summary counts as having seen that version.",
@@ -5401,7 +6393,7 @@ var definitions = {
5401
6393
  schema: Type3.Object({ section: Type3.String({ minLength: 1, maxLength: 80 }), name: Name("New name") }, { additionalProperties: false })
5402
6394
  },
5403
6395
  "messages.read": {
5404
- description: "Read messages and the paths of the attachments you can read. Three ways: \u2460 chatId + after: read on from that sequence number (from the start by default); \u2461 around with a message ID: that message and context messages on each side (5 by default), to see the context; this doesn't move your read position; \u2462 unread=true: your unread messages, in one chat with chatId, or in every chat (grouped by chat) without it. \u2460 and \u2462 move your read position to the last message read; hasMore=true means there is more, so call again.",
6396
+ description: "Read messages; their files are listed by id, name, type and size, for files.get and files.view. Three ways: \u2460 chatId + after: read on from that sequence number (from the start by default); \u2461 around with a message ID: that message and context messages on each side (5 by default), to see the context; this doesn't move your read position; \u2462 unread=true: your unread messages, in one chat with chatId, or in every chat (grouped by chat) without it. \u2460 and \u2462 move your read position to the last message read; hasMore=true means there is more, so call again.",
5405
6397
  schema: Type3.Object({
5406
6398
  chatId: Type3.Optional(Type3.String()),
5407
6399
  after: Type3.Optional(Type3.Integer({ minimum: 0 })),
@@ -5412,9 +6404,22 @@ var definitions = {
5412
6404
  }, { additionalProperties: false })
5413
6405
  },
5414
6406
  "messages.search": {
5415
- description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Use messages.read to read attachments.",
6407
+ description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read.",
5416
6408
  schema: Type3.Object({ chatId: Type3.Optional(Type3.String()), query: Type3.String({ minLength: 1, maxLength: 500 }), limit: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 100 })) }, { additionalProperties: false })
5417
6409
  },
6410
+ "files.get": {
6411
+ description: "Put a file from a message into your environment, to work on it with your tools; the result has its path there. It goes to temp/files/ under its own name unless you give a path.",
6412
+ schema: Type3.Object({ id: FileId, path: Type3.Optional(Type3.String({ minLength: 1, maxLength: 1e3, description: "Where to put it instead: absolute, or relative to your directory" })) }, { additionalProperties: false })
6413
+ },
6414
+ "environments.list": { description: "List the work environments you may use: your sandbox, your default and the ones your owner granted you. current is the id of the one you are in. Each has its id, name, the directory your file and command tools start in there, default=true for your default, and state for a sandbox (it starts when you switch to it).", schema: None },
6415
+ "environments.switch": {
6416
+ description: "Move your file and command tools (bash, read, write, edit) to another work environment from environments.list, like cd one level up: you stay there, across turns, until you switch back. The result has your directory there and what you can do there. Commands already running finish where they started.",
6417
+ schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
6418
+ },
6419
+ "files.view": {
6420
+ description: "Look at a file from a message without putting it anywhere: a picture (PNG, JPEG, GIF or WebP, up to 4 MB) is shown to you, and a text file up to 64 KB comes back as text. Anything else needs files.get.",
6421
+ schema: Type3.Object({ id: FileId }, { additionalProperties: false })
6422
+ },
5418
6423
  "messages.react": {
5419
6424
  description: `Add or remove an emoji reaction on a message (people and Bots can both react). on=false removes it. Giving an emoji the message already has from you removes it instead (and the other way round); marked or unmarked and note in the result say which happened. Use this toggle to signal states such as "got it", "on it" and "done". Only the message's author is told: if they are running, it joins their current turn without interrupting; if they are idle, it starts a turn.`,
5420
6425
  schema: Type3.Object({ messageId: MessageId, emoji: Type3.String({ minLength: 1, maxLength: 32, description: "A single emoji, such as \u{1F44D}" }), on: Type3.Optional(Type3.Boolean()) }, { additionalProperties: false })
@@ -5431,7 +6436,7 @@ var definitions = {
5431
6436
  }, { additionalProperties: false })
5432
6437
  },
5433
6438
  "messages.send": {
5434
- description: "Send a message, with files from the tool host if you like (path and name). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
6439
+ description: "Send a message, with files from your environment if you like (path there, absolute or relative to your directory, and the name people see). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
5435
6440
  schema: Type3.Object({
5436
6441
  chatId: Type3.Optional(Type3.String()),
5437
6442
  to: Type3.Optional(Type3.String({ minLength: 1, maxLength: 80 })),
@@ -5445,7 +6450,7 @@ var definitions = {
5445
6450
  };
5446
6451
 
5447
6452
  // src/server/service/bot/roster.ts
5448
- function botRows(chats, viewerId, entries) {
6453
+ function botRows(chats, viewerId, entries, environment) {
5449
6454
  const rows2 = entries.map((entry) => {
5450
6455
  const running = entry.status.status === "running";
5451
6456
  const visible = [];
@@ -5463,7 +6468,8 @@ function botRows(chats, viewerId, entries) {
5463
6468
  ...running && entry.status.activity ? { activity: entry.status.activity } : {},
5464
6469
  ...visible.length ? { chats: visible } : {},
5465
6470
  ...hidden ? { otherChats: hidden } : {},
5466
- ...entry.errorCode ? { error: entry.errorCode } : {}
6471
+ ...entry.errorCode ? { error: entry.errorCode } : {},
6472
+ ...environment ? { environment: environment(entry.id) } : {}
5467
6473
  } };
5468
6474
  });
5469
6475
  rows2.sort((a, b) => Number(b.self) - Number(a.self) || a.row.name.localeCompare(b.row.name));
@@ -5500,8 +6506,9 @@ var AgentTools = class {
5500
6506
  case "models.list":
5501
6507
  return this.ops.models();
5502
6508
  case "bots.create": {
5503
- const bot = await this.ops.create(args);
5504
- return { name: bot.name, model: bot.connectionId ? this.ops.modelLabel(bot) : null, thinking: bot.thinkingLevel };
6509
+ const { environment, ...settings } = args;
6510
+ const bot = await this.ops.create({ ...settings, ...environment ? { environment: environment === "sandbox" ? "computer" : "host" } : {} });
6511
+ return { name: bot.name, model: bot.connectionId ? this.ops.modelLabel(bot) : null, thinking: bot.thinkingLevel, environment: this.ops.environment(bot.id) === "computer" ? "sandbox" : "host" };
5505
6512
  }
5506
6513
  case "bots.update": {
5507
6514
  const { bot, ...settings } = args;
@@ -5571,6 +6578,9 @@ var Settings = {
5571
6578
  model: Type4.Optional(Model2),
5572
6579
  thinking: Type4.Optional(Thinking)
5573
6580
  };
6581
+ var Environment = Type4.Union([Type4.Literal("sandbox"), Type4.Literal("host")], {
6582
+ description: "Where its file and command tools run by default: sandbox, a Linux container that Jarvy makes and only it uses (the default when Docker is available), or host, the owner's computer, where you work too"
6583
+ });
5574
6584
  var Confirm = {
5575
6585
  askIn: Type4.String({ description: "ID of a chat the owner is in, where the confirmation card goes; usually the chat you were asked in" }),
5576
6586
  question: Type4.String({ minLength: 1, maxLength: 500, description: "The card's question, in the owner's language: what will be deleted and what goes with it" }),
@@ -5582,7 +6592,7 @@ var agentDefinitions = {
5582
6592
  "models.list": { description: "List the models you can give a Bot: each connection with its models and their thinking levels. Only the owner adds connections, keys and logins, in Settings.", schema: None2 },
5583
6593
  "bots.create": {
5584
6594
  description: "Create a Bot. Give it a model so it can work; without one it waits until it gets one. It starts when a message first arrives for it. bots.update changes everything later.",
5585
- schema: Type4.Object({ name: Name2, ...Settings }, { additionalProperties: false })
6595
+ schema: Type4.Object({ name: Name2, ...Settings, environment: Type4.Optional(Environment) }, { additionalProperties: false })
5586
6596
  },
5587
6597
  "bots.update": {
5588
6598
  description: "Change a Bot's name, description, persona, model or thinking; give only what changes. Works for any Bot, yourself included. The Bot is told what changed with its next batch, in its DM with you.",
@@ -5676,7 +6686,16 @@ var Tools = class {
5676
6686
  case "members.list":
5677
6687
  return this.tools.membersList();
5678
6688
  case "bots.list":
5679
- return botRows(this.scope.chats, this.scope.botId, this.scope.roster());
6689
+ return botRows(
6690
+ this.scope.chats,
6691
+ this.scope.botId,
6692
+ this.scope.roster(),
6693
+ args.environments ? (id) => this.scope.places.summary(id) : void 0
6694
+ );
6695
+ case "environments.list":
6696
+ return this.scope.places.list();
6697
+ case "environments.switch":
6698
+ return this.scope.places.switch(args.id);
5680
6699
  case "chats.create":
5681
6700
  return this.tools.create(args);
5682
6701
  case "chats.update":
@@ -5710,6 +6729,16 @@ var Tools = class {
5710
6729
  }
5711
6730
  case "messages.read":
5712
6731
  return this.tools.read(args, signal);
6732
+ case "files.get": {
6733
+ const { id, path } = args;
6734
+ const { file, data } = this.scope.files.open(this.scope.botId, id);
6735
+ signal?.throwIfAborted();
6736
+ return { path: await this.scope.workspace.put(file.name, id, data, path), name: file.name, type: file.mimeType, size: byteSize(file.size) };
6737
+ }
6738
+ case "files.view": {
6739
+ const { file, data } = this.scope.files.open(this.scope.botId, args.id);
6740
+ return viewFile(file, data);
6741
+ }
5713
6742
  case "messages.search":
5714
6743
  return this.tools.search(args);
5715
6744
  case "messages.send": {
@@ -5764,10 +6793,7 @@ var Tools = class {
5764
6793
  };
5765
6794
 
5766
6795
  // src/server/service/bot/inbox.ts
5767
- import { join as join5, dirname as dirname4 } from "node:path";
5768
-
5769
- // src/server/service/chat/envelope.ts
5770
- import { isAbsolute } from "node:path";
6796
+ import { join as join7, dirname as dirname6 } from "node:path";
5771
6797
 
5772
6798
  // src/server/shared/agent.ts
5773
6799
  var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
@@ -5788,13 +6814,14 @@ function remember(map, id, definition) {
5788
6814
  if (old !== void 0 && old !== definition) throw new Error("Conflicting source metadata");
5789
6815
  map.set(id, definition);
5790
6816
  }
6817
+ var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
5791
6818
  var iso2 = (time) => new Date(time).toISOString().replace(".000Z", "Z");
5792
6819
  var welcome = (owner, language) => `${owner} has just set up Jarvy and picked your model. You are their agent Bot. In this DM, welcome them in ${WELCOME_LANGUAGES[language]}, the language of their interface: say in a few sentences what Jarvy is and how you can help, such as building a team of Bots for their work and running Jarvy for them. Then ask what they want to get done: a software project, writing, research or something else.`;
5793
- function encodeNoticeBatch(botId, notices) {
6820
+ function encodeNoticeBatch(botId, notices, environment) {
5794
6821
  if (!notices.length) throw new Error("Empty notice batch");
5795
6822
  const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map();
5796
6823
  const list = noticeTail(notices, chats, members);
5797
- return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}</notice_batch>`;
6824
+ return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${environmentLine(environment)}</notice_batch>`;
5798
6825
  }
5799
6826
  function noticeTail(notices, chats, members) {
5800
6827
  for (const notice of notices) {
@@ -5805,9 +6832,11 @@ function noticeTail(notices, chats, members) {
5805
6832
  for (const notice of notices) if (notice.kind === "note" && (latest.get(notice.chat.id)?.version ?? 0) < notice.version) latest.set(notice.chat.id, notice);
5806
6833
  const updates = [...latest.values()].map((note) => `<${note.chat.type === "thread" ? "summary" : "notice"}${attribute("chat", note.chat.id)}${attribute("version", String(note.version))}${attribute("by", note.actor.id)}${attribute("at", iso2(note.createdAt))}/>`);
5807
6834
  const marks = notices.flatMap((notice) => notice.kind !== "reaction" ? [] : [`<reaction${attribute("message", notice.message.id)}${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("emoji", notice.emoji)}${attribute("at", iso2(notice.createdAt))}>${escape(notice.message.text)}</reaction>`]);
5808
- const changes = notices.flatMap((notice) => notice.kind !== "change" ? [] : [`<change${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("what", notice.what)}${attribute("at", iso2(notice.createdAt))}${notice.value === null ? "/>" : `>${escape(notice.value)}</change>`}`]);
6835
+ const changed = notices.filter((notice) => notice.kind === "change");
6836
+ const changes = changed.map((notice) => `<change${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("what", notice.what)}${attribute("at", iso2(notice.createdAt))}${notice.value === null ? "/>" : `>${escape(notice.value)}</change>`}`);
6837
+ const byAgent = changed.some((notice) => notice.actor.kind === "bot"), byOwner = changed.some((notice) => notice.actor.kind === "user");
5809
6838
  const onboard = notices.flatMap((notice) => notice.kind !== "onboard" ? [] : [`<onboard${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("at", iso2(notice.createdAt))}><note>${escape(welcome(notice.actor.name, notice.language))}</note></onboard>`]).slice(-1);
5810
- return (updates.length ? `<updates>${updates.join("")}</updates>` : "") + (marks.length ? `<notices>${marks.join("")}<note>These are reactions to your messages. Act on them if you need to; no reply is required.</note></notices>` : "") + (changes.length ? `<changes>${changes.join("")}<note>The agent Bot changed your settings, and the changes already apply. A persona change is already in your instructions. If something is unclear, ask it in that DM.</note></changes>` : "") + onboard.join("");
6839
+ return (updates.length ? `<updates>${updates.join("")}</updates>` : "") + (marks.length ? `<notices>${marks.join("")}<note>These are reactions to your messages. Act on them if you need to; no reply is required.</note></notices>` : "") + (changes.length ? `<changes>${changes.join("")}` + (byAgent ? "<note>The agent Bot changed your settings, and the changes already apply. A persona change is already in your instructions. If something is unclear, ask it in that DM.</note>" : "") + (byOwner ? "<note>Your owner changed where you work, and it already applies: environment is where your file and command tools run now, and access is what environments.switch can take you to. If you were in the middle of something, carry on where you are now.</note>" : "") + "</changes>" : "") + onboard.join("");
5811
6840
  }
5812
6841
  function encodeChatBatch(botId, messages, digest, notices) {
5813
6842
  if (!messages.length) throw new Error("Empty chat batch");
@@ -5818,30 +6847,30 @@ function encodeChatBatch(botId, messages, digest, notices) {
5818
6847
  const { chat } = message;
5819
6848
  remember(chats, chat.id, `<chat${attribute("id", chat.id)}${attribute("type", chat.type)}${chat.title ? attribute("title", chat.title) : ""}${chat.section ? attribute("section", chat.section) : ""}${chat.parent ? attribute("parent", chat.parent) : ""}${chat.root ? attribute("root", chat.root) : ""}${chat.status ? attribute("status", chat.status) : ""}/>`);
5820
6849
  remember(members, message.sender.id, `<member${attribute("id", message.sender.id)}${attribute("name", message.sender.name)}${attribute("kind", message.sender.kind)}/>`);
5821
- const at = new Date(message.createdAt).toISOString().replace(".000Z", "Z");
5822
- const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at);
6850
+ const at2 = new Date(message.createdAt).toISOString().replace(".000Z", "Z");
6851
+ const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at2);
5823
6852
  const reply = message.replyTo;
5824
6853
  if (reply) remember(members, reply.sender.id, `<member${attribute("id", reply.sender.id)}${attribute("name", reply.sender.name)}${attribute("kind", reply.sender.kind)}/>`);
5825
6854
  const quote = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
5826
- const files = message.attachments.map((file) => {
5827
- if (!isAbsolute(file.path)) throw new Error("Attachment path must be absolute");
5828
- return `<file${attribute("path", file.path)}${attribute("name", file.name)}/>`;
5829
- }).join("");
6855
+ const files = message.attachments.map((file) => `<file${attribute("id", file.id)}${attribute("name", file.name)}${attribute("type", file.type)}${attribute("size", file.size)}/>`).join("");
5830
6856
  return `<message${attributes}>${quote}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
5831
6857
  });
5832
6858
  const ref = (key, item) => attribute(key, item.chatId) + (chats.has(item.chatId) ? "" : attribute("title", item.title) + attribute("type", item.type));
5833
6859
  let tail = notices?.length ? noticeTail(notices, chats, members) : "";
5834
6860
  if (digest?.unread.length) tail += `<unread${attribute("total", String(digest.unread.reduce((sum, item) => sum + item.count, 0)))}>${digest.unread.map((item) => `<chat${ref("id", item)}${attribute("count", String(item.count))}/>`).join("")}</unread>`;
6861
+ tail += environmentLine(digest?.environment);
5835
6862
  return `<chat_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${encoded.join("")}${tail}</chat_batch>`;
5836
6863
  }
5837
6864
 
5838
6865
  // src/server/service/bot/inbox.ts
5839
6866
  var BotInbox = class {
5840
- constructor(botId, repository, chats, files) {
6867
+ /** `where` is the work environment the Bot is in now, named at the end of each batch (A50). */
6868
+ constructor(botId, repository, chats, files, where2) {
5841
6869
  this.botId = botId;
5842
6870
  this.repository = repository;
5843
6871
  this.chats = chats;
5844
6872
  this.files = files;
6873
+ this.where = where2;
5845
6874
  }
5846
6875
  /** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. A digest delivery
5847
6876
  * (a change the agent Bot made, A48) is not work of its own: it rides along with the next batch. */
@@ -5856,7 +6885,7 @@ var BotInbox = class {
5856
6885
  }
5857
6886
  reference(workspace) {
5858
6887
  if (this.repository.hasUncertainModel(this.botId)) throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.");
5859
- return this.repository.ensureSession(this.botId, join5(dirname4(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
6888
+ return this.repository.ensureSession(this.botId, join7(dirname6(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
5860
6889
  }
5861
6890
  /** XML for the claimed messages plus the tail (other deliveries not told yet, unread); `notices` are told once
5862
6891
  * the batch is submitted. */
@@ -5867,7 +6896,7 @@ var BotInbox = class {
5867
6896
  }
5868
6897
  const inputs = await this.files.envelope(this.botId, batch.inputs.map((input) => input.messageId));
5869
6898
  for (const input of batch.inputs) if (!this.chats.get(this.botId, input.chatId)) throw new RuntimeFault("chat_access_changed", "Chat access has changed.");
5870
- const digest = this.chats.channels.digest(this.botId);
6899
+ const digest = { ...this.chats.channels.digest(this.botId), ...this.where ? { environment: this.where() } : {} };
5871
6900
  const pending = this.envelope(this.chats.notices(this.botId));
5872
6901
  return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0), notices: pending.map((notice) => notice.id) };
5873
6902
  }
@@ -5894,7 +6923,7 @@ var BotInbox = class {
5894
6923
  notices() {
5895
6924
  const pending = this.chats.notices(this.botId);
5896
6925
  if (!pending.length) return null;
5897
- return { xml: encodeNoticeBatch(this.botId, this.envelope(pending)), ids: pending.map((notice) => notice.id) };
6926
+ return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.()), ids: pending.map((notice) => notice.id) };
5898
6927
  }
5899
6928
  /** These notices reached the model. */
5900
6929
  tell(ids) {
@@ -5927,8 +6956,8 @@ var AgentRuntime = class {
5927
6956
  return this.runId;
5928
6957
  }
5929
6958
  update(patch) {
5930
- const context = this.session?.context;
5931
- this.status = { ...this.status, ...patch, context: context ? { tokens: context.tokens, limit: context.contextWindow } : null };
6959
+ const context2 = this.session?.context;
6960
+ this.status = { ...this.status, ...patch, context: context2 ? { tokens: context2.tokens, limit: context2.contextWindow } : null };
5932
6961
  this.setup?.onStatus(this.status);
5933
6962
  }
5934
6963
  activity(phase) {
@@ -5939,9 +6968,9 @@ var AgentRuntime = class {
5939
6968
  if (this.prompting) this.update({ status: "running", activity: on ? "asking" : "tool_calling" });
5940
6969
  }
5941
6970
  fail(error) {
5942
- const fault = publicFault(error);
5943
- this.update({ status: this.session ? "idle" : "stop", activity: null, error: { code: fault.code, message: fault.message, ...fault.params ? { params: fault.params } : {} } });
5944
- console.error("[bot] execution failed", { botId: this.inbox.botId, runId: this.runId, code: fault.code, ...error instanceof RuntimeFault ? {} : { error } });
6971
+ const fault2 = publicFault(error);
6972
+ this.update({ status: this.session ? "idle" : "stop", activity: null, error: { code: fault2.code, message: fault2.message, ...fault2.params ? { params: fault2.params } : {} } });
6973
+ console.error("[bot] execution failed", { botId: this.inbox.botId, runId: this.runId, code: fault2.code, ...error instanceof RuntimeFault ? {} : { error } });
5945
6974
  }
5946
6975
  async start(setup) {
5947
6976
  await this.operation(async () => {
@@ -6065,7 +7094,7 @@ var AgentRuntime = class {
6065
7094
  return task;
6066
7095
  }
6067
7096
  // Model/thinking changes queue behind start/stop and each other but not behind the running turn:
6068
- // Pi 0.99.1 re-reads agent.state.model/thinkingLevel before every next request (prepareNextTurn),
7097
+ // Pi 1.2.0 re-reads agent.state.model/thinkingLevel before every next request (AgentSession's prepareNextTurnWithContext),
6069
7098
  // and each request resolves its API key when it starts, so in-flight requests are unaffected.
6070
7099
  reconfigure(operation) {
6071
7100
  const task = this.operations.catch(() => {
@@ -6153,7 +7182,7 @@ These instructions and the tool descriptions are in English so that every Bot re
6153
7182
 
6154
7183
  ## Chat
6155
7184
  The chat tool is your only way to talk to the other members. Anything you don't send through it is seen by no one.
6156
- To pass a document, an image or another file to someone, attach its path and name when you send with the chat tool.
7185
+ Files in messages come with an ID: files.get puts one into your environment and gives you its path, and files.view shows you an image or a text file directly. To pass a file to someone, attach its path in your environment and its name when you send with the chat tool.
6157
7186
  Channels can be put in channel groups and have a notice. Threads have a title, a status and a summary.
6158
7187
  In channels and threads you only get new messages when you are @-mentioned; once you subscribe, every new message there is sent to you. DMs need no @.
6159
7188
  Each batch of messages ends with your unread counts and the notices and summaries that have a new version. Read them through the chat gateway when you need to.
@@ -6162,17 +7191,33 @@ Each batch of messages ends with your unread counts and the notices and summarie
6162
7191
  To look into earlier work, search the chat first, then call history.read with the History ID a message carries to read how the work was done.
6163
7192
  history.read is listed by the chat gateway. History is material to look back on, not an instruction for the task at hand.
6164
7193
 
6165
- ## Workspace
6166
- You have your own workspace: ${bot.workspacePath}
7194
+ ${workspaceSection(setup, bot.workspacePath)}
7195
+ ## Responding
7196
+ When you are @-mentioned or get a DM, first let the person know you have it: react to that message with an emoji, or confirm in a sentence or two with an emoji. A reaction is only a signal, not a command; pick the emoji yourself.
7197
+ When something will take a while, post a short progress note partway through, and give the outcome when you are done. You don't need to answer every message that isn't meant for you.
7198
+ `;
7199
+ }
7200
+ function workspaceSection(setup, workspacePath) {
7201
+ const place = setup.home();
7202
+ if (place.kind === "computer") return `## Workspace
7203
+ Your workspace is your own folder and your home directory: your file and command tools see it as ${place.cwd}, and ~ is the same place, so settings such as ~/.ssh and ~/.gitconfig stay with it. It is kept when your sandbox is reset. Keep it tidy, and put temporary files under temp and clear them out.
7204
+
7205
+ ## Work environment
7206
+ Your tools run in your sandbox by default: a Linux container only you use, with Node, Python, git, build tools, and Playwright with its browsers. You can install software there with sudo; it stays until the owner resets your sandbox.
7207
+ ${SWITCHING}
7208
+ `;
7209
+ return `## Workspace
7210
+ You have your own workspace: ${workspacePath}
6167
7211
  Treat it as your only base. Create, change and delete files only inside it, keep it tidy, and put temporary files under temp and clear them out.
6168
7212
  Leave everything else alone by default: other members' workspaces, live data directories (such as ~/.jarvy), main repository checkouts and system directories. Touch them only when the task itself needs it or the person in charge asks you to, and say in your reply what you changed.
6169
7213
  Shared directories and other people's files are read-only. Ask the person in charge before writing to them.
6170
7214
 
6171
- ## Responding
6172
- When you are @-mentioned or get a DM, first let the person know you have it: react to that message with an emoji, or confirm in a sentence or two with an emoji. A reaction is only a signal, not a command; pick the emoji yourself.
6173
- When something will take a while, post a short progress note partway through, and give the outcome when you are done. You don't need to answer every message that isn't meant for you.
7215
+ ## Work environment
7216
+ Your tools run on your owner's computer by default, the machine running Jarvy.
7217
+ ${SWITCHING}
6174
7218
  `;
6175
7219
  }
7220
+ var SWITCHING = "Each batch of messages ends with the environment you are in now. environments.list shows the ones you may use, and environments.switch moves your tools to another, like cd, until you switch back.";
6176
7221
 
6177
7222
  // src/server/shared/live.ts
6178
7223
  var LIVE_STEPS = 300;
@@ -6186,9 +7231,9 @@ function clip(text3, length) {
6186
7231
  return text3.length > length ? `${text3.slice(0, length - 1)}\u2026` : text3;
6187
7232
  }
6188
7233
  var field = (value2, length = LIVE_INPUT) => typeof value2 === "string" ? clip(value2, length) : void 0;
6189
- var record = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
7234
+ var record2 = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
6190
7235
  function liveInput(tool, args, chat) {
6191
- const values = record(args);
7236
+ const values = record2(args);
6192
7237
  switch (tool) {
6193
7238
  case "bash":
6194
7239
  return { command: field(values.command) };
@@ -6212,15 +7257,15 @@ function liveInput(tool, args, chat) {
6212
7257
  }
6213
7258
  function gateway(values, chat) {
6214
7259
  const action = values.action === "call" && typeof values.tool === "string" ? values.tool : typeof values.action === "string" ? values.action : "";
6215
- const inner = record(values.args), where = target(inner, chat);
6216
- if (action === "messages.send") return { action, chat: where, text: field(inner.message, LIVE_MESSAGE) };
7260
+ const inner = record2(values.args), where2 = target(inner, chat);
7261
+ if (action === "messages.send") return { action, chat: where2, text: field(inner.message, LIVE_MESSAGE) };
6217
7262
  if (action === "messages.ask") return {
6218
7263
  action,
6219
- chat: where,
7264
+ chat: where2,
6220
7265
  text: field(inner.question, LIVE_MESSAGE),
6221
- options: Array.isArray(inner.options) ? inner.options.map((option2) => field(record(option2).label) ?? "").filter(Boolean) : void 0
7266
+ options: Array.isArray(inner.options) ? inner.options.map((option2) => field(record2(option2).label) ?? "").filter(Boolean) : void 0
6222
7267
  };
6223
- return where ? { action, chat: where } : { action };
7268
+ return where2 ? { action, chat: where2 } : { action };
6224
7269
  }
6225
7270
  function target(inner, chat) {
6226
7271
  if (typeof inner.to === "string") return { id: null, type: "dm", title: clip(inner.to, 80) };
@@ -6440,7 +7485,7 @@ var AgentOps = class {
6440
7485
  agentId = AGENT_BOT_ID;
6441
7486
  /** A Bot by name, ignoring case and a leading @. */
6442
7487
  bot(name2) {
6443
- const bot = this.store.listBots().find((record2) => same2(name2, record2.name));
7488
+ const bot = this.store.listBots().find((record3) => same2(name2, record3.name));
6444
7489
  if (!bot) throw new ChatError(404, `No Bot named ${name2} (see bots.list)`);
6445
7490
  return bot;
6446
7491
  }
@@ -6467,6 +7512,10 @@ var AgentOps = class {
6467
7512
  if (!connectionId) throw new ChatError(400, "This Bot has no model yet; give model too");
6468
7513
  return { connectionId, modelId, ...input.thinking === void 0 ? {} : { thinkingLevel: input.thinking } };
6469
7514
  }
7515
+ /** Where a Bot works now: computer or host (A50). */
7516
+ environment(botId) {
7517
+ return this.bots.describe(botId).environment.home;
7518
+ }
6470
7519
  /** "model (connection)", as the agent and the changed Bot read it. */
6471
7520
  modelLabel(bot) {
6472
7521
  const connection = bot.connectionId ? this.store.getModelConnection(bot.connectionId) : null;
@@ -6475,7 +7524,7 @@ var AgentOps = class {
6475
7524
  }
6476
7525
  async create(input) {
6477
7526
  const selection = this.selection(null, input);
6478
- const created = this.bots.create(input.name);
7527
+ const created = this.bots.create(input.name, input.environment);
6479
7528
  if (input.description !== void 0 || input.persona !== void 0)
6480
7529
  this.bots.get(created.id).identity.update({ name: created.name, description: input.description ?? "", persona: input.persona ?? "" });
6481
7530
  if (selection) await this.bots.selectModel(created.id, selection);
@@ -6544,9 +7593,13 @@ var BotService = class {
6544
7593
  constructor(deps) {
6545
7594
  this.deps = deps;
6546
7595
  this.agent = new AgentOps(this, deps.store, deps.chats);
7596
+ this.computers = new Computers({ store: deps.store, changed: () => this.listChanged() });
7597
+ this.environments = new BotEnvironments(deps.store, this.computers);
6547
7598
  }
6548
7599
  bots = /* @__PURE__ */ new Map();
6549
7600
  lives = /* @__PURE__ */ new Map();
7601
+ computers;
7602
+ environments;
6550
7603
  closed = false;
6551
7604
  started = false;
6552
7605
  /** What only the agent Bot does (A48). */
@@ -6556,14 +7609,18 @@ var BotService = class {
6556
7609
  }
6557
7610
  get(id) {
6558
7611
  if (this.closed) throw new RuntimeFault("stopping", "Shutting down.", 503);
6559
- const record2 = this.deps.store.getBot(id);
6560
- if (!record2) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7612
+ const record3 = this.deps.store.getBot(id);
7613
+ if (!record3) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6561
7614
  let bot = this.bots.get(id);
6562
7615
  if (bot) return bot;
6563
7616
  const { store, chats, files, credentials } = this.deps, live = this.live(id);
6564
- const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
7617
+ const here = () => {
7618
+ const place = this.environments.here(id, record3.workspacePath);
7619
+ return { name: place.name, directory: place.directory };
7620
+ };
7621
+ const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
6565
7622
  store,
6566
- bot: record2,
7623
+ bot: record3,
6567
7624
  selection: model.selection,
6568
7625
  reference,
6569
7626
  credentials,
@@ -6571,10 +7628,14 @@ var BotService = class {
6571
7628
  systemPrompt: () => botPrompt(setup),
6572
7629
  onHistory: setup.onHistory,
6573
7630
  activity,
6574
- live: (event) => live.event(event)
7631
+ live: (event) => live.event(event),
7632
+ environment: () => this.environments.current(id, record3.workspacePath)
6575
7633
  }));
6576
- const workspace = new Workspace(record2.workspacePath);
6577
- const memory = new Memory(join6(dirname5(record2.workspacePath), "history.db"));
7634
+ const workspace = new Workspace(record3.workspacePath, () => this.environments.current(id, record3.workspacePath), () => {
7635
+ const place = this.environments.home(id, record3.workspacePath);
7636
+ return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
7637
+ });
7638
+ const memory = new Memory(join8(dirname7(record3.workspacePath), "history.db"));
6578
7639
  bot = new Bot(id, {
6579
7640
  identity: new Identity(id, store),
6580
7641
  model: new Model(id, store, resolveThinking),
@@ -6588,12 +7649,17 @@ var BotService = class {
6588
7649
  workspace,
6589
7650
  asking: (on) => runtime.waitingForUser(on),
6590
7651
  roster: () => this.roster(),
7652
+ places: {
7653
+ list: () => this.environments.list(id, record3.workspacePath),
7654
+ switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
7655
+ summary: (botId) => this.environments.summary(botId)
7656
+ },
6591
7657
  ...id === AGENT_BOT_ID ? { agent: this.agent } : {}
6592
7658
  }),
6593
7659
  agentRuntime: runtime,
6594
7660
  state: new BotState(() => this.deps.changed(id))
6595
7661
  }, { deliver: async (message) => {
6596
- const outgoing = await files.readOutgoing(record2.workspacePath, message.attachments ?? [], message.signal);
7662
+ const outgoing = await workspace.readOutgoing(message.attachments ?? [], message.signal);
6597
7663
  message.signal?.throwIfAborted();
6598
7664
  return chats.deliver(id, message.chatId, {
6599
7665
  content: message.content,
@@ -6609,9 +7675,14 @@ var BotService = class {
6609
7675
  this.bots.set(id, bot);
6610
7676
  return bot;
6611
7677
  }
6612
- details(record2) {
6613
- const model = record2.connectionId ? this.deps.store.getModelConnection(record2.connectionId)?.models.find((model2) => model2.id === record2.modelId) : void 0;
6614
- return { ...record2, effectiveThinkingLevel: model ? resolveThinking(model, record2.thinkingLevel) : null, agent: record2.id === AGENT_BOT_ID };
7678
+ details(record3) {
7679
+ const model = record3.connectionId ? this.deps.store.getModelConnection(record3.connectionId)?.models.find((model2) => model2.id === record3.modelId) : void 0;
7680
+ return {
7681
+ ...record3,
7682
+ effectiveThinkingLevel: model ? resolveThinking(model, record3.thinkingLevel) : null,
7683
+ agent: record3.id === AGENT_BOT_ID,
7684
+ environment: this.environments.placement(record3.id)
7685
+ };
6615
7686
  }
6616
7687
  owns(id) {
6617
7688
  return this.deps.store.getBot(id) !== null;
@@ -6634,15 +7705,58 @@ var BotService = class {
6634
7705
  return { id: chat.id, type: chat.type, title: other ? this.deps.chats.member(other)?.name ?? chat.title : chat.title };
6635
7706
  }
6636
7707
  list() {
6637
- return this.deps.store.listBots().map((record2) => this.details(record2));
6638
- }
6639
- create(name2) {
6640
- return this.details(this.deps.store.createBot(name2));
7708
+ return this.deps.store.listBots().map((record3) => this.details(record3));
7709
+ }
7710
+ /** A new Bot works in its sandbox when this machine has Docker, unless asked for the owner's computer (A50). */
7711
+ create(name2, environment) {
7712
+ const kind = environment ?? (this.computers.available ? "computer" : "host");
7713
+ if (kind === "computer" && !this.computers.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
7714
+ const record3 = this.deps.store.createBot(name2);
7715
+ this.environments.setHome(record3.id, kind, record3.workspacePath);
7716
+ return this.details(record3);
7717
+ }
7718
+ /** The owner moves a Bot to its sandbox or to their computer, as its default; the next tool call runs there. */
7719
+ setHome(id, kind) {
7720
+ const moved = this.environments.setHome(id, kind, this.record(id).workspacePath);
7721
+ if (moved) this.tellOwner(id, [{ what: "environment", value: where(moved) }], true);
7722
+ return this.describe(id);
7723
+ }
7724
+ /** The owner lets a Bot use their computer, or takes it back; taking it back while the Bot is there sends it home. */
7725
+ grantHost(id, granted) {
7726
+ const { changed, moved } = this.environments.grantHost(id, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
7727
+ const changes = changed ? [{ what: "access", value: `your owner's computer: ${granted ? "you may switch to it" : "taken back"}` }] : [];
7728
+ if (moved) changes.push({ what: "environment", value: where(moved) });
7729
+ if (changes.length) this.tellOwner(id, changes, !!moved);
7730
+ return this.describe(id);
7731
+ }
7732
+ /** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
7733
+ * @-mention does, so the Bot doesn't go on in the old place; a Bot not running, or a change of access alone, is
7734
+ * told with its next batch (fish m1012–m1015). */
7735
+ tellOwner(id, changes, move) {
7736
+ if (!this.record(id).connectionId) return;
7737
+ const owner = this.deps.store.userMemberId(), chatId = this.deps.chats.openDirect(owner, id).id;
7738
+ const urgency = move && this.bots.get(id)?.status().status === "running" ? urgencyOf("change", "owner") : "digest";
7739
+ for (const change of changes) this.deps.store.agent.notify({ botId: id, kind: "change", reason: "owner", urgency, chatId, actorId: owner, detail: JSON.stringify(change) });
7740
+ if (urgency === "interrupt") this.deliver(id, urgency);
7741
+ }
7742
+ record(id) {
7743
+ const record3 = this.deps.store.getBot(id);
7744
+ if (!record3) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7745
+ return record3;
7746
+ }
7747
+ /** Starting (or resetting, which starts afresh) returns at once: the Bot's state follows as it changes. */
7748
+ async computer(id, action) {
7749
+ this.describe(id);
7750
+ if (action === "stop") await this.computers.stop(id);
7751
+ else {
7752
+ if (action === "reset") await this.computers.reset(id);
7753
+ void this.computers.ensure(id).catch(() => {
7754
+ });
7755
+ }
7756
+ return this.describe(id);
6641
7757
  }
6642
7758
  describe(id) {
6643
- const record2 = this.deps.store.getBot(id);
6644
- if (!record2) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6645
- return this.details(record2);
7759
+ return this.details(this.record(id));
6646
7760
  }
6647
7761
  runs(id) {
6648
7762
  this.describe(id);
@@ -6669,9 +7783,9 @@ var BotService = class {
6669
7783
  if (this.closed || !this.deps.store.getBot(id)) return;
6670
7784
  const bot = this.get(id);
6671
7785
  void bot.deliver(urgency).catch((error) => {
6672
- const fault = publicFault(error);
6673
- bot.state.update({ ...bot.status(), error: { code: fault.code, message: fault.message, ...fault.params ? { params: fault.params } : {} } });
6674
- console.error("[bot] delivery failed", { botId: id, code: fault.code });
7786
+ const fault2 = publicFault(error);
7787
+ bot.state.update({ ...bot.status(), error: { code: fault2.code, message: fault2.message, ...fault2.params ? { params: fault2.params } : {} } });
7788
+ console.error("[bot] delivery failed", { botId: id, code: fault2.code });
6675
7789
  });
6676
7790
  }
6677
7791
  /** Picks up what is still waiting for a Bot (after a failure, a new model, or a restart), if anything. */
@@ -6685,21 +7799,22 @@ var BotService = class {
6685
7799
  start() {
6686
7800
  if (this.started) return;
6687
7801
  this.started = true;
7802
+ void this.computers.start();
6688
7803
  for (const { botId, urgency } of this.deps.store.runtime.waitingBots(this.deps.store.userMemberId())) this.deliver(botId, urgency);
6689
7804
  }
6690
7805
  /** Every Bot's run state, for `bots.list` (A43). A Bot not loaded in this process is stopped. */
6691
7806
  roster() {
6692
7807
  const { store } = this.deps;
6693
- return store.listBots().map((record2) => {
6694
- const bot = this.bots.get(record2.id), runId = bot?.agentRuntime.activeRunId ?? null;
7808
+ return store.listBots().map((record3) => {
7809
+ const bot = this.bots.get(record3.id), runId = bot?.agentRuntime.activeRunId ?? null;
6695
7810
  const status = bot?.status() ?? { status: "stop", activity: null, context: null, error: null };
6696
7811
  return {
6697
- id: record2.id,
6698
- name: record2.name,
6699
- agent: record2.id === AGENT_BOT_ID,
7812
+ id: record3.id,
7813
+ name: record3.name,
7814
+ agent: record3.id === AGENT_BOT_ID,
6700
7815
  status,
6701
7816
  runChatIds: status.status === "running" && runId ? store.runtime.inputs(runId).map((input) => input.chatId) : [],
6702
- errorCode: store.runtime.latestErrorCode(record2.id)
7817
+ errorCode: store.runtime.latestErrorCode(record3.id)
6703
7818
  };
6704
7819
  });
6705
7820
  }
@@ -6737,16 +7852,19 @@ var BotService = class {
6737
7852
  this.bots.delete(id);
6738
7853
  this.lives.get(id)?.close();
6739
7854
  this.lives.delete(id);
7855
+ await this.environments.release(id);
6740
7856
  if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6741
- if (!keepFiles) await rm2(dirname5(workspacePath), { recursive: true, force: true });
7857
+ if (!keepFiles) await rm3(dirname7(workspacePath), { recursive: true, force: true });
6742
7858
  }
6743
7859
  async close() {
6744
7860
  if (this.closed) return;
6745
7861
  this.closed = true;
6746
7862
  await Promise.all([...this.bots.values()].map((bot) => bot.stop()));
6747
7863
  for (const live of this.lives.values()) live.close();
7864
+ await this.environments.close();
6748
7865
  }
6749
7866
  };
7867
+ var where = (moved) => `${moved.to.name}, in ${moved.to.directory}`;
6750
7868
 
6751
7869
  // src/server/application.ts
6752
7870
  var Application = class _Application {
@@ -6768,7 +7886,7 @@ var Application = class _Application {
6768
7886
  (id) => this.events.publish({ kind: "chat", id }),
6769
7887
  () => this.events.publish({ kind: "inbox", memberId: this.user.id })
6770
7888
  );
6771
- this.files = new ChatFiles(dataDir, this.chats);
7889
+ this.files = new ChatFiles(this.chats);
6772
7890
  store.runtime.recover();
6773
7891
  this.bots = new BotService({
6774
7892
  store,
@@ -6780,8 +7898,8 @@ var Application = class _Application {
6780
7898
  factory
6781
7899
  });
6782
7900
  this.auth = new CredentialLogins(store, this.credentials, (id) => this.bots.connectionChanged(id));
6783
- this.account = new AccountService(store, createHash4("sha256").update(dataDir).digest("hex").slice(0, 10));
6784
- this.models = new ModelService(store, this.credentials, this.auth, (id) => this.bots.connectionChanged(id));
7901
+ this.account = new AccountService(store, createHash6("sha256").update(dataDir).digest("hex").slice(0, 10));
7902
+ this.models = new ModelService(store, this.credentials, this.auth, (id) => this.bots.connectionChanged(id), new ModelCatalog(dataDir));
6785
7903
  }
6786
7904
  events = new Changes();
6787
7905
  credentials = new CredentialCoordinator();
@@ -6794,8 +7912,8 @@ var Application = class _Application {
6794
7912
  models;
6795
7913
  closing;
6796
7914
  static async open(dataDir, options = {}) {
6797
- await mkdir4(resolve4(dataDir), { recursive: true, mode: 448 });
6798
- const root = await realpath2(resolve4(dataDir));
7915
+ await mkdir5(resolve3(dataDir), { recursive: true, mode: 448 });
7916
+ const root = await realpath(resolve3(dataDir));
6799
7917
  let app;
6800
7918
  let release;
6801
7919
  try {
@@ -6803,7 +7921,7 @@ var Application = class _Application {
6803
7921
  retries: 0,
6804
7922
  stale: 3e4,
6805
7923
  update: 1e4,
6806
- lockfilePath: join7(root, ".runtime.lock"),
7924
+ lockfilePath: join9(root, ".runtime.lock"),
6807
7925
  onCompromised: () => {
6808
7926
  console.error("[runtime] data lock compromised");
6809
7927
  options.onCompromised?.();
@@ -6826,6 +7944,7 @@ var Application = class _Application {
6826
7944
  }
6827
7945
  start() {
6828
7946
  this.bots.start();
7947
+ this.models.refreshCatalog();
6829
7948
  }
6830
7949
  close() {
6831
7950
  if (!this.closing) this.closing = (async () => {
@@ -6841,14 +7960,14 @@ var Application = class _Application {
6841
7960
  };
6842
7961
 
6843
7962
  // src/server/daemon.ts
6844
- import { spawn } from "node:child_process";
7963
+ import { spawn as spawn2 } from "node:child_process";
6845
7964
  import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
6846
- import { join as join8, resolve as resolve5 } from "node:path";
6847
- var pidPath = (dir) => join8(resolve5(dir), "jarvy.pid");
6848
- var logPath = (dir) => join8(resolve5(dir), "jarvy.log");
7965
+ import { join as join10, resolve as resolve4 } from "node:path";
7966
+ var pidPath = (dir) => join10(resolve4(dir), "jarvy.pid");
7967
+ var logPath = (dir) => join10(resolve4(dir), "jarvy.log");
6849
7968
  function writePidFile(dir, url) {
6850
- const record2 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
6851
- writeFileSync(pidPath(dir), JSON.stringify(record2), { mode: 384 });
7969
+ const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
7970
+ writeFileSync(pidPath(dir), JSON.stringify(record3), { mode: 384 });
6852
7971
  }
6853
7972
  function removePidFile(dir) {
6854
7973
  if (readRecord(dir)?.pid === process.pid) rmSync(pidPath(dir), { force: true });
@@ -6876,19 +7995,19 @@ function alive(pid) {
6876
7995
  }
6877
7996
  }
6878
7997
  function runningServer(dir) {
6879
- const record2 = readRecord(dir);
6880
- if (!record2) return null;
6881
- const token = alive(record2.pid) ? startToken(record2.pid) : null;
6882
- if (token === null || token && record2.startToken && token !== record2.startToken) {
7998
+ const record3 = readRecord(dir);
7999
+ if (!record3) return null;
8000
+ const token = alive(record3.pid) ? startToken(record3.pid) : null;
8001
+ if (token === null || token && record3.startToken && token !== record3.startToken) {
6883
8002
  rmSync(pidPath(dir), { force: true });
6884
8003
  return null;
6885
8004
  }
6886
- return record2;
8005
+ return record3;
6887
8006
  }
6888
8007
  function startBackground(script, runArgs, dir) {
6889
- mkdirSync3(resolve5(dir), { recursive: true, mode: 448 });
8008
+ mkdirSync3(resolve4(dir), { recursive: true, mode: 448 });
6890
8009
  const log = openSync(logPath(dir), "a", 384);
6891
- const child = spawn(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
8010
+ const child = spawn2(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
6892
8011
  closeSync(log);
6893
8012
  return new Promise((resolvePromise, reject) => {
6894
8013
  child.once("error", reject);
@@ -6916,11 +8035,11 @@ function signalReady(url) {
6916
8035
  process.disconnect();
6917
8036
  }
6918
8037
  async function stopServer(dir, waitMs = 3e4) {
6919
- const record2 = runningServer(dir);
6920
- if (!record2) return "not_running";
6921
- process.kill(record2.pid, "SIGTERM");
8038
+ const record3 = runningServer(dir);
8039
+ if (!record3) return "not_running";
8040
+ process.kill(record3.pid, "SIGTERM");
6922
8041
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
6923
- if (!alive(record2.pid)) {
8042
+ if (!alive(record3.pid)) {
6924
8043
  rmSync(pidPath(dir), { force: true });
6925
8044
  return "stopped";
6926
8045
  }
@@ -6939,7 +8058,7 @@ function option(args, flag, fallback) {
6939
8058
  function secretPrompt(label) {
6940
8059
  if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error("Account setup requires an interactive terminal");
6941
8060
  process.stdout.write(label);
6942
- return new Promise((resolve6, reject) => {
8061
+ return new Promise((resolve5, reject) => {
6943
8062
  let value2 = "";
6944
8063
  const done = (result, error) => {
6945
8064
  process.stdin.off("data", onData);
@@ -6947,7 +8066,7 @@ function secretPrompt(label) {
6947
8066
  process.stdin.pause();
6948
8067
  process.stdout.write("\n");
6949
8068
  if (error) reject(error);
6950
- else resolve6(result ?? "");
8069
+ else resolve5(result ?? "");
6951
8070
  };
6952
8071
  const onData = (buffer) => {
6953
8072
  for (const char of buffer.toString("utf8")) {
@@ -7064,18 +8183,18 @@ async function main() {
7064
8183
  try {
7065
8184
  if (!store.isInitialized()) await initialize(store);
7066
8185
  const { host, port, url } = listenOptions(args);
7067
- const webRoot = join9(dirname6(fileURLToPath(import.meta.url)), "../web");
7068
- if (!existsSync2(join9(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
8186
+ const webRoot = join11(dirname8(fileURLToPath(import.meta.url)), "../web");
8187
+ if (!existsSync2(join11(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
7069
8188
  const server = createJarvyServer(app, webRoot);
7070
8189
  closeHttp = () => {
7071
8190
  server.close();
7072
8191
  server.closeAllConnections();
7073
8192
  };
7074
- await new Promise((resolve6, reject) => {
8193
+ await new Promise((resolve5, reject) => {
7075
8194
  server.once("error", reject);
7076
8195
  server.listen(port, host, () => {
7077
8196
  server.off("error", reject);
7078
- resolve6();
8197
+ resolve5();
7079
8198
  });
7080
8199
  });
7081
8200
  app.start();