@goodfish/jarvy 0.7.0 → 0.9.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 existsSync5 } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { dirname as dirname9, join as join14 } from "node:path";
6
+ import { dirname as dirname10, join as join16 } from "node:path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "node:url";
8
8
 
9
9
  // src/server/api/server.ts
@@ -14,8 +14,8 @@ import { networkInterfaces } from "node:os";
14
14
  import { getRequestListener } from "@hono/node-server";
15
15
 
16
16
  // src/server/api/app.ts
17
- import { Hono as Hono12 } from "hono";
18
- import { HTTPException as HTTPException12 } from "hono/http-exception";
17
+ import { Hono as Hono14 } from "hono";
18
+ import { HTTPException as HTTPException13 } from "hono/http-exception";
19
19
  import { serveStatic } from "@hono/node-server/serve-static";
20
20
 
21
21
  // src/server/api/middleware/security.ts
@@ -214,15 +214,15 @@ var MARK = /\[\[q(\d+)\]\]/g;
214
214
  function quoteMarks(content2) {
215
215
  return [...content2.matchAll(MARK)].map((match) => Number(match[1]));
216
216
  }
217
- function clipQuote(text3) {
218
- const chars = [...text3];
219
- return chars.length > 200 ? `${chars.slice(0, 120).join("")}\u2026\u2026${chars.slice(-60).join("")}` : text3;
217
+ function clipQuote(text4) {
218
+ const chars = [...text4];
219
+ return chars.length > 200 ? `${chars.slice(0, 120).join("")}\u2026\u2026${chars.slice(-60).join("")}` : text4;
220
220
  }
221
221
  function previewQuotes(content2, quotes) {
222
222
  if (!quotes.length) return content2;
223
223
  return content2.replace(MARK, (mark, n) => {
224
- const quote = quotes[Number(n) - 1];
225
- return quote ? `"${clipQuote(quote.text).replace(/\s+/g, " ").trim()}"` : mark;
224
+ const quote2 = quotes[Number(n) - 1];
225
+ return quote2 ? `"${clipQuote(quote2.text).replace(/\s+/g, " ").trim()}"` : mark;
226
226
  });
227
227
  }
228
228
  function clipMarked(content2, length) {
@@ -234,16 +234,16 @@ function replyExcerpt(reply) {
234
234
  function expandQuotes(content2, quotes, name3) {
235
235
  if (!quotes.length) return content2;
236
236
  return content2.replace(/[ \t]*\[\[q(\d+)\]\][ \t]*/g, (mark, n, offset, whole) => {
237
- const quote = quotes[Number(n) - 1];
238
- if (!quote) return mark;
239
- const head = `${name3(quote.senderId)}${quote.messageId ? ` (${quote.messageId})` : ""}: `;
240
- const block = `${head}${clipQuote(quote.text).trim()}`.split("\n").map((line) => `> ${line}`).join("\n");
237
+ const quote2 = quotes[Number(n) - 1];
238
+ if (!quote2) return mark;
239
+ const head = `${name3(quote2.senderId)}${quote2.messageId ? ` (${quote2.messageId})` : ""}: `;
240
+ const block = `${head}${clipQuote(quote2.text).trim()}`.split("\n").map((line) => `> ${line}`).join("\n");
241
241
  const end = offset + mark.length;
242
242
  return `${offset > 0 && whole[offset - 1] !== "\n" ? "\n" : ""}${block}${end < whole.length && whole[end] !== "\n" ? "\n" : ""}`;
243
243
  });
244
244
  }
245
- function quoteComparable(text3) {
246
- return text3.replace(MARK, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<(https?:\/\/[^>\s]+)>/g, "$1").replace(/^[ \t|:*_=-]*$/gm, (line) => /[-*_=]{3}/.test(line) ? "" : line).replace(/^[ \t]*(?:>[ \t>]*|#{1,6}[ \t]+|(?:[-*+]|\d+[.)])[ \t]+|```.*|~~~.*)/gm, "").replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, "$1").replace(/[*_~`|]/g, "").replace(/\s+/g, "");
245
+ function quoteComparable(text4) {
246
+ return text4.replace(MARK, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<(https?:\/\/[^>\s]+)>/g, "$1").replace(/^[ \t|:*_=-]*$/gm, (line) => /[-*_=]{3}/.test(line) ? "" : line).replace(/^[ \t]*(?:>[ \t>]*|#{1,6}[ \t]+|(?:[-*+]|\d+[.)])[ \t]+|```.*|~~~.*)/gm, "").replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, "$1").replace(/[*_~`|]/g, "").replace(/\s+/g, "");
247
247
  }
248
248
 
249
249
  // src/server/api/views/chat.ts
@@ -301,7 +301,8 @@ function messageView(message) {
301
301
  echoOf: message.echoOf,
302
302
  replyTo: message.replyTo,
303
303
  reactions: message.reactions,
304
- quotes: message.quotes
304
+ quotes: message.quotes,
305
+ folded: message.folded
305
306
  };
306
307
  }
307
308
 
@@ -387,6 +388,10 @@ function botRoutes(bots) {
387
388
  });
388
389
  routes.put("/:id/environment", jsonBody, async (c) => {
389
390
  const input = await readJson(c), id = c.req.param("id");
391
+ if (input.machineId !== void 0) {
392
+ if (typeof input.machineId !== "string" || typeof input.granted !== "boolean") throw new HTTPException7(400, { message: "machineId and granted (true or false) are needed" });
393
+ return c.json(bots.grantMachine(id, input.machineId, input.granted));
394
+ }
390
395
  if (input.hostGranted === void 0) return c.json(bots.setHome(id, environmentKind(input.home)));
391
396
  if (typeof input.hostGranted !== "boolean") throw new HTTPException7(400, { message: "hostGranted must be true or false" });
392
397
  return c.json(bots.grantHost(id, input.hostGranted));
@@ -676,6 +681,7 @@ function channelRoutes({ chats }) {
676
681
  const input = await readJson(c);
677
682
  return c.json(messageView(chats.react(c.get("actorId"), c.req.param("id"), input.emoji, input.on ?? true)));
678
683
  });
684
+ routes.put("/messages/:id/fold", jsonBody, async (c) => c.json(messageView(chats.fold(c.get("actorId"), c.req.param("id"), (await readJson(c)).folded))));
679
685
  routes.put("/chats/:id/mute", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.mute(c.get("actorId"), c.req.param("id"), (await readJson(c)).muted).id)));
680
686
  routes.put("/chats/:id/pin", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.pin(c.get("actorId"), c.req.param("id"), (await readJson(c)).pinned).id)));
681
687
  routes.get("/members/:id/dms", (c) => c.json({ chats: chats.botDirects(c.get("actorId"), c.req.param("id")).map((item) => chatView(item, (id) => chats.member(id))) }));
@@ -843,15 +849,100 @@ function secretRoutes(secrets) {
843
849
  return routes;
844
850
  }
845
851
 
852
+ // src/server/api/routes/environments.ts
853
+ import { Hono as Hono12 } from "hono";
854
+ import { HTTPException as HTTPException12 } from "hono/http-exception";
855
+ function environmentRoutes(bots) {
856
+ const routes = new Hono12(), machines = bots.machines;
857
+ routes.get("/", (c) => c.json({ machines: machines.list() }));
858
+ routes.post("/scan", jsonBody, async (c) => {
859
+ const input = await readJson(c);
860
+ return c.json(await machines.scan({ host: input.host, port: input.port, user: input.user }));
861
+ });
862
+ routes.post("/keys", async (c) => c.json(await machines.prepareKey(), 201));
863
+ routes.post("/", jsonBody, async (c) => {
864
+ const input = await readJson(c);
865
+ return c.json(await machines.add({
866
+ name: input.name,
867
+ host: input.host,
868
+ port: input.port,
869
+ user: input.user,
870
+ directory: input.directory,
871
+ password: input.password,
872
+ ...input.keyId !== void 0 ? { keyId: input.keyId } : {},
873
+ hostKeys: hostKeys(input.hostKeys)
874
+ }), 201);
875
+ });
876
+ routes.put("/:id/password", jsonBody, async (c) => {
877
+ const input = await readJson(c);
878
+ return c.json(await machines.setPassword(c.req.param("id"), input.password));
879
+ });
880
+ routes.put("/:id", jsonBody, async (c) => {
881
+ const input = await readJson(c);
882
+ return c.json(machines.update(c.req.param("id"), { name: input.name, directory: input.directory }));
883
+ });
884
+ routes.post("/:id/check", async (c) => c.json(await machines.check(c.req.param("id"))));
885
+ routes.post("/:id/trust", jsonBody, async (c) => {
886
+ const input = await readJson(c);
887
+ return c.json(await machines.trust(c.req.param("id"), input.fingerprints));
888
+ });
889
+ routes.delete("/:id", async (c) => {
890
+ await bots.removeMachine(c.req.param("id"), c.req.query("force") === "1");
891
+ return c.json({ ok: true });
892
+ });
893
+ routes.put("/:id/grants/:botId", jsonBody, async (c) => {
894
+ const input = await readJson(c);
895
+ if (typeof input.granted !== "boolean") throw new HTTPException12(400, { message: "granted must be true or false" });
896
+ bots.grantMachine(c.req.param("botId"), c.req.param("id"), input.granted);
897
+ return c.json(machines.get(c.req.param("id")));
898
+ });
899
+ routes.post("/requests/:chatId/:messageId", jsonBody, async (c) => {
900
+ const input = await readJson(c);
901
+ return c.json(messageView(await machines.fulfil(
902
+ c.get("actorId"),
903
+ c.req.param("chatId"),
904
+ c.req.param("messageId"),
905
+ { password: input.password, ...input.keyId !== void 0 ? { keyId: input.keyId } : {} }
906
+ )));
907
+ });
908
+ return routes;
909
+ }
910
+ function hostKeys(value2) {
911
+ const keys = value2;
912
+ const strings = (list) => Array.isArray(list) && list.length > 0 && list.length <= 20 && list.every((item) => typeof item === "string" && item.length <= 2e3);
913
+ if (!keys || !strings(keys.keys) || !strings(keys.fingerprints)) throw new HTTPException12(400, { message: "hostKeys must be what the scan returned" });
914
+ return { keys: keys.keys, fingerprints: keys.fingerprints };
915
+ }
916
+
917
+ // src/server/api/routes/tasks.ts
918
+ import { Hono as Hono13 } from "hono";
919
+ function taskRoutes(services) {
920
+ const routes = new Hono13();
921
+ const bot = (id) => {
922
+ services.bots.describe(id);
923
+ return id;
924
+ };
925
+ routes.get("/bots/:id/tasks", (c) => c.json({ tasks: services.tasks.list(bot(c.req.param("id"))) }));
926
+ routes.post("/bots/:id/tasks", jsonBody, async (c) => c.json(services.tasks.create(bot(c.req.param("id")), await readJson(c)), 201));
927
+ routes.put("/bots/:id/tasks/:taskId", jsonBody, async (c) => c.json(services.tasks.update(bot(c.req.param("id")), c.req.param("taskId"), await readJson(c))));
928
+ routes.post("/bots/:id/tasks/:taskId/:action{pause|resume}", (c) => c.json(services.tasks.setState(bot(c.req.param("id")), c.req.param("taskId"), c.req.param("action") === "pause" ? "paused" : "active")));
929
+ routes.delete("/bots/:id/tasks/:taskId", (c) => {
930
+ services.tasks.remove(bot(c.req.param("id")), c.req.param("taskId"));
931
+ return c.json({ ok: true });
932
+ });
933
+ return routes;
934
+ }
935
+
846
936
  // src/server/api/app.ts
847
937
  function createHttpApp(services, webRoot) {
848
- const app = new Hono12();
938
+ const app = new Hono14();
849
939
  app.use("*", headers);
850
940
  app.onError(handleError);
851
- const api = new Hono12();
941
+ const api = new Hono14();
852
942
  api.use("*", apiSecurity);
853
943
  api.route("/", rootCertificateRoute(services.connection));
854
944
  installAuthentication(api, services.account);
945
+ api.route("/", taskRoutes(services));
855
946
  api.route("/bots", botRoutes(services.bots));
856
947
  api.route("/", channelRoutes(services));
857
948
  api.route("/chats", chatRoutes(services));
@@ -861,8 +952,9 @@ function createHttpApp(services, webRoot) {
861
952
  api.route("/", liveRoutes(services));
862
953
  api.route("/", connectionRoutes(services.connection));
863
954
  api.route("/secrets", secretRoutes(services.secrets));
955
+ api.route("/environments", environmentRoutes(services.bots));
864
956
  api.all("*", () => {
865
- throw new HTTPException12(404, { message: "API route not found" });
957
+ throw new HTTPException13(404, { message: "API route not found" });
866
958
  });
867
959
  app.route("/api", api);
868
960
  app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
@@ -872,16 +964,16 @@ function createHttpApp(services, webRoot) {
872
964
  c.header("Cache-Control", "no-store");
873
965
  } }));
874
966
  app.all("*", (c) => {
875
- if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException12(405, { message: "Method not allowed" });
876
- throw new HTTPException12(404, { message: "Web assets missing; run npm run build" });
967
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException13(405, { message: "Method not allowed" });
968
+ throw new HTTPException13(404, { message: "Web assets missing; run npm run build" });
877
969
  });
878
970
  return app;
879
971
  }
880
972
 
881
973
  // src/server/api/server.ts
882
974
  var own = { at: 0, addresses: /* @__PURE__ */ new Set() };
883
- function fromThisMachine(address = "") {
884
- const plain2 = address.replace(/^::ffff:/, "");
975
+ function fromThisMachine(address2 = "") {
976
+ const plain2 = address2.replace(/^::ffff:/, "");
885
977
  if (/^127\./.test(plain2) || plain2 === "::1") return true;
886
978
  if (Date.now() - own.at > 6e4) own = { at: Date.now(), addresses: new Set(Object.values(networkInterfaces()).flatMap((list) => (list ?? []).map((item) => item.address))) };
887
979
  return own.addresses.has(plain2);
@@ -945,7 +1037,7 @@ var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
945
1037
  var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
946
1038
 
947
1039
  // src/server/infra/storage/store.ts
948
- import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as randomUUID9, scryptSync, timingSafeEqual } from "node:crypto";
1040
+ import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as randomUUID10, scryptSync, timingSafeEqual } from "node:crypto";
949
1041
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
950
1042
  import { homedir as homedir2 } from "node:os";
951
1043
  import { dirname as dirname2, join as join3, resolve } from "node:path";
@@ -1301,8 +1393,8 @@ var Cipher = class _Cipher {
1301
1393
  location() {
1302
1394
  return this.master.location;
1303
1395
  }
1304
- static sealed(text3) {
1305
- return text3.startsWith(PREFIX);
1396
+ static sealed(text4) {
1397
+ return text4.startsWith(PREFIX);
1306
1398
  }
1307
1399
  seal(plain2, context2) {
1308
1400
  const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", this.master.key, iv);
@@ -1362,6 +1454,106 @@ function migrateSecrets(db, cipher) {
1362
1454
  }
1363
1455
  }
1364
1456
 
1457
+ // src/server/infra/storage/migrations/ssh-schema.ts
1458
+ function migrateSsh(db) {
1459
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1460
+ if (version >= 22) return;
1461
+ db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
1462
+ try {
1463
+ db.exec(`
1464
+ CREATE TABLE environments_next(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id),
1465
+ kind TEXT NOT NULL CHECK(kind IN ('host','computer','ssh')), name TEXT NOT NULL DEFAULT '',
1466
+ connection TEXT NOT NULL DEFAULT '{}', directory TEXT, bot_id TEXT REFERENCES bots(id),
1467
+ key TEXT, host_keys TEXT, created_at INTEGER NOT NULL, deleted_at INTEGER);
1468
+ INSERT INTO environments_next(id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at)
1469
+ SELECT id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at FROM environments;
1470
+ DROP TABLE environments;
1471
+ ALTER TABLE environments_next RENAME TO environments;
1472
+ CREATE UNIQUE INDEX environments_host ON environments(owner_id) WHERE kind='host' AND deleted_at IS NULL;
1473
+ CREATE UNIQUE INDEX environments_computer ON environments(bot_id) WHERE kind='computer' AND deleted_at IS NULL;
1474
+ PRAGMA user_version=22;
1475
+ `);
1476
+ if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
1477
+ db.exec("COMMIT");
1478
+ } catch (error) {
1479
+ db.exec("ROLLBACK");
1480
+ throw error;
1481
+ } finally {
1482
+ db.exec("PRAGMA foreign_keys=ON");
1483
+ }
1484
+ }
1485
+
1486
+ // src/server/infra/storage/migrations/task-schema.ts
1487
+ function migrateTasks(db) {
1488
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1489
+ if (version >= 23) return;
1490
+ db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
1491
+ try {
1492
+ const sequence = db.prepare("SELECT seq FROM sqlite_sequence WHERE name='inbox'").get()?.seq ?? 0;
1493
+ db.exec(`
1494
+ CREATE TABLE tasks(id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
1495
+ name TEXT NOT NULL, instruction TEXT NOT NULL,
1496
+ schedule_kind TEXT NOT NULL CHECK(schedule_kind IN ('once','every','daily','weekly')),
1497
+ schedule_at INTEGER, schedule_minutes INTEGER, schedule_hour INTEGER, schedule_minute INTEGER, schedule_weekday INTEGER,
1498
+ state TEXT NOT NULL CHECK(state IN ('active','paused','done')),
1499
+ next_run_at INTEGER, last_run_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER);
1500
+ CREATE INDEX tasks_due ON tasks(state, next_run_at) WHERE deleted_at IS NULL;
1501
+ CREATE INDEX tasks_bot ON tasks(bot_id) WHERE deleted_at IS NULL;
1502
+ CREATE TABLE inbox_next(id INTEGER PRIMARY KEY AUTOINCREMENT, member_id TEXT NOT NULL REFERENCES members(id),
1503
+ kind TEXT NOT NULL CHECK(kind IN ('message','reaction','note','onboard','change','task')), reason TEXT NOT NULL,
1504
+ urgency TEXT NOT NULL CHECK(urgency IN ('interrupt','queue','digest')),
1505
+ chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1506
+ actor_id TEXT REFERENCES members(id), detail TEXT,
1507
+ state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','told','done','failed','interrupted','unknown','cancelled')),
1508
+ run_id TEXT REFERENCES bot_runs(id), created_at INTEGER NOT NULL, told_at INTEGER);
1509
+ INSERT INTO inbox_next(id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at)
1510
+ SELECT id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at FROM inbox ORDER BY id;
1511
+ DROP TABLE inbox;
1512
+ ALTER TABLE inbox_next RENAME TO inbox;
1513
+ CREATE UNIQUE INDEX inbox_one_message ON inbox(member_id, message_id) WHERE kind='message';
1514
+ CREATE INDEX inbox_open ON inbox(member_id, state, id);
1515
+ CREATE INDEX inbox_run ON inbox(run_id) WHERE run_id IS NOT NULL;
1516
+ `);
1517
+ db.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='inbox'").run(sequence);
1518
+ db.exec("PRAGMA user_version=23");
1519
+ if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
1520
+ db.exec("COMMIT");
1521
+ } catch (error) {
1522
+ db.exec("ROLLBACK");
1523
+ throw error;
1524
+ } finally {
1525
+ db.exec("PRAGMA foreign_keys=ON");
1526
+ }
1527
+ }
1528
+
1529
+ // src/server/infra/storage/migrations/ssh-login-schema.ts
1530
+ function migrateSshLogin(db) {
1531
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1532
+ if (version >= 24) return;
1533
+ db.exec("BEGIN IMMEDIATE");
1534
+ try {
1535
+ db.exec("ALTER TABLE environments ADD COLUMN password TEXT; PRAGMA user_version=24");
1536
+ db.exec("COMMIT");
1537
+ } catch (error) {
1538
+ db.exec("ROLLBACK");
1539
+ throw error;
1540
+ }
1541
+ }
1542
+
1543
+ // src/server/infra/storage/migrations/fold-schema.ts
1544
+ function migrateFolds(db) {
1545
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1546
+ if (version >= 25) return;
1547
+ db.exec("BEGIN IMMEDIATE");
1548
+ try {
1549
+ db.exec("ALTER TABLE messages ADD COLUMN folded_at INTEGER; PRAGMA user_version=25;");
1550
+ db.exec("COMMIT");
1551
+ } catch (error) {
1552
+ db.exec("ROLLBACK");
1553
+ throw error;
1554
+ }
1555
+ }
1556
+
1365
1557
  // src/server/infra/storage/migrations/schema.ts
1366
1558
  import { randomUUID as randomUUID2 } from "node:crypto";
1367
1559
 
@@ -1484,7 +1676,7 @@ function migrateRuntime(db) {
1484
1676
  // src/server/infra/storage/migrations/schema.ts
1485
1677
  function migrateDatabase(db, cipher) {
1486
1678
  const version = db.prepare("PRAGMA user_version").get().user_version;
1487
- if (version > 21) throw new Error(`Unsupported database version: ${version}`);
1679
+ if (version > 25) throw new Error(`Unsupported database version: ${version}`);
1488
1680
  migratePrototype(db);
1489
1681
  if (version >= 5) {
1490
1682
  migrateRuntime(db);
@@ -1503,6 +1695,10 @@ function migrateDatabase(db, cipher) {
1503
1695
  migrateAgent(db);
1504
1696
  migrateEnvironments(db);
1505
1697
  migrateSecrets(db, cipher);
1698
+ migrateSsh(db);
1699
+ migrateTasks(db);
1700
+ migrateSshLogin(db);
1701
+ migrateFolds(db);
1506
1702
  return;
1507
1703
  }
1508
1704
  db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
@@ -1579,6 +1775,10 @@ function migrateDatabase(db, cipher) {
1579
1775
  migrateAgent(db);
1580
1776
  migrateEnvironments(db);
1581
1777
  migrateSecrets(db, cipher);
1778
+ migrateSsh(db);
1779
+ migrateTasks(db);
1780
+ migrateSshLogin(db);
1781
+ migrateFolds(db);
1582
1782
  }
1583
1783
 
1584
1784
  // src/server/infra/storage/chat-repository.ts
@@ -1691,14 +1891,14 @@ var ChannelRepository = class {
1691
1891
  }
1692
1892
  /** Channel notice / thread summary: any member who can write there, people and Bots alike. The routed
1693
1893
  * deliveries (A41) are written with the new version; `detail` is that version. */
1694
- setNote(actorId, chatId, text3, deliveries = []) {
1894
+ setNote(actorId, chatId, text4, deliveries = []) {
1695
1895
  this.chats.assertAccess(actorId, chatId, true);
1696
1896
  const type = this.db.prepare("SELECT type FROM chats WHERE id=?").get(chatId).type;
1697
1897
  if (type === "dm") throw new ChatError(400, "Direct messages have no notice");
1698
1898
  const now = Date.now();
1699
1899
  inTransaction(this.db, () => {
1700
1900
  this.db.prepare(`INSERT INTO chat_notes(chat_id,text,updated_by,updated_at,version) VALUES(?,?,?,?,1)
1701
- ON CONFLICT(chat_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at,version=version+1`).run(chatId, text3, actorId, now);
1901
+ ON CONFLICT(chat_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at,version=version+1`).run(chatId, text4, actorId, now);
1702
1902
  this.db.prepare("UPDATE chats SET updated_at=? WHERE id=?").run(now, chatId);
1703
1903
  this.markNoteSeen(actorId, chatId);
1704
1904
  const version = String(this.db.prepare("SELECT version FROM chat_notes WHERE chat_id=?").get(chatId).version);
@@ -1904,7 +2104,7 @@ var InteractionRepository = class {
1904
2104
  notices(botId) {
1905
2105
  const rows2 = this.db.prepare(`SELECT n.id,n.kind,n.urgency,n.chat_id AS chatId,c.title AS chatTitle,c.type AS chatType,n.message_id AS messageId,n.actor_id AS actorId,n.detail,n.created_at AS createdAt
1906
2106
  FROM inbox n JOIN chats c ON c.id=n.chat_id WHERE n.member_id=? AND n.kind<>'message' AND n.state='pending' ORDER BY n.id`).all(botId);
1907
- return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: this.chats.member(actorId) }));
2107
+ return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: actorId ? this.chats.member(actorId) : null }));
1908
2108
  }
1909
2109
  /** These notices reached the Bot (steered into its run, or in a batch it was given). */
1910
2110
  tell(botId, ids) {
@@ -1940,6 +2140,10 @@ var InteractionRepository = class {
1940
2140
  return this.db.prepare(`SELECT DISTINCT member_id AS id FROM chat_states WHERE muted=1 AND chat_id IN
1941
2141
  (SELECT id FROM chats WHERE id=? UNION SELECT parent_id FROM chats WHERE id=? AND parent_id IS NOT NULL)`).all(chatId, chatId).map((row) => row.id);
1942
2142
  }
2143
+ /** Folds a message to one line in the interface, or unfolds it (A60); its content and everything else stay. */
2144
+ setFolded(messageId, folded) {
2145
+ this.db.prepare("UPDATE messages SET folded_at=? WHERE id=?").run(folded ? Date.now() : null, messageId);
2146
+ }
1943
2147
  setPinned(memberId, chatId, pinned) {
1944
2148
  this.chats.assertAccess(memberId, chatId);
1945
2149
  this.db.prepare(`INSERT INTO chat_states(chat_id,member_id,pinned) VALUES(?,?,?)
@@ -1954,7 +2158,7 @@ var QuoteRepository = class {
1954
2158
  }
1955
2159
  /** Inside the caller's transaction. */
1956
2160
  insert(messageId, quotes) {
1957
- quotes.forEach((quote, index) => this.db.prepare("INSERT INTO message_quotes(message_id,position,source_id,source_sender_id,text) VALUES(?,?,?,?,?)").run(messageId, index + 1, quote.messageId, quote.senderId, quote.text));
2161
+ quotes.forEach((quote2, index) => this.db.prepare("INSERT INTO message_quotes(message_id,position,source_id,source_sender_id,text) VALUES(?,?,?,?,?)").run(messageId, index + 1, quote2.messageId, quote2.senderId, quote2.text));
1958
2162
  }
1959
2163
  of(messageId) {
1960
2164
  return this.db.prepare("SELECT source_id AS messageId,source_sender_id AS senderId,text FROM message_quotes WHERE message_id=? ORDER BY position").all(messageId);
@@ -1962,7 +2166,7 @@ var QuoteRepository = class {
1962
2166
  };
1963
2167
 
1964
2168
  // src/server/infra/storage/chat-repository.ts
1965
- var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo";
2169
+ var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo,folded_at AS foldedAt";
1966
2170
  var CHAT_COLUMNS = `c.id,c.type,c.title,c.owner_id AS ownerId,c.created_at AS createdAt,c.updated_at AS updatedAt,
1967
2171
  c.section_id AS sectionId,c.topic,c.parent_id AS parentId,c.root_message_id AS rootMessageId,c.status,
1968
2172
  (SELECT content FROM messages WHERE chat_id=c.id ORDER BY sequence DESC LIMIT 1) AS lastMessage,
@@ -2117,12 +2321,12 @@ var ChatRepository = class {
2117
2321
  return rows2.map((row) => this.messageFromRow(row));
2118
2322
  }
2119
2323
  /** Search every chat the member can read: its DMs and channels and their threads (newest first). `within` keeps
2120
- * one chat, a channel with its threads. */
2121
- searchAll(actorId, query, limit, within) {
2122
- const scope = within ? "AND (c.id=? OR c.parent_id=?)" : "";
2324
+ * one chat, a channel with its threads; `sender` keeps one member's messages. */
2325
+ searchAll(actorId, query, limit, within, sender) {
2326
+ const scope = (within ? "AND (c.id=? OR c.parent_id=?) " : "") + (sender ? "AND m.sender_id=?" : "");
2123
2327
  const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS.split(",").map((column) => `m.${column}`).join(",")} FROM messages m JOIN chats c ON c.id=m.chat_id
2124
2328
  JOIN chat_members cm ON cm.chat_id=COALESCE(c.parent_id,c.id) AND cm.member_id=? AND cm.can_read=1
2125
- WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], limit);
2329
+ WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], ...sender ? [sender] : [], limit);
2126
2330
  return rows2.map((row) => this.messageFromRow(row));
2127
2331
  }
2128
2332
  /** Channels and threads the member can read whose title, topic or notice / summary contains the query (newest first). */
@@ -2141,9 +2345,10 @@ var ChatRepository = class {
2141
2345
  const row = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE id=?`).get(id);
2142
2346
  return row && this.canAccess(actorId, row.chatId) ? this.messageFromRow(row) : null;
2143
2347
  }
2144
- search(actorId, chatId, query, limit) {
2348
+ search(actorId, chatId, query, limit, sender) {
2145
2349
  this.assertAccess(actorId, chatId);
2146
- const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0 ORDER BY sequence DESC LIMIT ?`).all(chatId, query, limit);
2350
+ const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0 ${sender ? "AND sender_id=?" : ""}
2351
+ ORDER BY sequence DESC LIMIT ?`).all(chatId, query, ...sender ? [sender] : [], limit);
2147
2352
  return rows2.map((row) => this.messageFromRow(row));
2148
2353
  }
2149
2354
  sentActivity(actorId, senderId, before, limit) {
@@ -2174,7 +2379,8 @@ var ChatRepository = class {
2174
2379
  echoOf: row.echoOf === null ? null : { id: row.echoOf, title: this.db.prepare("SELECT title FROM chats WHERE id=?").get(row.echoOf).title },
2175
2380
  replyTo: row.replyTo === null ? null : this.quote(row.replyTo),
2176
2381
  reactions: this.interactions.reactions(row.id),
2177
- quotes: this.quotes.of(row.id)
2382
+ quotes: this.quotes.of(row.id),
2383
+ folded: row.foldedAt !== null
2178
2384
  };
2179
2385
  }
2180
2386
  /** What a reply shows of the message it answers: who wrote it and the start of its content, with its quotes ("" is attachments only). */
@@ -2236,14 +2442,15 @@ var ChatRepository = class {
2236
2442
  toolCallId: draft.source?.toolCallId ?? null,
2237
2443
  question: null,
2238
2444
  echoOf: chatId,
2239
- replyTo: null
2445
+ replyTo: null,
2446
+ foldedAt: null
2240
2447
  });
2241
2448
  }
2242
2449
  }
2243
2450
  const file = this.db.prepare("SELECT name FROM attachments WHERE message_id=? ORDER BY created_at,rowid LIMIT 1").get(id);
2244
2451
  const title = draft.content ? previewQuotes(draft.content, draft.quotes ?? []).slice(0, 46).replace(/\s+/g, " ") : file?.name.slice(0, 46) ?? "";
2245
2452
  this.db.prepare("UPDATE chats SET title=CASE WHEN title='' AND ?=1 THEN ? ELSE title END,updated_at=? WHERE id=?").run(sequence, title, createdAt, chatId);
2246
- const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null });
2453
+ const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null, foldedAt: null });
2247
2454
  this.db.exec("COMMIT");
2248
2455
  return { message, duplicate: false, ...echo ? { echo } : {} };
2249
2456
  } catch (error) {
@@ -2633,11 +2840,21 @@ var AgentRepository = class {
2633
2840
 
2634
2841
  // src/server/infra/storage/environment-repository.ts
2635
2842
  import { randomUUID as randomUUID7 } from "node:crypto";
2636
- var COLUMNS = "id, owner_id AS ownerId, kind, bot_id AS botId, connection";
2637
- var record = (row) => ({ id: row.id, ownerId: row.ownerId, kind: row.kind, botId: row.botId, spec: JSON.parse(row.connection) });
2843
+ var FIELDS = (table) => ["id", "owner_id AS ownerId", "kind", "bot_id AS botId", "name", "directory", "connection"].map((column) => `${table}${column}`).join(", ");
2844
+ var COLUMNS = FIELDS("");
2845
+ var record = (row) => ({
2846
+ id: row.id,
2847
+ ownerId: row.ownerId,
2848
+ kind: row.kind,
2849
+ botId: row.botId,
2850
+ name: row.name,
2851
+ directory: row.directory,
2852
+ spec: JSON.parse(row.connection)
2853
+ });
2638
2854
  var EnvironmentRepository = class {
2639
- constructor(db, ownerId) {
2855
+ constructor(db, cipher, ownerId) {
2640
2856
  this.db = db;
2857
+ this.cipher = cipher;
2641
2858
  this.ownerId = ownerId;
2642
2859
  }
2643
2860
  get(id) {
@@ -2659,7 +2876,7 @@ var EnvironmentRepository = class {
2659
2876
  }
2660
2877
  /** Every live Bot's computer. */
2661
2878
  computers() {
2662
- 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
2879
+ return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environments e JOIN bots b ON b.id=e.bot_id
2663
2880
  WHERE e.kind='computer' AND e.deleted_at IS NULL AND b.deleted_at IS NULL`).all().map(record);
2664
2881
  }
2665
2882
  /** Gives the Bot a computer, reached by the container's name. */
@@ -2703,7 +2920,7 @@ var EnvironmentRepository = class {
2703
2920
  }
2704
2921
  /** The environments the owner granted the Bot, oldest grant first. */
2705
2922
  grants(botId) {
2706
- 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
2923
+ return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environment_grants g JOIN environments e ON e.id=g.environment_id
2707
2924
  WHERE g.bot_id=? AND e.deleted_at IS NULL ORDER BY g.granted_at, e.id`).all(botId).map(record);
2708
2925
  }
2709
2926
  /** A grant lasts until it is revoked (A50); false when nothing changed. */
@@ -2713,6 +2930,68 @@ var EnvironmentRepository = class {
2713
2930
  revoke(environmentId, botId) {
2714
2931
  return Number(this.db.prepare("DELETE FROM environment_grants WHERE environment_id=? AND bot_id=?").run(environmentId, botId).changes) > 0;
2715
2932
  }
2933
+ /** The SSH machines, oldest first. */
2934
+ sshMachines() {
2935
+ return this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE kind='ssh' AND deleted_at IS NULL ORDER BY created_at, id`).all().map(record);
2936
+ }
2937
+ addSsh(input, id = randomUUID7()) {
2938
+ const { host, port, user, home, platform } = input;
2939
+ this.db.prepare("INSERT INTO environments(id,owner_id,kind,name,connection,directory,key,host_keys,password,created_at) VALUES(?,?,'ssh',?,?,?,?,?,?,?)").run(
2940
+ id,
2941
+ this.ownerId(),
2942
+ input.name,
2943
+ JSON.stringify({ host, port: String(port), user, home, platform }),
2944
+ input.directory,
2945
+ this.cipher.seal(input.privateKey, `environment:${id}`),
2946
+ JSON.stringify(input.hostKeys),
2947
+ input.password ? this.cipher.seal(input.password, `environment-password:${id}`) : null,
2948
+ Date.now()
2949
+ );
2950
+ return this.get(id);
2951
+ }
2952
+ /** `password`: a new one for a machine reached with its password; null goes back to the key (A59). */
2953
+ updateSsh(id, input) {
2954
+ if (input.password !== void 0) this.db.prepare("UPDATE environments SET password=? WHERE id=? AND kind='ssh'").run(input.password === null ? null : this.cipher.seal(input.password, `environment-password:${id}`), id);
2955
+ if (input.name !== void 0) this.db.prepare("UPDATE environments SET name=? WHERE id=? AND kind='ssh'").run(input.name, id);
2956
+ if (input.directory !== void 0) this.db.prepare("UPDATE environments SET directory=? WHERE id=? AND kind='ssh'").run(input.directory, id);
2957
+ if (input.hostKeys !== void 0) this.db.prepare("UPDATE environments SET host_keys=? WHERE id=? AND kind='ssh'").run(JSON.stringify(input.hostKeys), id);
2958
+ }
2959
+ /** The machine's private key (null when it doesn't open under this installation's key), its password when it is
2960
+ * reached with one (A59; `passwordUnreadable` when it is kept but doesn't open) and the accepted host keys. */
2961
+ sshSecrets(id) {
2962
+ const row = this.db.prepare("SELECT key, password, host_keys AS hostKeys FROM environments WHERE id=? AND kind='ssh' AND deleted_at IS NULL").get(id);
2963
+ if (!row) return null;
2964
+ const password = row.password ? this.cipher.open(row.password, `environment-password:${id}`) : null;
2965
+ return {
2966
+ privateKey: row.key ? this.cipher.open(row.key, `environment:${id}`) : null,
2967
+ password,
2968
+ passwordUnreadable: !!row.password && password === null,
2969
+ hostKeys: row.hostKeys ? JSON.parse(row.hostKeys) : { keys: [], fingerprints: [] }
2970
+ };
2971
+ }
2972
+ /** Whether a machine is reached with its password rather than the key (A59). */
2973
+ usesPassword(id) {
2974
+ return !!this.db.prepare("SELECT password FROM environments WHERE id=? AND kind='ssh'").get(id)?.password;
2975
+ }
2976
+ /** The Bots that may use an environment. */
2977
+ grantees(environmentId) {
2978
+ return this.db.prepare("SELECT g.bot_id AS botId FROM environment_grants g JOIN bots b ON b.id=g.bot_id WHERE g.environment_id=? AND b.deleted_at IS NULL ORDER BY g.granted_at").all(environmentId).map((row) => row.botId);
2979
+ }
2980
+ /** Deletes an SSH machine with its key: Bots working there are back in their default. Returns those Bots. */
2981
+ dropSsh(id) {
2982
+ this.db.exec("BEGIN IMMEDIATE");
2983
+ try {
2984
+ const moved = this.db.prepare("SELECT id FROM bots WHERE current_environment_id=?").all(id).map((row) => row.id);
2985
+ this.db.prepare("UPDATE bots SET current_environment_id=home_environment_id WHERE current_environment_id=?").run(id);
2986
+ this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(id);
2987
+ this.db.prepare("UPDATE environments SET deleted_at=?, key=NULL, password=NULL WHERE id=? AND kind='ssh'").run(Date.now(), id);
2988
+ this.db.exec("COMMIT");
2989
+ return moved;
2990
+ } catch (error) {
2991
+ this.db.exec("ROLLBACK");
2992
+ throw error;
2993
+ }
2994
+ }
2716
2995
  };
2717
2996
 
2718
2997
  // src/server/infra/storage/settings-repository.ts
@@ -2834,6 +3113,193 @@ var SecretRepository = class {
2834
3113
  }
2835
3114
  };
2836
3115
 
3116
+ // src/server/infra/storage/task-repository.ts
3117
+ import { randomUUID as randomUUID9 } from "node:crypto";
3118
+
3119
+ // src/server/shared/task.ts
3120
+ var TASK_MINUTES_MIN = 5;
3121
+ function localAt(day, hour, minute, addDays) {
3122
+ const at2 = new Date(day.getFullYear(), day.getMonth(), day.getDate() + addDays, hour, minute, 0, 0);
3123
+ return at2.getTime();
3124
+ }
3125
+ function nextAfter(schedule, from) {
3126
+ if (schedule.kind === "once") return schedule.at > from ? schedule.at : null;
3127
+ if (schedule.kind === "every") {
3128
+ const step = schedule.minutes * 6e4;
3129
+ return schedule.since + Math.max(1, Math.floor((from - schedule.since) / step) + 1) * step;
3130
+ }
3131
+ const day = new Date(from);
3132
+ if (schedule.kind === "daily") {
3133
+ const today = localAt(day, schedule.hour, schedule.minute, 0);
3134
+ return today > from ? today : localAt(day, schedule.hour, schedule.minute, 1);
3135
+ }
3136
+ const ahead = (schedule.weekday - day.getDay() + 7) % 7;
3137
+ const candidate = localAt(day, schedule.hour, schedule.minute, ahead);
3138
+ return candidate > from ? candidate : localAt(day, schedule.hour, schedule.minute, ahead + 7);
3139
+ }
3140
+ function describeSchedule(schedule) {
3141
+ const clock = (hour, minute) => `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
3142
+ if (schedule.kind === "once") return `once at ${localIso(schedule.at)}`;
3143
+ if (schedule.kind === "every") return schedule.minutes % 60 === 0 && schedule.minutes >= 120 ? `every ${schedule.minutes / 60} hours` : `every ${schedule.minutes} minutes`;
3144
+ if (schedule.kind === "daily") return `daily at ${clock(schedule.hour, schedule.minute)}`;
3145
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
3146
+ return `every ${days[schedule.weekday]} at ${clock(schedule.hour, schedule.minute)}`;
3147
+ }
3148
+ function localIso(time2) {
3149
+ const at2 = new Date(time2), offset = -at2.getTimezoneOffset(), sign2 = offset >= 0 ? "+" : "-", abs = Math.abs(offset);
3150
+ const pad = (value2) => String(value2).padStart(2, "0");
3151
+ return `${at2.getFullYear()}-${pad(at2.getMonth() + 1)}-${pad(at2.getDate())}T${pad(at2.getHours())}:${pad(at2.getMinutes())}:${pad(at2.getSeconds())}${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
3152
+ }
3153
+
3154
+ // src/server/infra/storage/task-repository.ts
3155
+ var COLUMNS3 = `id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
3156
+ state,next_run_at,last_run_at,created_at,updated_at`;
3157
+ var LIMIT = 200;
3158
+ function scheduleOf(row) {
3159
+ switch (row.schedule_kind) {
3160
+ case "once":
3161
+ return { kind: "once", at: row.schedule_at };
3162
+ case "every":
3163
+ return { kind: "every", minutes: row.schedule_minutes, since: row.schedule_at };
3164
+ case "daily":
3165
+ return { kind: "daily", hour: row.schedule_hour, minute: row.schedule_minute };
3166
+ case "weekly":
3167
+ return { kind: "weekly", weekday: row.schedule_weekday, hour: row.schedule_hour, minute: row.schedule_minute };
3168
+ }
3169
+ }
3170
+ function toTask(row) {
3171
+ return {
3172
+ id: row.id,
3173
+ botId: row.bot_id,
3174
+ name: row.name,
3175
+ instruction: row.instruction,
3176
+ schedule: scheduleOf(row),
3177
+ state: row.state,
3178
+ nextRunAt: row.next_run_at,
3179
+ lastRunAt: row.last_run_at,
3180
+ createdAt: row.created_at,
3181
+ updatedAt: row.updated_at
3182
+ };
3183
+ }
3184
+ function scheduleColumns(schedule) {
3185
+ return {
3186
+ kind: schedule.kind,
3187
+ at: schedule.kind === "once" ? schedule.at : schedule.kind === "every" ? schedule.since : null,
3188
+ minutes: schedule.kind === "every" ? schedule.minutes : null,
3189
+ hour: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.hour : null,
3190
+ minute: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.minute : null,
3191
+ weekday: schedule.kind === "weekly" ? schedule.weekday : null
3192
+ };
3193
+ }
3194
+ var TaskRepository = class {
3195
+ constructor(db) {
3196
+ this.db = db;
3197
+ }
3198
+ /** A Bot's tasks: active ones first, soonest first; then paused and done ones. */
3199
+ list(botId) {
3200
+ const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE bot_id=? AND deleted_at IS NULL
3201
+ ORDER BY state='active' DESC, COALESCE(next_run_at,0), created_at LIMIT ${LIMIT}`).all(botId);
3202
+ return rows2.map(toTask);
3203
+ }
3204
+ get(botId, id) {
3205
+ const row = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE id=? AND bot_id=? AND deleted_at IS NULL`).get(id, botId);
3206
+ return row ? toTask(row) : null;
3207
+ }
3208
+ create(input) {
3209
+ const now = Date.now(), at2 = nextAfter(input.schedule, now);
3210
+ if (at2 === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
3211
+ const id = randomUUID9(), columns = scheduleColumns(input.schedule);
3212
+ this.db.prepare(`INSERT INTO tasks(id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
3213
+ state,next_run_at,last_run_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,'active',?,NULL,?,?)`).run(id, input.botId, input.name, input.instruction, columns.kind, columns.at, columns.minutes, columns.hour, columns.minute, columns.weekday, at2, now, now);
3214
+ return this.get(input.botId, id);
3215
+ }
3216
+ /** Replaces what is given; a new schedule recomputes the next run from now, and turns a one-off that already ran back on. */
3217
+ update(botId, id, input) {
3218
+ const task = this.get(botId, id);
3219
+ if (!task) return null;
3220
+ const now = Date.now(), schedule = input.schedule ?? task.schedule, columns = scheduleColumns(schedule);
3221
+ if (input.schedule && nextAfter(schedule, now) === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
3222
+ const state = input.schedule && task.state === "done" ? "active" : task.state;
3223
+ const next = state !== "active" ? null : input.schedule ? nextAfter(schedule, now) : task.nextRunAt;
3224
+ this.db.prepare(`UPDATE tasks SET name=?,instruction=?,schedule_kind=?,schedule_at=?,schedule_minutes=?,schedule_hour=?,schedule_minute=?,
3225
+ schedule_weekday=?,state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?`).run(
3226
+ input.name ?? task.name,
3227
+ input.instruction ?? task.instruction,
3228
+ columns.kind,
3229
+ columns.at,
3230
+ columns.minutes,
3231
+ columns.hour,
3232
+ columns.minute,
3233
+ columns.weekday,
3234
+ state,
3235
+ next,
3236
+ now,
3237
+ id,
3238
+ botId
3239
+ );
3240
+ return this.get(botId, id);
3241
+ }
3242
+ /** Pauses or resumes it; resuming a one-off already past fails, as making one in the past does. */
3243
+ setState(botId, id, state) {
3244
+ const task = this.get(botId, id);
3245
+ if (!task) return null;
3246
+ const now = Date.now(), next = state === "active" ? nextAfter(task.schedule, now) : null;
3247
+ if (state === "active" && next === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
3248
+ this.db.prepare("UPDATE tasks SET state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?").run(state, next, now, id, botId);
3249
+ return this.get(botId, id);
3250
+ }
3251
+ /** Gone from the lists and from the scheduler; the row stays for the record. */
3252
+ remove(botId, id) {
3253
+ const now = Date.now();
3254
+ return Number(this.db.prepare("UPDATE tasks SET deleted_at=?,next_run_at=NULL,updated_at=? WHERE id=? AND bot_id=? AND deleted_at IS NULL").run(now, now, id, botId).changes) > 0;
3255
+ }
3256
+ /** The soonest time any task runs; the scheduler sleeps until then. Null when nothing is scheduled. */
3257
+ soonest() {
3258
+ const row = this.db.prepare("SELECT min(next_run_at) AS at FROM tasks WHERE deleted_at IS NULL AND state='active' AND next_run_at IS NOT NULL").get();
3259
+ return row?.at ?? null;
3260
+ }
3261
+ due(now) {
3262
+ return this.db.prepare(`SELECT ${COLUMNS3.replace(/(\w+)/g, "t.$1")} FROM tasks t JOIN bots b ON b.id=t.bot_id
3263
+ WHERE t.deleted_at IS NULL AND t.state='active' AND t.next_run_at<=? AND b.deleted_at IS NULL ORDER BY t.next_run_at`).all(now);
3264
+ }
3265
+ /** The Bots with a task due now, so the caller can find each one's DM before `fire`. */
3266
+ dueBots(now = Date.now()) {
3267
+ return [...new Set(this.due(now).map((row) => row.bot_id))];
3268
+ }
3269
+ /** The tasks that are due, oldest first, each already moved on: its inbox row (in `dms`, the Bot's DM with its owner)
3270
+ * is written and its next run set in one transaction, so a crash can neither lose nor repeat a run. A Bot missing
3271
+ * from `dms` keeps its task due for the next pass. `at` is when it was due. */
3272
+ fire(dms, now = Date.now()) {
3273
+ const due = this.due(now).filter((row) => dms.has(row.bot_id));
3274
+ if (!due.length) return [];
3275
+ this.db.exec("BEGIN IMMEDIATE");
3276
+ try {
3277
+ const fired = [];
3278
+ for (const row of due) {
3279
+ const at2 = row.next_run_at, next = nextAfter(scheduleOf(row), now);
3280
+ this.db.prepare("UPDATE tasks SET last_run_at=?,next_run_at=?,state=?,updated_at=? WHERE id=?").run(at2, next, next === null ? "done" : "active", now, row.id);
3281
+ this.db.prepare(`INSERT INTO inbox(member_id,kind,reason,urgency,chat_id,actor_id,detail,created_at) VALUES(?,'task','schedule','queue',?,NULL,?,?)`).run(row.bot_id, dms.get(row.bot_id), JSON.stringify({ task: row.id, due: at2 }), now);
3282
+ fired.push({ taskId: row.id, botId: row.bot_id, chatId: dms.get(row.bot_id), at: at2 });
3283
+ }
3284
+ this.db.exec("COMMIT");
3285
+ return fired;
3286
+ } catch (error) {
3287
+ this.db.exec("ROLLBACK");
3288
+ throw error;
3289
+ }
3290
+ }
3291
+ /** At start: tasks whose time passed while the server was not running move on to their next time; a one-off already
3292
+ * past is done without running. Returns how many were skipped. */
3293
+ skipOverdue(now = Date.now()) {
3294
+ const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE deleted_at IS NULL AND state='active' AND next_run_at<=?`).all(now);
3295
+ for (const row of rows2) {
3296
+ const next = nextAfter(scheduleOf(row), now);
3297
+ this.db.prepare("UPDATE tasks SET next_run_at=?,state=?,updated_at=? WHERE id=?").run(next, next === null ? "done" : "active", now, row.id);
3298
+ }
3299
+ return rows2.length;
3300
+ }
3301
+ };
3302
+
2837
3303
  // src/server/infra/vault/master-key.ts
2838
3304
  import { randomBytes as randomBytes2 } from "node:crypto";
2839
3305
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -2937,6 +3403,8 @@ var Store = class {
2937
3403
  agent;
2938
3404
  environments;
2939
3405
  settings;
3406
+ /** Scheduled tasks (A58). */
3407
+ tasks;
2940
3408
  secrets;
2941
3409
  /** Seals secrets and model credentials under the master key (A52). */
2942
3410
  cipher;
@@ -2958,8 +3426,9 @@ var Store = class {
2958
3426
  this.runtime = new RuntimeRepository(this.db);
2959
3427
  this.credentials = new CredentialRepository(this.db, this.cipher);
2960
3428
  this.agent = new AgentRepository(this.db, () => this.userMemberId());
2961
- this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
3429
+ this.environments = new EnvironmentRepository(this.db, this.cipher, () => this.userMemberId());
2962
3430
  this.settings = new SettingsRepository(this.db);
3431
+ this.tasks = new TaskRepository(this.db);
2963
3432
  this.secrets = new SecretRepository(this.db, this.cipher, () => this.userMemberId());
2964
3433
  this.agent.ensure();
2965
3434
  }
@@ -2970,7 +3439,7 @@ var Store = class {
2970
3439
  installationDeviceId() {
2971
3440
  const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
2972
3441
  if (row) return row.device_id;
2973
- const id = randomUUID9();
3442
+ const id = randomUUID10();
2974
3443
  this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
2975
3444
  return id;
2976
3445
  }
@@ -3042,7 +3511,7 @@ var Store = class {
3042
3511
  if (!this.getBot(id)) return false;
3043
3512
  this.db.exec("BEGIN IMMEDIATE");
3044
3513
  try {
3045
- this.db.prepare("UPDATE bots SET deleted_at=? WHERE id=?").run(Date.now(), id);
3514
+ this.db.prepare("UPDATE bots SET deleted_at=?, connection_id=NULL WHERE id=?").run(Date.now(), id);
3046
3515
  this.db.prepare("DELETE FROM chats WHERE type='dm' AND id IN (SELECT chat_id FROM chat_members WHERE member_id=?)").run(id);
3047
3516
  for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
3048
3517
  this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
@@ -3064,7 +3533,7 @@ var Store = class {
3064
3533
  /** The caller names the Bot (A45: the interface picks a free default in its own language). */
3065
3534
  createBot(name3) {
3066
3535
  const clean = this.botName(name3);
3067
- const id = randomUUID9(), createdAt = Date.now();
3536
+ const id = randomUUID10(), createdAt = Date.now();
3068
3537
  this.db.exec("BEGIN IMMEDIATE");
3069
3538
  try {
3070
3539
  this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
@@ -3136,7 +3605,7 @@ var Store = class {
3136
3605
  }
3137
3606
  const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
3138
3607
  if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
3139
- const id = randomUUID9(), now = Date.now();
3608
+ const id = randomUUID10(), now = Date.now();
3140
3609
  this.db.exec("BEGIN IMMEDIATE");
3141
3610
  try {
3142
3611
  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());
@@ -3168,10 +3637,21 @@ var Store = class {
3168
3637
  this.db.prepare("UPDATE provider_connections SET display_name=?,base_url=?,api=?,updated_at=?,provider_name=? WHERE id=?").run(name3, baseUrl, api, Date.now(), providerName, id);
3169
3638
  return this.getModelConnection(id);
3170
3639
  }
3640
+ /** Deleted Bots keep their row (deleteBot) but use nothing: their link to the connection is cleared with it. */
3171
3641
  deleteModelConnection(id) {
3172
3642
  if (!this.getModelConnection(id)) return false;
3173
- if (this.db.prepare("SELECT 1 FROM bots WHERE connection_id=? LIMIT 1").get(id)) throw new ConflictError("A Bot is using this connection");
3174
- return Number(this.db.prepare("DELETE FROM provider_connections WHERE id=?").run(id).changes) > 0;
3643
+ const users = this.db.prepare("SELECT name FROM bots WHERE connection_id=? AND deleted_at IS NULL ORDER BY name").all(id).map((row) => row.name);
3644
+ if (users.length) throw new ConflictError(`In use by ${users.join(", ")}`, "connection_in_use", { bots: users.join(", "), count: users.length });
3645
+ this.db.exec("BEGIN IMMEDIATE");
3646
+ try {
3647
+ this.db.prepare("UPDATE bots SET connection_id=NULL WHERE connection_id=? AND deleted_at IS NOT NULL").run(id);
3648
+ const deleted = Number(this.db.prepare("DELETE FROM provider_connections WHERE id=?").run(id).changes) > 0;
3649
+ this.db.exec("COMMIT");
3650
+ return deleted;
3651
+ } catch (error) {
3652
+ this.db.exec("ROLLBACK");
3653
+ throw error;
3654
+ }
3175
3655
  }
3176
3656
  saveModelCredential(id, input) {
3177
3657
  const connection = this.getModelConnection(id);
@@ -3261,10 +3741,10 @@ var set = (...items) => tlv(49, Buffer.concat(items));
3261
3741
  var integer2 = (bytes) => tlv(2, bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
3262
3742
  var bits = (bytes, unused = 0) => tlv(3, Buffer.concat([Buffer.from([unused]), bytes]));
3263
3743
  var octets = (bytes) => tlv(4, bytes);
3264
- var utf8 = (text3) => tlv(12, Buffer.from(text3, "utf8"));
3744
+ var utf8 = (text4) => tlv(12, Buffer.from(text4, "utf8"));
3265
3745
  var TRUE = tlv(1, Buffer.from([255]));
3266
- function oid(text3) {
3267
- const [a, b, ...rest] = text3.split(".").map(Number);
3746
+ function oid(text4) {
3747
+ const [a, b, ...rest] = text4.split(".").map(Number);
3268
3748
  const out = [a * 40 + b];
3269
3749
  for (const value2 of rest) {
3270
3750
  const chunk = [value2 & 127];
@@ -3281,10 +3761,10 @@ var ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
3281
3761
  var name = (common) => seq(set(seq(oid("2.5.4.10"), utf8("Jarvy"))), set(seq(oid("2.5.4.3"), utf8(common))));
3282
3762
  var extension = (id, value2, critical = false) => seq(oid(id), ...critical ? [TRUE] : [], octets(value2));
3283
3763
  var keyId = (key) => createHash2("sha1").update(key.export({ type: "spki", format: "der" })).digest();
3284
- function ipBytes(address) {
3285
- if (isIPv4(address)) return Buffer.from(address.split(".").map(Number));
3286
- if (!isIPv6(address) || address.includes(".")) return null;
3287
- const [head, tail] = address.split("::");
3764
+ function ipBytes(address2) {
3765
+ if (isIPv4(address2)) return Buffer.from(address2.split(".").map(Number));
3766
+ if (!isIPv6(address2) || address2.includes(".")) return null;
3767
+ const [head, tail] = address2.split("::");
3288
3768
  const groups = (part) => part ? part.split(":") : [];
3289
3769
  const left = groups(head), right = groups(tail);
3290
3770
  const all = tail === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
@@ -3366,8 +3846,8 @@ function read2(dir, file) {
3366
3846
  const path = join4(dir, file);
3367
3847
  return existsSync2(path) ? readFileSync2(path, "utf8") : null;
3368
3848
  }
3369
- function write(dir, file, text3, secret) {
3370
- writeFileSync2(join4(dir, file), text3, { mode: secret ? 384 : 420 });
3849
+ function write(dir, file, text4, secret) {
3850
+ writeFileSync2(join4(dir, file), text4, { mode: secret ? 384 : 420 });
3371
3851
  }
3372
3852
  function readKey(dir, name3, cipher, log) {
3373
3853
  const plain2 = read2(dir, `${name3}.pem`);
@@ -3415,7 +3895,7 @@ function certificates(dataDir, host, cipher, log = console.log) {
3415
3895
  log(`[tls] signed a server certificate for ${names.join(", ")}`);
3416
3896
  }
3417
3897
  const parsedRoot = new X509Certificate2(root.certPem);
3418
- return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, names };
3898
+ return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, fingerprintSha1: parsedRoot.fingerprint, names };
3419
3899
  }
3420
3900
 
3421
3901
  // src/server/service/account/service.ts
@@ -3847,13 +4327,13 @@ var ModelService = class {
3847
4327
  }
3848
4328
  /** Suggests model fields from Pi's catalog (with pi.dev's newer entries, A49); the caller decides whether to save them. */
3849
4329
  autoconfigure(input) {
3850
- const text3 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
3851
- const modelId = text3(input.modelId), api = text3(input.api);
4330
+ const text4 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
4331
+ const modelId = text4(input.modelId), api = text4(input.api);
3852
4332
  if (!modelId || !api) throw new RuntimeFault("autoconfig_model", "Enter the model ID.", 400);
3853
4333
  let choice;
3854
4334
  if (input.choice !== void 0) {
3855
4335
  const raw = input.choice;
3856
- const provider = text3(raw?.provider), chosen = text3(raw?.modelId);
4336
+ const provider = text4(raw?.provider), chosen = text4(raw?.modelId);
3857
4337
  if (!provider || !chosen) throw new RuntimeFault("autoconfig_source", "The setup source isn't valid.", 400);
3858
4338
  choice = { provider, modelId: chosen };
3859
4339
  }
@@ -3884,7 +4364,7 @@ var ModelService = class {
3884
4364
  };
3885
4365
 
3886
4366
  // src/server/infra/pi/auth.ts
3887
- import { randomUUID as randomUUID10 } from "node:crypto";
4367
+ import { randomUUID as randomUUID11 } from "node:crypto";
3888
4368
  import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
3889
4369
  var CredentialLogins = class {
3890
4370
  constructor(store, credentials, changed) {
@@ -3932,7 +4412,7 @@ var CredentialLogins = class {
3932
4412
  prompt(flow, prompt) {
3933
4413
  const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
3934
4414
  signal.throwIfAborted();
3935
- const id = randomUUID10();
4415
+ const id = randomUUID11();
3936
4416
  flow.state.prompt = {
3937
4417
  id,
3938
4418
  type: prompt.type,
@@ -4018,21 +4498,21 @@ var User = class extends Member {
4018
4498
 
4019
4499
  // src/server/application.ts
4020
4500
  import { createHash as createHash7 } from "node:crypto";
4021
- import { mkdir as mkdir6, realpath } from "node:fs/promises";
4022
- import { join as join12, resolve as resolve3 } from "node:path";
4501
+ import { mkdir as mkdir7, realpath } from "node:fs/promises";
4502
+ import { join as join14, resolve as resolve3 } from "node:path";
4023
4503
  import lockfile from "proper-lockfile";
4024
4504
 
4025
4505
  // src/server/service/chat/mentions.ts
4026
4506
  var WORD = /[\p{Script=Latin}\p{Nd}_]/u;
4027
4507
  function findMentions(source2, candidates2) {
4028
- const text3 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
4029
- const lower = text3.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
4508
+ const text4 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
4509
+ const lower = text4.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
4030
4510
  for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
4031
- if (at2 > 0 && WORD.test(text3[at2 - 1])) continue;
4511
+ if (at2 > 0 && WORD.test(text4[at2 - 1])) continue;
4032
4512
  const match = sorted.find((candidate) => {
4033
4513
  const name3 = candidate.name.toLowerCase();
4034
4514
  if (!name3 || !lower.startsWith(name3, at2 + 1)) return false;
4035
- const next = text3[at2 + 1 + name3.length];
4515
+ const next = text4[at2 + 1 + name3.length];
4036
4516
  return !(next && WORD.test(next) && WORD.test(name3.at(-1)));
4037
4517
  });
4038
4518
  if (match && !found.includes(match)) found.push(match);
@@ -4045,14 +4525,14 @@ function messageTargets({ dm, sender, members, mentioned, replied, subscribers }
4045
4525
  if (dm) return members.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "dm" }));
4046
4526
  const targets = mentioned.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "mention" }));
4047
4527
  const add = (member, reason) => {
4048
- if (member.id !== sender.id && !targets.some((target2) => target2.member.id === member.id)) targets.push({ member, reason });
4528
+ if (member.id !== sender.id && !targets.some((target3) => target3.member.id === member.id)) targets.push({ member, reason });
4049
4529
  };
4050
4530
  if (replied && members.some((member) => member.id === replied.id)) add(replied, "reply");
4051
4531
  for (const member of members) if (subscribers.includes(member.id)) add(member, "subscription");
4052
4532
  return targets;
4053
4533
  }
4054
4534
  function echoTargets({ sender, members, subscribers, reached }) {
4055
- return members.filter((member) => member.id !== sender.id && subscribers.includes(member.id) && !reached.some((target2) => target2.member.id === member.id)).map((member) => ({ member, reason: "subscription" }));
4535
+ return members.filter((member) => member.id !== sender.id && subscribers.includes(member.id) && !reached.some((target3) => target3.member.id === member.id)).map((member) => ({ member, reason: "subscription" }));
4056
4536
  }
4057
4537
  function reactionTargets({ actorId, author }) {
4058
4538
  return author && author.id !== actorId ? [{ member: author, reason: "author" }] : [];
@@ -4074,7 +4554,10 @@ var POLICY = {
4074
4554
  // What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
4075
4555
  // a Bot to another work environment stops its running turn like an @, so it doesn't go on in the old place (A50); a
4076
4556
  // Bot not running, and a change of access alone, hear of it with their next batch instead.
4077
- change: { agent: "digest", owner: "interrupt" }
4557
+ change: { agent: "digest", owner: "interrupt" },
4558
+ // A task that is due wakes its Bot but never interrupts it, and is not steered into a running turn: it waits for
4559
+ // the turn to end (A58).
4560
+ task: { schedule: "queue" }
4078
4561
  };
4079
4562
  function urgencyOf(kind, reason) {
4080
4563
  return POLICY[kind][reason];
@@ -4085,9 +4568,9 @@ function mutedUrgency(reason, urgency) {
4085
4568
 
4086
4569
  // src/server/service/delivery/delivery.ts
4087
4570
  function plan(kind, targets, muted = []) {
4088
- return targets.flatMap((target2) => {
4089
- const urgency = urgencyOf(kind, target2.reason);
4090
- return urgency ? [{ ...target2, kind, urgency: muted.includes(target2.member.id) ? mutedUrgency(target2.reason, urgency) : urgency }] : [];
4571
+ return targets.flatMap((target3) => {
4572
+ const urgency = urgencyOf(kind, target3.reason);
4573
+ return urgency ? [{ ...target3, kind, urgency: muted.includes(target3.member.id) ? mutedUrgency(target3.reason, urgency) : urgency }] : [];
4091
4574
  });
4092
4575
  }
4093
4576
  var Dispatcher = class {
@@ -4270,7 +4753,8 @@ function questionSpec(input) {
4270
4753
  options: parsed,
4271
4754
  multiple: input.multiple === true,
4272
4755
  allowCustom: input.allowCustom === true,
4273
- ...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) }
4756
+ ...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) },
4757
+ ...input.machine === void 0 ? {} : { machine: machineAsk(input.machine) }
4274
4758
  };
4275
4759
  }
4276
4760
  function secretAsk(value2) {
@@ -4278,6 +4762,22 @@ function secretAsk(value2) {
4278
4762
  if (!ask || typeof ask !== "object" || typeof ask.name !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(ask.name) || !text(ask.purpose, 300) || typeof ask.exists !== "boolean" || typeof ask.needsValue !== "boolean") throw new ChatError(400, "Invalid secret request");
4279
4763
  return { name: ask.name, purpose: ask.purpose.trim(), exists: ask.exists, needsValue: ask.needsValue };
4280
4764
  }
4765
+ function machineAsk(value2) {
4766
+ const ask = value2, keys = ask?.hostKeys;
4767
+ const strings = (list) => Array.isArray(list) && list.length <= 20 && list.every((item) => text(item, 2e3));
4768
+ if (!ask || typeof ask !== "object" || !text(ask.name, 40) || !text(ask.host, 253) || !text(ask.user, 64) || !Number.isInteger(ask.port) || !text(ask.purpose, 300) || ask.directory !== null && !text(ask.directory, 1e3) || !keys || !strings(keys.keys) || !strings(keys.fingerprints)) throw new ChatError(400, "Invalid machine request");
4769
+ const existing = ask.existing;
4770
+ return {
4771
+ name: ask.name,
4772
+ host: ask.host,
4773
+ port: ask.port,
4774
+ user: ask.user,
4775
+ directory: ask.directory,
4776
+ purpose: ask.purpose.trim(),
4777
+ hostKeys: { keys: keys.keys, fingerprints: keys.fingerprints },
4778
+ existing: existing && typeof existing.id === "string" && typeof existing.name === "string" ? { id: existing.id, name: existing.name } : null
4779
+ };
4780
+ }
4281
4781
  function answerOf(question, input) {
4282
4782
  const selected = input.selected ?? [], custom = input.custom === void 0 || input.custom === null || input.custom === "" ? null : input.custom;
4283
4783
  if (!Array.isArray(selected) || new Set(selected).size !== selected.length || !selected.every((index) => Number.isInteger(index) && index >= 0 && index < question.options.length)) throw new ChatError(400, "Invalid selection");
@@ -4619,9 +5119,9 @@ var ChatService = class {
4619
5119
  /** A DM with another member of the actor's space (a Bot DMs its owner or another Bot); one per pair. */
4620
5120
  openDirect(actorId, targetId) {
4621
5121
  const owner = this.repository.channels.ownerOf(actorId), directory = this.repository.channels.directory(owner);
4622
- const actor = directory.find((member) => member.id === actorId), target2 = directory.find((member) => member.id === targetId);
4623
- if (!actor || !target2) throw new ChatError(404, "Member not found");
4624
- const chat = this.repository.openDirect(actorId, targetId, owner, `${actor.name} \xB7 ${target2.name}`);
5122
+ const actor = directory.find((member) => member.id === actorId), target3 = directory.find((member) => member.id === targetId);
5123
+ if (!actor || !target3) throw new ChatError(404, "Member not found");
5124
+ const chat = this.repository.openDirect(actorId, targetId, owner, `${actor.name} \xB7 ${target3.name}`);
4625
5125
  this.notifySaved(chat.id);
4626
5126
  return chat;
4627
5127
  }
@@ -4668,6 +5168,15 @@ var ChatService = class {
4668
5168
  if (added) this.dispatcher.dispatch(deliveries);
4669
5169
  return this.repository.message(actorId, messageId);
4670
5170
  }
5171
+ /** Folds a message to one line in the owner's interface, or unfolds it (A60). Nobody is told: a Bot reads it as before. */
5172
+ fold(actorId, messageId, folded) {
5173
+ if (typeof folded !== "boolean") throw new ChatError(400, "Invalid folded state");
5174
+ const message = this.repository.message(actorId, messageId);
5175
+ if (!message) throw new ChatError(404, "Message not found");
5176
+ this.repository.interactions.setFolded(messageId, folded);
5177
+ this.notifySaved(message.chatId);
5178
+ return this.repository.message(actorId, messageId);
5179
+ }
4671
5180
  mute(actorId, chatId, muted) {
4672
5181
  if (typeof muted !== "boolean") throw new ChatError(400, "Invalid muted");
4673
5182
  this.repository.interactions.setMuted(actorId, chatId, muted);
@@ -4692,10 +5201,11 @@ var ChatService = class {
4692
5201
  unreadMessages(actorId, chatId, limit) {
4693
5202
  return this.repository.unreadMessages(actorId, chatId, limit);
4694
5203
  }
4695
- searchAll(actorId, query, limit = 30, within) {
5204
+ /** `sender`: only that member's messages (a Bot looking for what it sent itself). */
5205
+ searchAll(actorId, query, limit = 30, within, sender) {
4696
5206
  if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
4697
5207
  if (within) this.repository.assertAccess(actorId, within);
4698
- return this.repository.searchAll(actorId, query.trim(), limit, within);
5208
+ return this.repository.searchAll(actorId, query.trim(), limit, within, sender);
4699
5209
  }
4700
5210
  /** The search box: channels and threads by title, topic or notice / summary, and messages by text, with the chat
4701
5211
  * each message is in. `within` searches one chat (a channel with its threads) and returns no chats. */
@@ -4766,9 +5276,9 @@ var ChatService = class {
4766
5276
  message(actorId, id) {
4767
5277
  return this.repository.message(actorId, id);
4768
5278
  }
4769
- search(actorId, chatId, query, limit = 30) {
5279
+ search(actorId, chatId, query, limit = 30, sender) {
4770
5280
  if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
4771
- return this.repository.search(actorId, chatId, query, limit);
5281
+ return this.repository.search(actorId, chatId, query, limit, sender);
4772
5282
  }
4773
5283
  notifySaved(chatId) {
4774
5284
  try {
@@ -4889,8 +5399,8 @@ function activityPage(messages, history, limit) {
4889
5399
  }
4890
5400
 
4891
5401
  // src/server/service/bot/service.ts
4892
- import { dirname as dirname8, join as join11 } from "node:path";
4893
- import { rm as rm3 } from "node:fs/promises";
5402
+ import { dirname as dirname9, join as join13 } from "node:path";
5403
+ import { rm as rm4 } from "node:fs/promises";
4894
5404
 
4895
5405
  // src/server/infra/pi/history.ts
4896
5406
  var value = (input) => JSON.parse(JSON.stringify(input));
@@ -4954,9 +5464,9 @@ var PiHistoryAdapter = class {
4954
5464
  if (m.role === "system") {
4955
5465
  if (m.sections) {
4956
5466
  const sections = {};
4957
- for (const [name3, text3] of Object.entries(m.sections)) {
4958
- sections[name3] = text3 === null ? null : message.content.length;
4959
- if (text3 !== null) message.content.push({ type: "text", text: text3 });
5467
+ for (const [name3, text4] of Object.entries(m.sections)) {
5468
+ sections[name3] = text4 === null ? null : message.content.length;
5469
+ if (text4 !== null) message.content.push({ type: "text", text: text4 });
4960
5470
  }
4961
5471
  pi.sections = sections;
4962
5472
  }
@@ -5029,6 +5539,99 @@ function reportedContext(branch) {
5029
5539
  return { tokens: null, stale: false };
5030
5540
  }
5031
5541
 
5542
+ // src/server/shared/prompt.ts
5543
+ var joinPrompt = (parts) => parts.map((part) => part.text).join("");
5544
+
5545
+ // src/server/infra/pi/prompt-change.ts
5546
+ var CHANGE_LINES = 40;
5547
+ var CONTEXT_LINES = 2;
5548
+ var TEXT_LIMIT = 8e3;
5549
+ var DIFF_LIMIT = 1500;
5550
+ function lineDiff(before, after2) {
5551
+ const rows2 = before.length, columns = after2.length;
5552
+ const lcs = Array.from({ length: rows2 + 1 }, () => new Uint32Array(columns + 1));
5553
+ for (let i2 = rows2 - 1; i2 >= 0; i2--) for (let j2 = columns - 1; j2 >= 0; j2--)
5554
+ lcs[i2][j2] = before[i2] === after2[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
5555
+ const out = [];
5556
+ let i = 0, j = 0;
5557
+ while (i < rows2 && j < columns) {
5558
+ if (before[i] === after2[j]) {
5559
+ out.push({ kind: " ", text: after2[j] });
5560
+ i++;
5561
+ j++;
5562
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ kind: "-", text: before[i++] });
5563
+ else out.push({ kind: "+", text: after2[j++] });
5564
+ }
5565
+ while (i < rows2) out.push({ kind: "-", text: before[i++] });
5566
+ while (j < columns) out.push({ kind: "+", text: after2[j++] });
5567
+ return out;
5568
+ }
5569
+ function hunks(lines) {
5570
+ const keep = /* @__PURE__ */ new Set();
5571
+ lines.forEach((line, index) => {
5572
+ if (line.kind === " ") return;
5573
+ for (let at2 = Math.max(0, index - CONTEXT_LINES); at2 <= Math.min(lines.length - 1, index + CONTEXT_LINES); at2++) keep.add(at2);
5574
+ });
5575
+ const out = [];
5576
+ let last = -1;
5577
+ for (const index of [...keep].sort((a, b) => a - b)) {
5578
+ if (last >= 0 && index > last + 1) out.push("\u2026");
5579
+ const line = lines[index];
5580
+ out.push(`${line.kind}${line.text}`);
5581
+ last = index;
5582
+ }
5583
+ return out;
5584
+ }
5585
+ function promptChange(before, after2) {
5586
+ if (joinPrompt(before) === joinPrompt(after2)) return null;
5587
+ const next = after2;
5588
+ const old = new Map(before.map((part) => [part.name, part.text]));
5589
+ const changed = after2.filter((part) => (old.get(part.name) ?? "") !== part.text);
5590
+ const gone = before.filter((part) => !after2.some((next2) => next2.name === part.name));
5591
+ const parts = [...changed.map((part) => part.name), ...gone.map((part) => part.name)];
5592
+ const lines = [];
5593
+ let tooLong = false;
5594
+ for (const name3 of parts) {
5595
+ const from = (old.get(name3) ?? "").split("\n"), to = (after2.find((part) => part.name === name3)?.text ?? "").split("\n");
5596
+ if (from.length > DIFF_LIMIT || to.length > DIFF_LIMIT) {
5597
+ tooLong = true;
5598
+ break;
5599
+ }
5600
+ lines.push(`@@ ${name3}`, ...hunks(lineDiff(from, to)));
5601
+ if (lines.length > CHANGE_LINES) {
5602
+ tooLong = true;
5603
+ break;
5604
+ }
5605
+ }
5606
+ const cut = (text4) => text4.length > TEXT_LIMIT ? `${text4.slice(0, TEXT_LIMIT)}\u2026` : text4;
5607
+ if (!tooLong) return { parts, diff: cut(lines.join("\n")), whole: false, next };
5608
+ return { parts, diff: cut(changed.map((part) => `@@ ${part.name}
5609
+ ${part.text.trim()}`).join("\n\n")), whole: true, next };
5610
+ }
5611
+ var PromptLog = class {
5612
+ adopted;
5613
+ told;
5614
+ constructor(now) {
5615
+ this.adopted = this.told = now();
5616
+ }
5617
+ /** The prompt every request goes out with, until a change is taken up. */
5618
+ get current() {
5619
+ return joinPrompt(this.adopted);
5620
+ }
5621
+ /** What to tell the model with the next batch: how the live prompt differs from the newest one it knows. */
5622
+ pending(now) {
5623
+ return promptChange(this.told, now());
5624
+ }
5625
+ /** The model was told: the next change is measured from here. */
5626
+ sent(change) {
5627
+ this.told = change.next;
5628
+ }
5629
+ /** A new model or a compaction: the live prompt goes out from now on, with nothing left to tell. */
5630
+ take(now) {
5631
+ this.adopted = this.told = now();
5632
+ }
5633
+ };
5634
+
5032
5635
  // src/server/infra/pi/http.ts
5033
5636
  import { EventEmitter } from "node:events";
5034
5637
  import * as undici from "undici";
@@ -5128,7 +5731,7 @@ import { StringDecoder } from "node:string_decoder";
5128
5731
  var placeholder = (name3) => `{{secret:${name3}}}`;
5129
5732
  var MIN_MASKED = 4;
5130
5733
  var MIN_FRAGMENT = 8;
5131
- var xml = (text3) => text3.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5734
+ var xml = (text4) => text4.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5132
5735
  function base64Fragments(value2) {
5133
5736
  const bytes = Buffer.from(value2, "utf8"), found = [];
5134
5737
  for (const offset of [0, 1, 2]) {
@@ -5157,8 +5760,8 @@ var Masker = class {
5157
5760
  get empty() {
5158
5761
  return this.pairs.length === 0;
5159
5762
  }
5160
- mask(text3) {
5161
- let result = text3;
5763
+ mask(text4) {
5764
+ let result = text4;
5162
5765
  for (const { form, name: name3 } of this.pairs) if (result.includes(form)) result = result.split(form).join(placeholder(name3));
5163
5766
  return result;
5164
5767
  }
@@ -5240,9 +5843,9 @@ var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
5240
5843
  var u32be = (bytes, offset) => at(bytes, offset) * 16777216 + (at(bytes, offset + 1) << 16) + (at(bytes, offset + 2) << 8) + at(bytes, offset + 3);
5241
5844
  var u32le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8) + (at(bytes, offset + 2) << 16) + at(bytes, offset + 3) * 16777216;
5242
5845
  var startsWith = (bytes, prefix) => bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte);
5243
- function ascii(bytes, offset, text3) {
5244
- if (bytes.length < offset + text3.length) return false;
5245
- for (let index = 0; index < text3.length; index++) if (bytes[offset + index] !== text3.charCodeAt(index)) return false;
5846
+ function ascii(bytes, offset, text4) {
5847
+ if (bytes.length < offset + text4.length) return false;
5848
+ for (let index = 0; index < text4.length; index++) if (bytes[offset + index] !== text4.charCodeAt(index)) return false;
5246
5849
  return true;
5247
5850
  }
5248
5851
 
@@ -5364,7 +5967,7 @@ function environmentTools(lookup, secrets) {
5364
5967
  const access = async (path) => {
5365
5968
  if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
5366
5969
  };
5367
- const readFile3 = (path) => current().readFile(path), writeFile5 = (path, content2) => current().writeFile(path, content2);
5970
+ const readFile4 = (path) => current().readFile(path), writeFile5 = (path, content2) => current().writeFile(path, content2);
5368
5971
  const cwd = current().cwd;
5369
5972
  const definitions2 = [
5370
5973
  relocated(createBashToolDefinition(cwd, { operations: {
@@ -5387,9 +5990,9 @@ function environmentTools(lookup, secrets) {
5387
5990
  }
5388
5991
  } }), current),
5389
5992
  // Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
5390
- createReadToolDefinition(cwd, { operations: { readFile: readFile3, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
5993
+ createReadToolDefinition(cwd, { operations: { readFile: readFile4, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
5391
5994
  createWriteToolDefinition(cwd, { operations: { writeFile: writeFile5, mkdir: (path) => current().mkdir(path) } }),
5392
- createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile: writeFile5, access } })
5995
+ createEditToolDefinition(cwd, { operations: { readFile: readFile4, writeFile: writeFile5, access } })
5393
5996
  ];
5394
5997
  return definitions2.map((definition) => inside(definition, lookup, pinned));
5395
5998
  }
@@ -5397,7 +6000,7 @@ function inside(definition, lookup, pinned) {
5397
6000
  const execute = definition.execute.bind(definition);
5398
6001
  return { ...definition, execute: ((...args) => {
5399
6002
  const env = lookup(), ctx = args[4];
5400
- if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target2, key) => key === "cwd" ? env.cwd : Reflect.get(target2, key, target2) });
6003
+ if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target3, key) => key === "cwd" ? env.cwd : Reflect.get(target3, key, target3) });
5401
6004
  return pinned.run({ env, toolCallId: typeof args[0] === "string" ? args[0] : null }, () => execute(...args));
5402
6005
  }) };
5403
6006
  }
@@ -5417,12 +6020,12 @@ function relocated(definition, current) {
5417
6020
  try {
5418
6021
  const result = await execute(...args), path = result.details?.fullOutputPath;
5419
6022
  if (typeof path !== "string") return result;
5420
- const target2 = await moveOut(path, env), swap = (text3) => text3?.replaceAll(path, target2);
6023
+ const target3 = await moveOut(path, env), swap = (text4) => text4?.replaceAll(path, target3);
5421
6024
  return {
5422
6025
  ...result,
5423
6026
  content: result.content.map((part) => part.type === "text" ? { ...part, text: swap(part.text) } : part),
5424
- details: { ...result.details, fullOutputPath: target2 },
5425
- ...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target2 } } : {}
6027
+ details: { ...result.details, fullOutputPath: target3 },
6028
+ ...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target3 } } : {}
5426
6029
  };
5427
6030
  } catch (error) {
5428
6031
  const path = error instanceof Error ? /Full output: (\S+?)\]/.exec(error.message)?.[1] : void 0;
@@ -5432,10 +6035,10 @@ function relocated(definition, current) {
5432
6035
  }) };
5433
6036
  }
5434
6037
  async function moveOut(path, env) {
5435
- const target2 = `${env.cwd}/temp/${basename(path)}`;
5436
- await env.writeFile(target2, await readLocal(path));
6038
+ const target3 = `${env.temp ?? `${env.cwd}/temp`}/${basename(path)}`;
6039
+ await env.writeFile(target3, await readLocal(path));
5437
6040
  await rm(path, { force: true });
5438
- return target2;
6041
+ return target3;
5439
6042
  }
5440
6043
  function defined(vars) {
5441
6044
  return Object.fromEntries(Object.entries(vars).filter((entry) => entry[1] !== void 0));
@@ -5466,6 +6069,7 @@ var PiSession = class _PiSession {
5466
6069
  result = { outcome: "completed" };
5467
6070
  signature = "";
5468
6071
  translator = new LiveTranslator();
6072
+ prompts;
5469
6073
  get context() {
5470
6074
  const usage = this.session.getContextUsage();
5471
6075
  if (!usage) return void 0;
@@ -5489,7 +6093,7 @@ var PiSession = class _PiSession {
5489
6093
  if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
5490
6094
  throw new RuntimeFault("session_path", "The session file path isn't valid.");
5491
6095
  const exists = existsSync3(reference.file);
5492
- if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run) => run.state === "unknown")))
6096
+ if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run2) => run2.state === "unknown")))
5493
6097
  throw new RuntimeFault("session_missing", "The session file is missing; check the run records before recovering.");
5494
6098
  if (exists) {
5495
6099
  let header;
@@ -5512,6 +6116,7 @@ var PiSession = class _PiSession {
5512
6116
  const model = installModel(models, connection, configured2, store.modelHeaders(connection.id, configured2.id));
5513
6117
  const sessionManager = SessionManager.open(reference.file, sessionsDir, bot.workspacePath);
5514
6118
  const adapter = new _PiSession(options, models, credentials);
6119
+ adapter.prompts = new PromptLog(options.systemPrompt);
5515
6120
  configureHttp(HTTP_IDLE_TIMEOUT_MS);
5516
6121
  const settingsManager = SettingsManager.inMemory({
5517
6122
  httpIdleTimeoutMs: HTTP_IDLE_TIMEOUT_MS,
@@ -5526,7 +6131,7 @@ var PiSession = class _PiSession {
5526
6131
  noSkills: true,
5527
6132
  noPromptTemplates: true,
5528
6133
  noContextFiles: true,
5529
- systemPromptOverride: options.systemPrompt,
6134
+ systemPromptOverride: () => adapter.prompts.current,
5530
6135
  appendSystemPromptOverride: () => [],
5531
6136
  extensionFactories: [createCodemodeExtension(), (api) => {
5532
6137
  for (const tool of options.tools) api.registerTool({
@@ -5544,17 +6149,17 @@ var PiSession = class _PiSession {
5544
6149
  return { content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }], details: {} };
5545
6150
  }
5546
6151
  });
5547
- api.on("before_agent_start", () => ({ systemPrompt: options.systemPrompt() }));
6152
+ api.on("before_agent_start", () => ({ systemPrompt: adapter.prompts.current }));
5548
6153
  api.on("tool_result", (event) => {
5549
6154
  const masker = options.secrets?.masker();
5550
6155
  if (!masker || masker.empty) return void 0;
5551
6156
  let changed = false;
5552
6157
  const content2 = event.content.map((part) => {
5553
6158
  if (part.type !== "text") return part;
5554
- const text3 = masker.mask(part.text);
5555
- if (text3 === part.text) return part;
6159
+ const text4 = masker.mask(part.text);
6160
+ if (text4 === part.text) return part;
5556
6161
  changed = true;
5557
- return { ...part, text: text3 };
6162
+ return { ...part, text: text4 };
5558
6163
  });
5559
6164
  if (!changed) return void 0;
5560
6165
  return { content: content2, ...event.structuredContent !== void 0 ? { structuredContent: masker.maskDeep(event.structuredContent) } : {} };
@@ -5624,6 +6229,7 @@ var PiSession = class _PiSession {
5624
6229
  this.options.activity("compacting");
5625
6230
  console.log("[pi] compaction started", { botId: this.options.bot.id, reason: event.reason });
5626
6231
  }
6232
+ if (event.type === "compaction_end" && event.result && !event.aborted && !event.errorMessage) this.prompts.take(this.options.systemPrompt);
5627
6233
  if (event.type === "compaction_end") console.log(
5628
6234
  event.errorMessage ? "[pi] compaction failed" : "[pi] compaction finished",
5629
6235
  {
@@ -5718,12 +6324,19 @@ var PiSession = class _PiSession {
5718
6324
  this.settings.applyOverrides({ compaction: { reserveTokens: compactionReserve(model.contextWindow) } });
5719
6325
  this.session.setThinkingLevel(resolveThinking(model, selection.thinkingLevel), { persist: false });
5720
6326
  this.signature = signature;
6327
+ this.prompts.take(this.options.systemPrompt);
5721
6328
  return this.session.thinkingLevel;
5722
6329
  } catch {
5723
6330
  this.credentials.current = previous;
5724
6331
  throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.", 409);
5725
6332
  }
5726
6333
  }
6334
+ promptChange() {
6335
+ return this.prompts.pending(this.options.systemPrompt);
6336
+ }
6337
+ promptTold(change) {
6338
+ this.prompts.sent(change);
6339
+ }
5727
6340
  async dispose() {
5728
6341
  this.requestAbort();
5729
6342
  await this.abortTask;
@@ -5771,12 +6384,13 @@ function overDurable(id, kind, env, options) {
5771
6384
  id,
5772
6385
  kind,
5773
6386
  cwd: env.cwd,
6387
+ ...options.temp ? { temp: options.temp } : {},
5774
6388
  async exec(command, exec) {
5775
6389
  const result = await env.exec(command, {
5776
6390
  cwd: exec.cwd,
5777
6391
  ...options.passEnv && exec.env ? { env: { ...exec.env, ...exec.secrets }, inheritEnv: false } : { ...exec.secrets ? { env: exec.secrets } : {}, inheritEnv: true },
5778
6392
  ...exec.timeout ? { timeout: exec.timeout } : {},
5779
- onOutput: (text3) => exec.onData(Buffer.from(text3))
6393
+ onOutput: (text4) => exec.onData(Buffer.from(text4))
5780
6394
  }, context(exec.signal));
5781
6395
  if (result.ok) return { exitCode: result.value.exitCode };
5782
6396
  if (result.error.code === "aborted") throw new Error("aborted");
@@ -5950,16 +6564,16 @@ async function removeContainer(name3) {
5950
6564
  async function syncDaemon(dir) {
5951
6565
  const arch = process.arch === "arm64" ? "arm64" : "x64";
5952
6566
  const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
5953
- const target2 = join6(dir, "pi-env");
5954
- const current = await readFile2(target2).catch(() => null);
6567
+ const target3 = join6(dir, "pi-env");
6568
+ const current = await readFile2(target3).catch(() => null);
5955
6569
  if (current?.equals(wanted)) return;
5956
6570
  await mkdir3(dir, { recursive: true, mode: 448 });
5957
- const temp = `${target2}.${process.pid}.tmp`;
6571
+ const temp = `${target3}.${process.pid}.tmp`;
5958
6572
  await writeFile2(temp, wanted, { mode: 493 });
5959
- await rename2(temp, target2);
6573
+ await rename2(temp, target3);
5960
6574
  }
5961
6575
  function containerEnvironment(id, container, log) {
5962
- const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text3) => log(text3.trim()) });
6576
+ const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text4) => log(text4.trim()) });
5963
6577
  return overDurable(id, "computer", new RemoteExecutionEnv({ connection, id, cwd: COMPUTER_CWD }), { passEnv: false, close: () => connection.close() });
5964
6578
  }
5965
6579
 
@@ -5972,32 +6586,35 @@ function hostEnvironment(cwd) {
5972
6586
  // src/server/service/bot/environments.ts
5973
6587
  var ABOUT = {
5974
6588
  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.",
5975
- 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."
6589
+ host: "The machine where this chat platform's server runs. 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."
5976
6590
  };
5977
6591
  var BotEnvironments = class {
5978
- constructor(store, computers) {
6592
+ constructor(store, computers, machines) {
5979
6593
  this.store = store;
5980
6594
  this.computers = computers;
6595
+ this.machines = machines;
5981
6596
  }
5982
6597
  open = /* @__PURE__ */ new Map();
5983
6598
  current(botId, workspacePath) {
5984
- const target2 = this.store.environments.placement(botId).current;
6599
+ const target3 = this.store.environments.placement(botId).current, signature = this.signature(target3);
5985
6600
  const open = this.open.get(botId);
5986
- if (open?.id === target2.id) return open.view;
6601
+ if (open?.id === signature) return open.view;
5987
6602
  if (open) this.retire(open);
5988
- const opened = this.track(target2.id, target2.kind === "computer" ? this.computers.environment(target2.botId ?? botId, target2) : hostEnvironment(workspacePath));
6603
+ const opened = this.track(signature, target3.kind === "computer" ? this.computers.environment(target3.botId ?? botId, target3) : target3.kind === "ssh" ? this.machines.environment(target3, botId) : hostEnvironment(workspacePath));
5989
6604
  this.open.set(botId, opened);
5990
6605
  return opened.view;
5991
6606
  }
5992
6607
  placement(botId) {
5993
6608
  const environments = this.store.environments, { home, current } = environments.placement(botId);
5994
- const hostId = environments.host().id;
6609
+ const hostId = environments.host().id, granted = new Set(environments.grants(botId).map((grant) => grant.id));
5995
6610
  return {
5996
- home: home.kind,
6611
+ home: home.kind === "computer" ? "computer" : "host",
5997
6612
  current: current.kind,
6613
+ ...current.kind === "ssh" ? { currentName: current.name } : {},
5998
6614
  computer: this.computers.status(botId),
5999
6615
  docker: this.computers.available,
6000
- hostGranted: environments.grants(botId).some((grant) => grant.id === hostId)
6616
+ hostGranted: granted.has(hostId),
6617
+ machines: environments.sshMachines().map((machine) => ({ id: machine.id, name: machine.name, granted: granted.has(machine.id) }))
6001
6618
  };
6002
6619
  }
6003
6620
  /** The environments the Bot may be in: its sandbox, its default, and the ones its owner granted it. */
@@ -6008,11 +6625,12 @@ var BotEnvironments = class {
6008
6625
  }
6009
6626
  place(botId, record3, workspacePath) {
6010
6627
  if (record3.kind === "host") return { id: record3.id, kind: "host", name: "your owner's computer", directory: workspacePath };
6628
+ if (record3.kind === "ssh") return { id: record3.id, kind: "ssh", name: record3.name, directory: record3.directory ?? record3.spec.home ?? "/" };
6011
6629
  return { id: record3.id, kind: "sandbox", name: record3.botId === botId ? "your sandbox" : "another Bot's sandbox", directory: COMPUTER_CWD };
6012
6630
  }
6013
6631
  /** A Bot's environments as other Bots see them in bots.list. */
6014
6632
  summary(botId) {
6015
- const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
6633
+ const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.kind === "ssh" ? record3.name : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
6016
6634
  const { home, current } = this.store.environments.placement(botId);
6017
6635
  return { default: name3(home), current: name3(current), usable: this.usable(botId).map(name3) };
6018
6636
  }
@@ -6030,18 +6648,37 @@ var BotEnvironments = class {
6030
6648
  return { current: current.id, environments: this.usable(botId).map((record3) => ({
6031
6649
  ...this.place(botId, record3, workspacePath),
6032
6650
  ...record3.id === home.id ? { default: true } : {},
6033
- ...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {}
6651
+ ...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {},
6652
+ ...record3.kind === "ssh" ? { address: this.machines.address(record3) } : {}
6034
6653
  })) };
6035
6654
  }
6036
- /** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first; if it
6037
- * can't start, the Bot stays where it was. */
6655
+ /** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first, an SSH
6656
+ * machine connected first; if that fails, the Bot stays where it was. */
6038
6657
  async switch(botId, environmentId, workspacePath) {
6039
- const target2 = this.usable(botId).find((record3) => record3.id === environmentId);
6040
- if (!target2) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
6041
- const already = this.store.environments.placement(botId).current.id === target2.id;
6042
- if (target2.kind === "computer") await this.computers.ensure(target2.botId ?? botId);
6043
- this.store.environments.setCurrent(botId, target2.id);
6044
- return { ...this.place(botId, target2, workspacePath), about: ABOUT[target2.kind], ...already ? { note: "You were already here." } : {} };
6658
+ const target3 = this.usable(botId).find((record3) => record3.id === environmentId);
6659
+ if (!target3) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
6660
+ const previous = this.store.environments.placement(botId).current, already = previous.id === target3.id;
6661
+ if (target3.kind === "computer") await this.computers.ensure(target3.botId ?? botId);
6662
+ this.store.environments.setCurrent(botId, target3.id);
6663
+ if (target3.kind === "ssh" && !already) {
6664
+ const place = this.place(botId, target3, workspacePath);
6665
+ try {
6666
+ if (!await this.current(botId, workspacePath).exists(place.directory)) throw new Error(`${place.directory} doesn't exist there; ask your owner to change the machine's directory, or switch back`);
6667
+ } catch (error) {
6668
+ const opened = this.open.get(botId);
6669
+ if (opened) {
6670
+ this.open.delete(botId);
6671
+ this.retire(opened);
6672
+ }
6673
+ this.store.environments.setCurrent(botId, previous.id);
6674
+ throw new ChatError(409, `Couldn't connect to ${target3.name}, so you stay where you were: ${error instanceof Error ? error.message : String(error)}`);
6675
+ }
6676
+ }
6677
+ return {
6678
+ ...this.place(botId, target3, workspacePath),
6679
+ about: target3.kind === "ssh" ? this.machines.about(target3, botId) : ABOUT[target3.kind],
6680
+ ...already ? { note: "You were already here." } : {}
6681
+ };
6045
6682
  }
6046
6683
  /** The owner makes the Bot's sandbox (given it on first use) or their computer its default, and moves it there. */
6047
6684
  setHome(botId, kind, workspacePath) {
@@ -6058,15 +6695,18 @@ var BotEnvironments = class {
6058
6695
  const to = this.here(botId, workspacePath);
6059
6696
  return from.id === to.id ? null : { from, to };
6060
6697
  }
6061
- /** The owner grants or takes back their computer (A50); taking it back while the Bot works there sends it home. */
6062
- grantHost(botId, granted, by, workspacePath) {
6063
- const environments = this.store.environments, host = environments.host();
6064
- if (granted) return { changed: environments.grant(host.id, botId, by), moved: null };
6065
- const changed = environments.revoke(host.id, botId), { home, current } = environments.placement(botId);
6066
- if (current.id !== host.id || home.id === host.id) return { changed, moved: null };
6698
+ /** The owner grants or takes back their computer (A50) or an SSH machine (A57); taking it back while the Bot works
6699
+ * there sends it home. */
6700
+ grant(botId, environmentId, granted, by, workspacePath) {
6701
+ const environments = this.store.environments, target3 = environmentId === "host" ? environments.host() : environments.get(environmentId);
6702
+ if (!target3 || target3.kind === "computer") throw new ChatError(404, "No such environment");
6703
+ const name3 = this.place(botId, target3, workspacePath).name;
6704
+ if (granted) return { changed: environments.grant(target3.id, botId, by), moved: null, name: name3 };
6705
+ const changed = environments.revoke(target3.id, botId), { home, current } = environments.placement(botId);
6706
+ if (current.id !== target3.id || home.id === target3.id) return { changed, moved: null, name: name3 };
6067
6707
  const from = this.here(botId, workspacePath);
6068
6708
  environments.setCurrent(botId, home.id);
6069
- return { changed, moved: { from, to: this.here(botId, workspacePath) } };
6709
+ return { changed, moved: { from, to: this.here(botId, workspacePath) }, name: name3 };
6070
6710
  }
6071
6711
  async release(botId) {
6072
6712
  const open = this.open.get(botId);
@@ -6080,6 +6720,10 @@ var BotEnvironments = class {
6080
6720
  await Promise.all(all.map((open) => open.env.close()));
6081
6721
  await this.computers.close();
6082
6722
  }
6723
+ /** What an open environment is for: changing a machine's directory reopens it where the owner put it. */
6724
+ signature(record3) {
6725
+ return record3.kind === "ssh" ? `${record3.id}@${record3.directory ?? ""}` : record3.id;
6726
+ }
6083
6727
  retire(open) {
6084
6728
  open.retired = true;
6085
6729
  if (!open.busy) void open.env.close();
@@ -6087,7 +6731,7 @@ var BotEnvironments = class {
6087
6731
  /** Counts the operations running on an environment, so one the Bot has left closes only when they are done. */
6088
6732
  track(id, env) {
6089
6733
  const opened = { id, env, view: env, busy: 0, retired: false };
6090
- const run = async (work) => {
6734
+ const run2 = async (work) => {
6091
6735
  opened.busy++;
6092
6736
  try {
6093
6737
  return await work();
@@ -6099,13 +6743,14 @@ var BotEnvironments = class {
6099
6743
  id: env.id,
6100
6744
  kind: env.kind,
6101
6745
  cwd: env.cwd,
6102
- exec: (command, options) => run(() => env.exec(command, options)),
6103
- readFile: (path) => run(() => env.readFile(path)),
6104
- head: (path, bytes) => run(() => env.head(path, bytes)),
6105
- writeFile: (path, content2) => run(() => env.writeFile(path, content2)),
6106
- mkdir: (path) => run(() => env.mkdir(path)),
6107
- exists: (path) => run(() => env.exists(path)),
6108
- fileSize: (path) => run(() => env.fileSize(path)),
6746
+ ...env.temp ? { temp: env.temp } : {},
6747
+ exec: (command, options) => run2(() => env.exec(command, options)),
6748
+ readFile: (path) => run2(() => env.readFile(path)),
6749
+ head: (path, bytes) => run2(() => env.head(path, bytes)),
6750
+ writeFile: (path, content2) => run2(() => env.writeFile(path, content2)),
6751
+ mkdir: (path) => run2(() => env.mkdir(path)),
6752
+ exists: (path) => run2(() => env.exists(path)),
6753
+ fileSize: (path) => run2(() => env.fileSize(path)),
6109
6754
  close: () => env.close()
6110
6755
  };
6111
6756
  return opened;
@@ -6174,7 +6819,7 @@ var Computers = class {
6174
6819
  }
6175
6820
  /** What the Bot's tools use while it works on its computer: every call first makes sure the container runs. */
6176
6821
  environment(botId, record3) {
6177
- const run = async (op) => {
6822
+ const run2 = async (op) => {
6178
6823
  try {
6179
6824
  return await op(await this.connect(botId, record3));
6180
6825
  } catch (error) {
@@ -6188,13 +6833,13 @@ var Computers = class {
6188
6833
  id: record3.id,
6189
6834
  kind: "computer",
6190
6835
  cwd: COMPUTER_CWD,
6191
- exec: (command, options) => run((env) => env.exec(command, options)),
6192
- readFile: (path) => run((env) => env.readFile(path)),
6193
- head: (path, bytes) => run((env) => env.head(path, bytes)),
6194
- writeFile: (path, content2) => run((env) => env.writeFile(path, content2)),
6195
- mkdir: (path) => run((env) => env.mkdir(path)),
6196
- exists: (path) => run((env) => env.exists(path)),
6197
- fileSize: (path) => run((env) => env.fileSize(path)),
6836
+ exec: (command, options) => run2((env) => env.exec(command, options)),
6837
+ readFile: (path) => run2((env) => env.readFile(path)),
6838
+ head: (path, bytes) => run2((env) => env.head(path, bytes)),
6839
+ writeFile: (path, content2) => run2((env) => env.writeFile(path, content2)),
6840
+ mkdir: (path) => run2((env) => env.mkdir(path)),
6841
+ exists: (path) => run2((env) => env.exists(path)),
6842
+ fileSize: (path) => run2((env) => env.fileSize(path)),
6198
6843
  close: () => this.disconnect(botId)
6199
6844
  };
6200
6845
  }
@@ -6208,7 +6853,7 @@ var Computers = class {
6208
6853
  await home;
6209
6854
  let env = this.connections.get(botId);
6210
6855
  if (!env) {
6211
- env = containerEnvironment(record3.id, container, (text3) => this.log(`[${botId.slice(0, 8)}] ${text3}`));
6856
+ env = containerEnvironment(record3.id, container, (text4) => this.log(`[${botId.slice(0, 8)}] ${text4}`));
6212
6857
  this.connections.set(botId, env);
6213
6858
  }
6214
6859
  return env;
@@ -6336,15 +6981,502 @@ var Computers = class {
6336
6981
  this.set(botId, { state: "running" });
6337
6982
  }
6338
6983
  }
6339
- log(text3) {
6340
- (this.deps.log ?? console.log)(`[computers] ${text3}`);
6984
+ log(text4) {
6985
+ (this.deps.log ?? console.log)(`[computers] ${text4}`);
6341
6986
  }
6342
6987
  };
6343
6988
 
6989
+ // src/server/service/bot/machines.ts
6990
+ import { randomUUID as randomUUID12 } from "node:crypto";
6991
+ import { dirname as dirname5, join as join9, posix } from "node:path";
6992
+
6993
+ // src/server/infra/env/ssh.ts
6994
+ import { spawn as spawn2 } from "node:child_process";
6995
+ import { mkdirSync as mkdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6996
+ import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3 } from "node:fs/promises";
6997
+ import { join as join8 } from "node:path";
6998
+ import { HostKeyChangedError, HostKeyUnknownError, RemoteExecutionEnv as RemoteExecutionEnv2, detectPlatform, scanHostKey, sshArguments, sshConnection } from "@earendil-works/pi-env";
6999
+ var SshFault = class extends Error {
7000
+ constructor(code, message) {
7001
+ super(message);
7002
+ this.code = code;
7003
+ }
7004
+ };
7005
+ var SCAN_ALIAS = "jarvy-scan";
7006
+ var TIMEOUT_MS2 = 3e4;
7007
+ async function scanHost(address2, runtime) {
7008
+ await mkdir4(runtime, { recursive: true, mode: 448 });
7009
+ try {
7010
+ const scanned = await scanHostKey({ ...target(address2, SCAN_ALIAS), knownHostsFile: join8(runtime, "none") });
7011
+ return {
7012
+ keys: scanned.lines.map((line) => line.trim().split(/\s+/).slice(1, 3).join(" ")),
7013
+ fingerprints: scanned.fingerprints.map((line) => line.replace(` ${SCAN_ALIAS} `, " ").replace(/^\d+\s+/, ""))
7014
+ };
7015
+ } catch (error) {
7016
+ throw classify(error, address2);
7017
+ }
7018
+ }
7019
+ async function generateKey(runtime, comment) {
7020
+ await mkdir4(runtime, { recursive: true, mode: 448 });
7021
+ const directory = await mkdtemp2(join8(runtime, "keygen-"));
7022
+ try {
7023
+ await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f", join8(directory, "key")]);
7024
+ return { privateKey: await readFile3(join8(directory, "key"), "utf8"), publicKey: (await readFile3(join8(directory, "key.pub"), "utf8")).trim() };
7025
+ } finally {
7026
+ await rm3(directory, { recursive: true, force: true });
7027
+ }
7028
+ }
7029
+ function publicKeyOf(privateKey, comment) {
7030
+ try {
7031
+ const body = Buffer.from(privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64"), magic = "openssh-key-v1\0";
7032
+ if (body.subarray(0, magic.length).toString("latin1") !== magic) return null;
7033
+ let at2 = magic.length;
7034
+ const field2 = () => {
7035
+ const size = body.readUInt32BE(at2);
7036
+ const value2 = body.subarray(at2 + 4, at2 + 4 + size);
7037
+ at2 += 4 + size;
7038
+ return value2;
7039
+ };
7040
+ field2();
7041
+ field2();
7042
+ field2();
7043
+ if (body.readUInt32BE(at2) !== 1) return null;
7044
+ at2 += 4;
7045
+ const blob = field2(), type = blob.subarray(4, 4 + blob.readUInt32BE(0)).toString("latin1");
7046
+ return `${type} ${blob.toString("base64")} ${comment}`;
7047
+ } catch {
7048
+ return null;
7049
+ }
7050
+ }
7051
+ async function installKey(address2, runtime, hostKeys2, password, publicKey) {
7052
+ const access = { ...address2, runtime, alias: SCAN_ALIAS, hostKeys: hostKeys2, privateKey: "", password };
7053
+ await withFiles(access, async (files) => {
7054
+ const script = [
7055
+ "umask 077",
7056
+ 'mkdir -p "$HOME/.ssh"',
7057
+ 'f="$HOME/.ssh/authorized_keys"',
7058
+ 'touch "$f"',
7059
+ "IFS= read -r key",
7060
+ `grep -qxF "$key" "$f" || printf '%s\\n' "$key" >> "$f"`
7061
+ ].join("\n");
7062
+ await runSsh(access, files, `sh -c ${quote(script)}`, `${publicKey}
7063
+ `, "password");
7064
+ });
7065
+ }
7066
+ async function probeHost(access) {
7067
+ return withFiles(access, async (files) => {
7068
+ try {
7069
+ const remote = await detectPlatform({ ...target(access, access.alias), ...files });
7070
+ if (remote.platform !== "linux" && remote.platform !== "darwin") throw new SshFault("ssh_unsupported", `Unsupported system: ${remote.platform}`);
7071
+ return { platform: remote.platform, home: remote.home };
7072
+ } catch (error) {
7073
+ throw classify(error, access, access.password ? "password" : "key");
7074
+ }
7075
+ });
7076
+ }
7077
+ async function removeKey(access, comment) {
7078
+ await withFiles(access, async (files) => {
7079
+ const script = [
7080
+ "umask 077",
7081
+ 'f="$HOME/.ssh/authorized_keys"',
7082
+ '[ -f "$f" ] || exit 0',
7083
+ `awk ${quote(`$NF != "${comment}"`)} "$f" > "$f.jarvy" && cat "$f.jarvy" > "$f"`,
7084
+ "s=$?",
7085
+ 'rm -f "$f.jarvy"',
7086
+ "exit $s"
7087
+ ].join("\n");
7088
+ await runSsh(access, files, `sh -c ${quote(script)}`, "", access.password ? "password" : "key");
7089
+ });
7090
+ }
7091
+ function sshEnvironment(id, access, directory, temp, onLog) {
7092
+ mkdirSync4(access.runtime, { recursive: true, mode: 448 });
7093
+ const folder = join8(access.runtime, `open-${id}-${process.pid}-${Date.now().toString(36)}`);
7094
+ const files = writeFilesSync(folder, access);
7095
+ const { connection } = sshConnection({ ...target(access, access.alias), ...files, ...onLog ? { onLog } : {} });
7096
+ return overDurable(id, "ssh", new RemoteExecutionEnv2({ connection, id: `ssh:${id}`, cwd: directory }), {
7097
+ passEnv: false,
7098
+ temp,
7099
+ close: () => {
7100
+ connection.close();
7101
+ rmSync2(folder, { recursive: true, force: true });
7102
+ }
7103
+ });
7104
+ }
7105
+ function clearRuntime(runtime) {
7106
+ rmSync2(runtime, { recursive: true, force: true });
7107
+ }
7108
+ function target(address2, alias2) {
7109
+ return { host: address2.host, port: address2.port, user: address2.user, hostKeyAlias: alias2, configFile: "/dev/null" };
7110
+ }
7111
+ async function withFiles(access, work) {
7112
+ await mkdir4(access.runtime, { recursive: true, mode: 448 });
7113
+ const directory = await mkdtemp2(join8(access.runtime, "use-"));
7114
+ try {
7115
+ return await work(writeFilesSync(directory, access));
7116
+ } finally {
7117
+ await rm3(directory, { recursive: true, force: true });
7118
+ }
7119
+ }
7120
+ function writeFilesSync(directory, access) {
7121
+ mkdirSync4(directory, { recursive: true, mode: 448 });
7122
+ const knownHostsFile = join8(directory, "known_hosts");
7123
+ writeFileSync3(knownHostsFile, access.hostKeys.map((key) => `${access.alias} ${key}
7124
+ `).join(""), { mode: 384 });
7125
+ if (access.password) {
7126
+ const secret = join8(directory, "password"), askpass = join8(directory, "askpass"), ssh = join8(directory, "ssh");
7127
+ writeFileSync3(secret, `${access.password}
7128
+ `, { mode: 384 });
7129
+ writeFileSync3(askpass, `#!/bin/sh
7130
+ exec cat ${quote(secret)}
7131
+ `, { mode: 448 });
7132
+ writeFileSync3(ssh, ["#!/bin/sh", `exec env -u SSH_AUTH_SOCK SSH_ASKPASS=${quote(askpass)} SSH_ASKPASS_REQUIRE=force DISPLAY=none ssh -o BatchMode=no -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 -o ConnectTimeout=15 "$@"`, ""].join("\n"), { mode: 448 });
7133
+ return { knownHostsFile, ssh };
7134
+ }
7135
+ const identityFile = join8(directory, "key");
7136
+ writeFileSync3(identityFile, access.privateKey.endsWith("\n") ? access.privateKey : `${access.privateKey}
7137
+ `, { mode: 384 });
7138
+ return { knownHostsFile, identityFile };
7139
+ }
7140
+ function runSsh(access, files, command, stdin, how) {
7141
+ const { ssh, ...rest } = files;
7142
+ return run(ssh ?? "ssh", [...sshArguments({ ...target(access, access.alias), ...rest }), command], stdin).catch((error) => {
7143
+ throw classify(error, access, how);
7144
+ });
7145
+ }
7146
+ function run(program, args, stdin = "", env) {
7147
+ return new Promise((resolve5, reject) => {
7148
+ const child = spawn2(program, args, { stdio: ["pipe", "pipe", "pipe"], ...env ? { env } : {}, detached: true });
7149
+ const out = [], err = [];
7150
+ const timer = setTimeout(() => child.kill(), TIMEOUT_MS2);
7151
+ child.stdout.on("data", (chunk) => out.push(chunk));
7152
+ child.stderr.on("data", (chunk) => err.push(chunk));
7153
+ child.stdin.on("error", () => {
7154
+ });
7155
+ child.on("error", (error) => {
7156
+ clearTimeout(timer);
7157
+ reject(error);
7158
+ });
7159
+ child.on("close", (code) => {
7160
+ clearTimeout(timer);
7161
+ if (code === 0) resolve5(Buffer.concat(out).toString("utf8"));
7162
+ else reject(new Error(Buffer.concat(err).toString("utf8").trim() || `${program} exited with ${code ?? "a signal"}`));
7163
+ });
7164
+ child.stdin.end(stdin);
7165
+ });
7166
+ }
7167
+ function classify(error, address2, how = "key") {
7168
+ if (error instanceof SshFault) return error;
7169
+ const text4 = error instanceof Error ? error.message : String(error), where2 = `${address2.user}@${address2.host}:${address2.port}`;
7170
+ if (error instanceof HostKeyChangedError || /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(text4))
7171
+ return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
7172
+ if (error instanceof HostKeyUnknownError) return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
7173
+ const denied = /Permission denied \(([^)]*)\)/.exec(text4);
7174
+ if (denied || /Permission denied|Too many authentication failures/.test(text4)) {
7175
+ const methods = denied ? denied[1].split(",").map((method) => method.trim()) : [];
7176
+ if (how === "password" && methods.length && !methods.some((method) => method === "password" || method === "keyboard-interactive"))
7177
+ return new SshFault("ssh_password_refused", `${where2} doesn't accept password logins`);
7178
+ if (how === "key") return new SshFault("ssh_key_refused", `${where2} didn't accept Jarvy's key`);
7179
+ return new SshFault("ssh_auth_failed", `${where2} refused the password`);
7180
+ }
7181
+ if (/Connection refused|timed out|No route to host|Could not resolve|Network is unreachable|Connection closed|No host key received/i.test(text4))
7182
+ return new SshFault("ssh_unreachable", `${where2} can't be reached: ${text4.split("\n").pop()}`);
7183
+ return new SshFault("ssh_failed", text4.split("\n").slice(-3).join(" ").slice(0, 300) || `ssh ${where2} failed`);
7184
+ }
7185
+ var quote = (text4) => `'${text4.replaceAll("'", "'\\''")}'`;
7186
+
7187
+ // src/server/service/bot/machines.ts
7188
+ var HOST = /^(?!-)[A-Za-z0-9._:-]{1,253}$/;
7189
+ var USER = /^(?!-)[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
7190
+ var PREPARED_MS = 60 * 6e4;
7191
+ var SshMachines = class {
7192
+ /** `changed`: what Bots may use changed, so the owner's page reloads them. */
7193
+ constructor(store, chats, changed = () => {
7194
+ }) {
7195
+ this.store = store;
7196
+ this.chats = chats;
7197
+ this.changed = changed;
7198
+ this.runtime = join9(dirname5(store.path), "ssh");
7199
+ clearRuntime(this.runtime);
7200
+ }
7201
+ runtime;
7202
+ prepared = /* @__PURE__ */ new Map();
7203
+ list() {
7204
+ return this.store.environments.sshMachines().map((record3) => this.view(record3));
7205
+ }
7206
+ get(id) {
7207
+ return this.view(this.need(id));
7208
+ }
7209
+ /** The host keys a machine presents, to show the owner before the password is typed. */
7210
+ async scan(input) {
7211
+ return this.wrap(() => scanHost(address(input), this.runtime));
7212
+ }
7213
+ /** A key for a machine the owner will add its public half to themselves; `keyId` then adds the machine. */
7214
+ async prepareKey() {
7215
+ const now = Date.now();
7216
+ for (const [keyId2, key2] of this.prepared) if (key2.until < now) this.prepared.delete(keyId2);
7217
+ const id = randomUUID12(), key = await this.wrap(() => generateKey(this.runtime, keyComment(id)));
7218
+ this.prepared.set(id, { id, ...key, until: now + PREPARED_MS });
7219
+ return { keyId: id, publicKey: key.publicKey };
7220
+ }
7221
+ /** Adds a machine and saves it once a login works; nothing is saved when a step fails. With `password`, Jarvy makes
7222
+ * a key and installs it with that password; when the machine then refuses the key, the password itself is kept
7223
+ * (sealed) and used to log in (A59), and the key stays installed for when keys are allowed. With `keyId`, the owner
7224
+ * has added that prepared key to the machine, and a login with it is checked. */
7225
+ async add(input, grantTo) {
7226
+ const where2 = address(input), name3 = this.freeName(text3(input.name, 40) || `${where2.user}@${where2.host}`);
7227
+ const password = typeof input.password === "string" && input.password ? input.password : null;
7228
+ const prepared = typeof input.keyId === "string" ? this.prepared.get(input.keyId) : void 0;
7229
+ if (!password && input.keyId === void 0) throw new ChatError(400, "A password is needed to install the key", "ssh_password_missing");
7230
+ if (!password && (!prepared || prepared.until < Date.now())) throw new ChatError(409, "That key is no longer kept; show a new one and add it to the machine", "ssh_key_expired");
7231
+ if (!input.hostKeys.keys.length) throw new ChatError(400, "The host keys are missing; scan the machine first");
7232
+ directoryIn("/", input.directory);
7233
+ const id = prepared && !password ? prepared.id : randomUUID12(), comment = keyComment(id);
7234
+ const record3 = await this.wrap(async () => {
7235
+ const key = prepared && !password ? prepared : await generateKey(this.runtime, comment);
7236
+ const access = { ...where2, runtime: this.runtime, alias: alias(id), hostKeys: input.hostKeys.keys, privateKey: key.privateKey };
7237
+ const save = (remote, kept) => this.store.environments.addSsh({
7238
+ name: name3,
7239
+ ...where2,
7240
+ ...remote,
7241
+ directory: directoryIn(remote.home, input.directory),
7242
+ hostKeys: input.hostKeys,
7243
+ privateKey: key.privateKey,
7244
+ ...kept ? { password: kept } : {}
7245
+ }, id);
7246
+ if (!password) return save(await probeHost(access));
7247
+ await installKey(where2, this.runtime, input.hostKeys.keys, password, key.publicKey);
7248
+ try {
7249
+ try {
7250
+ return save(await probeHost(access));
7251
+ } catch (error) {
7252
+ if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw error;
7253
+ return save(await probeHost({ ...access, password }), password);
7254
+ }
7255
+ } catch (error) {
7256
+ await removeKey(password ? { ...access, password } : access, comment).catch(() => {
7257
+ });
7258
+ throw error;
7259
+ }
7260
+ });
7261
+ if (prepared) this.prepared.delete(prepared.id);
7262
+ if (grantTo) this.store.environments.grant(record3.id, grantTo.botId, grantTo.by);
7263
+ this.changed();
7264
+ return this.view(record3);
7265
+ }
7266
+ /** A new password for a machine reached with its password, checked with a login before it is kept. */
7267
+ async setPassword(id, password) {
7268
+ const record3 = this.need(id);
7269
+ if (typeof password !== "string" || !password) throw new ChatError(400, "A password is needed", "ssh_password_missing");
7270
+ if (!this.store.environments.usesPassword(id)) throw new ChatError(409, "This machine is reached with Jarvy's key, not a password");
7271
+ await this.wrap(() => probeHost({ ...this.access(record3), password }));
7272
+ this.store.environments.updateSsh(id, { password });
7273
+ return this.get(id);
7274
+ }
7275
+ update(id, input) {
7276
+ const record3 = this.need(id);
7277
+ const name3 = input.name === void 0 ? void 0 : text3(input.name, 40);
7278
+ if (name3 !== void 0 && !name3) throw new ChatError(400, "A name is needed");
7279
+ if (name3 !== void 0 && name3 !== record3.name) this.freeName(name3);
7280
+ this.store.environments.updateSsh(id, {
7281
+ ...name3 !== void 0 ? { name: name3 } : {},
7282
+ ...input.directory !== void 0 ? { directory: directoryIn(record3.spec.home ?? "/", input.directory) } : {}
7283
+ });
7284
+ this.changed();
7285
+ return this.get(id);
7286
+ }
7287
+ /** Scans the machine again: `changed` when it no longer presents the keys the owner accepted. A machine reached
7288
+ * with its password whose keys are unchanged is tried with Jarvy's key: once that works, the password is dropped
7289
+ * and the key is used from then on (A59). `machine` is how it stands after. */
7290
+ async check(id) {
7291
+ const record3 = this.need(id), accepted = this.store.environments.sshSecrets(id).hostKeys;
7292
+ const hostKeys2 = await this.scan(this.addressOf(record3)), changed = hostKeys2.keys.some((key) => !accepted.keys.includes(key));
7293
+ if (!changed && this.store.environments.usesPassword(id)) {
7294
+ const { password: _password, ...withKey } = this.access(record3);
7295
+ try {
7296
+ await probeHost(withKey);
7297
+ this.store.environments.updateSsh(id, { password: null });
7298
+ this.changed();
7299
+ } catch (error) {
7300
+ if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw this.fault(error);
7301
+ }
7302
+ }
7303
+ return { hostKeys: hostKeys2, changed, machine: this.get(id) };
7304
+ }
7305
+ /** The owner accepts the keys the machine presents now (after a reinstall, say). */
7306
+ async trust(id, fingerprints) {
7307
+ const { hostKeys: hostKeys2 } = await this.check(id);
7308
+ if (!Array.isArray(fingerprints) || hostKeys2.fingerprints.join("\n") !== fingerprints.join("\n")) throw new ChatError(409, "The machine now presents other keys; check again", "ssh_host_key_changed");
7309
+ this.store.environments.updateSsh(id, { hostKeys: hostKeys2 });
7310
+ return this.get(id);
7311
+ }
7312
+ /** Removes Jarvy's key from the machine, then forgets it. When the machine can't be reached the key stays
7313
+ * there; `force` forgets it anyway. Returns the Bots that were working there. */
7314
+ async remove(id, force) {
7315
+ const record3 = this.need(id);
7316
+ try {
7317
+ await this.wrap(() => removeKey(this.access(record3), keyComment(id)));
7318
+ } catch (error) {
7319
+ if (!force) throw error;
7320
+ }
7321
+ const moved = this.store.environments.dropSsh(id);
7322
+ this.changed();
7323
+ return moved;
7324
+ }
7325
+ /** The machine as a Bot's environment. Its own files go in ~/.cache/jarvy/<Bot> there. */
7326
+ environment(record3, botId) {
7327
+ const home = record3.spec.home ?? "/tmp";
7328
+ return sshEnvironment(
7329
+ record3.id,
7330
+ this.access(record3),
7331
+ record3.directory ?? home,
7332
+ posix.join(home, ".cache", "jarvy", botId),
7333
+ (text4) => console.error(`[ssh ${record3.name}] ${text4.trim()}`)
7334
+ );
7335
+ }
7336
+ /** What a Bot is told of a machine. */
7337
+ about(record3, botId) {
7338
+ const temp = posix.join(record3.spec.home ?? "~", ".cache", "jarvy", botId);
7339
+ return `A ${record3.spec.platform === "darwin" ? "macOS" : "Linux"} machine your owner added, reached over SSH as ${record3.spec.user}@${record3.spec.host}. Your tools run there as that user and can reach their files: work in your directory, leave other people's files and system directories alone unless the task needs it, and say in your reply what you changed there. Files from the chat and long command outputs go in ${temp}.`;
7340
+ }
7341
+ address(record3) {
7342
+ return `${record3.spec.user}@${record3.spec.host}${record3.spec.port === "22" ? "" : `:${record3.spec.port}`}`;
7343
+ }
7344
+ /** environments.request: a machine the Bot may already use needs no card; one already added needs only a grant;
7345
+ * else the card shows its fingerprints and takes the password. */
7346
+ async prepareRequest(botId, input) {
7347
+ const where2 = address(input);
7348
+ const existing = this.store.environments.sshMachines().find((record3) => record3.spec.host === where2.host && record3.spec.user === where2.user && record3.spec.port === String(where2.port));
7349
+ if (existing && this.store.environments.grantees(existing.id).includes(botId)) return { granted: existing };
7350
+ const hostKeys2 = existing ? this.store.environments.sshSecrets(existing.id).hostKeys : await this.scan(where2);
7351
+ const machine = {
7352
+ name: existing?.name ?? (text3(input.name, 40) || `${where2.user}@${where2.host}`),
7353
+ ...where2,
7354
+ directory: typeof input.directory === "string" && input.directory.trim() ? input.directory.trim() : null,
7355
+ purpose: input.purpose,
7356
+ hostKeys: hostKeys2,
7357
+ existing: existing ? { id: existing.id, name: existing.name } : null
7358
+ };
7359
+ return { question: { question: input.purpose, options: [{ label: "Add", value: "add" }], multiple: false, allowCustom: false, machine } };
7360
+ }
7361
+ /** The owner's answer on a request card: adds the machine with the password or a prepared key (or grants the one
7362
+ * already added) to the Bot that asked, then settles the card. The password never enters the message. */
7363
+ async fulfil(actorId, chatId, messageId, input) {
7364
+ const message = this.chats.message(actorId, messageId), ask = message?.question?.machine;
7365
+ if (!message || message.chatId !== chatId || !ask || message.question?.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
7366
+ if (!this.chats.questions.waitingFor(messageId)) throw new ChatError(409, "The question has expired", "question_expired");
7367
+ const botId = message.sender.id, by = this.store.userMemberId();
7368
+ const existing = ask.existing && this.store.environments.get(ask.existing.id);
7369
+ if (existing) {
7370
+ this.store.environments.grant(existing.id, botId, by);
7371
+ this.changed();
7372
+ } else await this.add({
7373
+ name: ask.name,
7374
+ host: ask.host,
7375
+ port: ask.port,
7376
+ user: ask.user,
7377
+ directory: ask.directory ?? void 0,
7378
+ password: input.password,
7379
+ ...input.keyId !== void 0 ? { keyId: input.keyId } : {},
7380
+ hostKeys: ask.hostKeys
7381
+ }, { botId, by });
7382
+ return this.chats.questions.respond(actorId, chatId, messageId, { action: "answer", selected: [0] });
7383
+ }
7384
+ /** The machine a request card added or granted, by its address. */
7385
+ find(botId, input) {
7386
+ const where2 = address(input);
7387
+ return this.store.environments.grants(botId).find((record3) => record3.kind === "ssh" && record3.spec.host === where2.host && record3.spec.user === where2.user && record3.spec.port === String(where2.port)) ?? null;
7388
+ }
7389
+ view(record3) {
7390
+ const { spec } = record3, secrets = this.store.environments.sshSecrets(record3.id);
7391
+ return {
7392
+ id: record3.id,
7393
+ name: record3.name,
7394
+ host: spec.host ?? "",
7395
+ port: Number(spec.port ?? 22),
7396
+ user: spec.user ?? "",
7397
+ home: spec.home ?? "",
7398
+ directory: record3.directory ?? spec.home ?? "",
7399
+ platform: spec.platform ?? "",
7400
+ fingerprints: secrets?.hostKeys.fingerprints ?? [],
7401
+ botIds: this.store.environments.grantees(record3.id),
7402
+ login: secrets?.password || secrets?.passwordUnreadable ? "password" : "key",
7403
+ publicKey: secrets?.privateKey ? publicKeyOf(secrets.privateKey, keyComment(record3.id)) : null
7404
+ };
7405
+ }
7406
+ need(id) {
7407
+ const record3 = this.store.environments.get(id);
7408
+ if (!record3 || record3.kind !== "ssh") throw new ChatError(404, "No machine with this id", "machine_missing");
7409
+ return record3;
7410
+ }
7411
+ addressOf(record3) {
7412
+ return { host: record3.spec.host, port: Number(record3.spec.port), user: record3.spec.user };
7413
+ }
7414
+ access(record3) {
7415
+ const secrets = this.store.environments.sshSecrets(record3.id);
7416
+ if (!secrets?.privateKey || secrets.passwordUnreadable) throw new ChatError(409, "This machine's key doesn't open under this installation's master key; delete the machine and add it again", "ssh_key_unreadable");
7417
+ return {
7418
+ ...this.addressOf(record3),
7419
+ runtime: this.runtime,
7420
+ alias: alias(record3.id),
7421
+ hostKeys: secrets.hostKeys.keys,
7422
+ privateKey: secrets.privateKey,
7423
+ ...secrets.password ? { password: secrets.password } : {}
7424
+ };
7425
+ }
7426
+ freeName(name3) {
7427
+ if (this.store.environments.sshMachines().some((record3) => record3.name === name3)) throw new ChatError(409, "Another machine has this name", "machine_name_taken");
7428
+ return name3;
7429
+ }
7430
+ /** SSH failures reach people and Bots as chat errors with a code the interface can word. */
7431
+ async wrap(work) {
7432
+ try {
7433
+ return await work();
7434
+ } catch (error) {
7435
+ throw this.fault(error);
7436
+ }
7437
+ }
7438
+ fault(error) {
7439
+ if (!(error instanceof SshFault)) return error;
7440
+ switch (error.code) {
7441
+ case "ssh_auth_failed":
7442
+ return new ChatError(403, error.message, "ssh_auth_failed");
7443
+ case "ssh_password_refused":
7444
+ return new ChatError(403, error.message, "ssh_password_refused");
7445
+ case "ssh_key_refused":
7446
+ return new ChatError(403, error.message, "ssh_key_refused");
7447
+ case "ssh_unreachable":
7448
+ return new ChatError(409, error.message, "ssh_unreachable");
7449
+ case "ssh_host_key_changed":
7450
+ return new ChatError(409, error.message, "ssh_host_key_changed");
7451
+ case "ssh_unsupported":
7452
+ return new ChatError(409, error.message, "ssh_unsupported");
7453
+ default:
7454
+ return new ChatError(409, error.message, "ssh_failed", { detail: error.message });
7455
+ }
7456
+ }
7457
+ };
7458
+ var alias = (id) => `jarvy-${id}`;
7459
+ var keyComment = (id) => `jarvy-${id}`;
7460
+ var text3 = (value2, max) => typeof value2 === "string" ? value2.trim().slice(0, max) : "";
7461
+ function address(input) {
7462
+ const host = text3(input.host, 253), user = text3(input.user, 64), port = input.port === void 0 || input.port === "" ? 22 : Number(input.port);
7463
+ if (!HOST.test(host)) throw new ChatError(400, "host must be a host name or IP address", "ssh_bad_host");
7464
+ if (!USER.test(user)) throw new ChatError(400, "user must be a user name on that machine", "ssh_bad_user");
7465
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ChatError(400, "port must be 1 to 65535", "ssh_bad_port");
7466
+ return { host, port, user };
7467
+ }
7468
+ function directoryIn(home, value2) {
7469
+ const raw = text3(value2, 1e3);
7470
+ if (!raw || raw === "~") return home;
7471
+ if (raw.startsWith("~/")) return posix.join(home, raw.slice(2));
7472
+ if (!raw.startsWith("/") || /[\x00-\x1f]/.test(raw)) throw new ChatError(400, "directory must be an absolute path or start with ~/", "ssh_bad_directory");
7473
+ return posix.normalize(raw);
7474
+ }
7475
+
6344
7476
  // src/server/infra/storage/history-repository.ts
6345
7477
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
6346
- import { chmodSync as chmodSync3, mkdirSync as mkdirSync4 } from "node:fs";
6347
- import { dirname as dirname5 } from "node:path";
7478
+ import { chmodSync as chmodSync3, mkdirSync as mkdirSync5 } from "node:fs";
7479
+ import { dirname as dirname6 } from "node:path";
6348
7480
  var json = (value2) => value2 == null ? null : JSON.stringify(value2);
6349
7481
  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')=?)";
6350
7482
  var BLOCKS = "FROM json_each(history.message,'$.content') b WHERE json_extract(b.value,'$.type')";
@@ -6356,7 +7488,7 @@ var SUMMARY = `SELECT id,timestamp,role,model,
6356
7488
  var HistoryRepository = class {
6357
7489
  db;
6358
7490
  constructor(path) {
6359
- mkdirSync4(dirname5(path), { recursive: true, mode: 448 });
7491
+ mkdirSync5(dirname6(path), { recursive: true, mode: 448 });
6360
7492
  this.db = new DatabaseSync2(path);
6361
7493
  chmodSync3(path, 384);
6362
7494
  const version = this.db.prepare("PRAGMA user_version").get().user_version;
@@ -6668,24 +7800,24 @@ var Model = class {
6668
7800
  };
6669
7801
 
6670
7802
  // src/server/service/bot/workspace.ts
6671
- import { posix } from "node:path";
7803
+ import { posix as posix2 } from "node:path";
6672
7804
 
6673
7805
  // src/server/infra/files/workspace.ts
6674
- import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
6675
- import { join as join8 } from "node:path";
7806
+ import { mkdir as mkdir5, writeFile as writeFile3 } from "node:fs/promises";
7807
+ import { join as join10 } from "node:path";
6676
7808
  var LocalWorkspace = class {
6677
7809
  constructor(cwd) {
6678
7810
  this.cwd = cwd;
6679
7811
  }
6680
7812
  get tempDir() {
6681
- return join8(this.cwd, "temp");
7813
+ return join10(this.cwd, "temp");
6682
7814
  }
6683
7815
  async prepare() {
6684
- await mkdir4(this.tempDir, { recursive: true, mode: 448 });
7816
+ await mkdir5(this.tempDir, { recursive: true, mode: 448 });
6685
7817
  }
6686
7818
  async writeTempFile(name3, content2, signal) {
6687
7819
  await this.prepare();
6688
- const path = join8(this.tempDir, name3);
7820
+ const path = join10(this.tempDir, name3);
6689
7821
  await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
6690
7822
  return path;
6691
7823
  }
@@ -6693,8 +7825,8 @@ var LocalWorkspace = class {
6693
7825
 
6694
7826
  // src/server/infra/files/skills.ts
6695
7827
  import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
6696
- import { mkdir as mkdir5, writeFile as writeFile4 } from "node:fs/promises";
6697
- import { dirname as dirname6, join as join9 } from "node:path";
7828
+ import { mkdir as mkdir6, writeFile as writeFile4 } from "node:fs/promises";
7829
+ import { dirname as dirname7, join as join11 } from "node:path";
6698
7830
  import { fileURLToPath } from "node:url";
6699
7831
 
6700
7832
  // src/server/shared/skill.ts
@@ -6707,7 +7839,7 @@ function builtinSkills() {
6707
7839
  const skills = [];
6708
7840
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
6709
7841
  if (!entry.isDirectory()) continue;
6710
- const file = join9(dir, entry.name, "SKILL.md");
7842
+ const file = join11(dir, entry.name, "SKILL.md");
6711
7843
  if (!existsSync4(file)) continue;
6712
7844
  const skill = parseSkill(entry.name, readFileSync4(file, "utf8"));
6713
7845
  if (skill) skills.push(skill);
@@ -6717,16 +7849,16 @@ function builtinSkills() {
6717
7849
  }
6718
7850
  async function installSkills(workspace, skills) {
6719
7851
  for (const skill of skills) {
6720
- const file = join9(workspace, skillPath(skill.name));
6721
- await mkdir5(dirname6(file), { recursive: true });
7852
+ const file = join11(workspace, skillPath(skill.name));
7853
+ await mkdir6(dirname7(file), { recursive: true });
6722
7854
  await writeFile4(file, skill.content, "utf8");
6723
7855
  }
6724
7856
  }
6725
7857
  function skillsDir() {
6726
- let dir = dirname6(fileURLToPath(import.meta.url));
7858
+ let dir = dirname7(fileURLToPath(import.meta.url));
6727
7859
  for (let depth = 0; depth < 6; depth++) {
6728
- if (existsSync4(join9(dir, "package.json"))) return existsSync4(join9(dir, "skills")) ? join9(dir, "skills") : null;
6729
- const parent = dirname6(dir);
7860
+ if (existsSync4(join11(dir, "package.json"))) return existsSync4(join11(dir, "skills")) ? join11(dir, "skills") : null;
7861
+ const parent = dirname7(dir);
6730
7862
  if (parent === dir) break;
6731
7863
  dir = parent;
6732
7864
  }
@@ -6759,7 +7891,7 @@ var Workspace = class {
6759
7891
  async writeTempFile(name3, content2, signal) {
6760
7892
  const env = this.current();
6761
7893
  if (env.kind === "host") return this.local.writeTempFile(name3, content2, signal);
6762
- const path = `${env.cwd}/temp/${name3}`;
7894
+ const path = `${tempOf(env)}/${name3}`;
6763
7895
  await env.writeFile(path, content2);
6764
7896
  return path;
6765
7897
  }
@@ -6767,14 +7899,14 @@ var Workspace = class {
6767
7899
  * id when another file has that name). Returns where it went. */
6768
7900
  async put(name3, id, data, path) {
6769
7901
  const env = this.current();
6770
- let target2 = path ? posix.resolve(env.cwd, path) : `${env.cwd}/temp/files/${plain(name3)}`;
6771
- if (!path && await env.exists(target2)) {
7902
+ let target3 = path ? posix2.resolve(env.cwd, path) : `${tempOf(env)}/files/${plain(name3)}`;
7903
+ if (!path && await env.exists(target3)) {
6772
7904
  const dot = plain(name3).lastIndexOf("."), stem = dot > 0 ? plain(name3).slice(0, dot) : plain(name3), extension2 = dot > 0 ? plain(name3).slice(dot) : "";
6773
- target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${extension2}`;
7905
+ target3 = `${tempOf(env)}/files/${stem}-${id.slice(0, 8)}${extension2}`;
6774
7906
  }
6775
- await env.mkdir(posix.dirname(target2));
6776
- await env.writeFile(target2, data);
6777
- return target2;
7907
+ await env.mkdir(posix2.dirname(target3));
7908
+ await env.writeFile(target3, data);
7909
+ return target3;
6778
7910
  }
6779
7911
  /** The files a Bot attaches to a message, read from its environment: paths there, relative to its directory. */
6780
7912
  async readOutgoing(items, signal) {
@@ -6782,7 +7914,7 @@ var Workspace = class {
6782
7914
  const env = this.current(), files = [];
6783
7915
  for (const item of items) {
6784
7916
  signal?.throwIfAborted();
6785
- const path = posix.resolve(env.cwd, item.path), size = await env.fileSize(path);
7917
+ const path = posix2.resolve(env.cwd, item.path), size = await env.fileSize(path);
6786
7918
  if (size === null) throw new ChatError(400, `No file at ${path} in your environment`);
6787
7919
  if (!size || size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
6788
7920
  const data = await env.readFile(path);
@@ -6799,6 +7931,7 @@ function plain(name3) {
6799
7931
  const cleaned = name3.replace(/[/\\\0]/g, "_").trim();
6800
7932
  return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
6801
7933
  }
7934
+ var tempOf = (env) => env.temp ?? `${env.cwd}/temp`;
6802
7935
 
6803
7936
  // src/server/service/bot/state.ts
6804
7937
  var BotState = class {
@@ -6817,20 +7950,20 @@ var BotState = class {
6817
7950
  };
6818
7951
 
6819
7952
  // src/server/service/bot/tools.ts
6820
- import { Type as Type5 } from "typebox";
6821
- import { Value } from "typebox/value";
7953
+ import { Type as Type6 } from "typebox";
7954
+ import { Value as Value2 } from "typebox/value";
6822
7955
 
6823
7956
  // src/server/service/bot/history-read.ts
6824
- import { randomUUID as randomUUID11 } from "node:crypto";
7957
+ import { randomUUID as randomUUID13 } from "node:crypto";
6825
7958
  import { Type as Type2 } from "typebox";
6826
7959
 
6827
7960
  // src/server/service/bot/history-format.ts
6828
- function fence(text3, language = "text") {
7961
+ function fence(text4, language = "text") {
6829
7962
  let length = 3;
6830
- for (const match of text3.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1);
7963
+ for (const match of text4.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1);
6831
7964
  const marker = "`".repeat(length);
6832
7965
  return `${marker}${language}
6833
- ${text3}
7966
+ ${text4}
6834
7967
  ${marker}`;
6835
7968
  }
6836
7969
  var jsonBlock = (value2) => fence(JSON.stringify(value2, null, 2), "json");
@@ -6901,12 +8034,12 @@ async function readHistory(memory, workspace, input, signal) {
6901
8034
  const rendered = formatHistory(result, format);
6902
8035
  signal?.throwIfAborted();
6903
8036
  if (!input.to_file) return rendered;
6904
- const name3 = `history-${randomUUID11()}.${format === "json" ? "json" : "md"}`;
8037
+ const name3 = `history-${randomUUID13()}.${format === "json" ? "json" : "md"}`;
6905
8038
  return workspace.writeTempFile(name3, rendered, signal);
6906
8039
  }
6907
8040
 
6908
8041
  // src/server/service/bot/file-view.ts
6909
- var TEXT_LIMIT = 64 * 1024;
8042
+ var TEXT_LIMIT2 = 64 * 1024;
6910
8043
  var PICTURE_LIMIT = 4 * 1024 * 1024;
6911
8044
  function viewFile(file, data) {
6912
8045
  const head = `${file.name} (${file.mimeType || "application/octet-stream"}, ${byteSize(file.size)})`;
@@ -6915,10 +8048,10 @@ function viewFile(file, data) {
6915
8048
  if (data.length > PICTURE_LIMIT) throw new ChatError(400, "This picture is over 4 MB; use files.get and look at a smaller copy");
6916
8049
  return { kind: "media", text: head, images: [{ data: data.toString("base64"), mimeType: picture }] };
6917
8050
  }
6918
- const text3 = data.length <= TEXT_LIMIT ? asText(data) : null;
6919
- if (text3 !== null) return `${head}
8051
+ const text4 = data.length <= TEXT_LIMIT2 ? asText(data) : null;
8052
+ if (text4 !== null) return `${head}
6920
8053
 
6921
- ${text3}`;
8054
+ ${text4}`;
6922
8055
  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");
6923
8056
  }
6924
8057
  function pictureType(data) {
@@ -7165,8 +8298,10 @@ var ChatTools = class {
7165
8298
  signal?.throwIfAborted();
7166
8299
  return messages.map((message) => messageRow(this.chats, this.bot, message, listing(message)));
7167
8300
  }
8301
+ /** `mine`: only what this Bot sent; those messages carry the History ID of the turn that sent them (A21). */
7168
8302
  search(input) {
7169
- 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);
8303
+ const sender = input.mine ? this.bot : void 0;
8304
+ const found = input.chatId === void 0 ? this.chats.searchAll(this.bot, input.query, input.limit ?? 30, void 0, sender) : this.chats.search(this.bot, this.chat(input.chatId).id, input.query, input.limit ?? 30, sender);
7170
8305
  const chats = /* @__PURE__ */ new Map();
7171
8306
  const chatOf = (id) => {
7172
8307
  if (!chats.has(id)) chats.set(id, this.chats.get(this.bot, id));
@@ -7192,7 +8327,7 @@ var definitions = {
7192
8327
  "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 },
7193
8328
  "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 },
7194
8329
  "bots.list": {
7195
- 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.",
8330
+ 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 this chat platform: 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.",
7196
8331
  schema: Type3.Object({ environments: Type3.Optional(Type3.Boolean({ description: "Also give each Bot's work environments" })) }, { additionalProperties: false })
7197
8332
  },
7198
8333
  "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 },
@@ -7255,8 +8390,13 @@ var definitions = {
7255
8390
  }, { additionalProperties: false })
7256
8391
  },
7257
8392
  "messages.search": {
7258
- 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.",
7259
- 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 })
8393
+ 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. mine=true: only messages you sent; each carries the History ID of the turn that sent it.",
8394
+ schema: Type3.Object({
8395
+ chatId: Type3.Optional(Type3.String()),
8396
+ query: Type3.String({ minLength: 1, maxLength: 500 }),
8397
+ limit: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 100 })),
8398
+ mine: Type3.Optional(Type3.Boolean({ description: "Only messages you sent" }))
8399
+ }, { additionalProperties: false })
7260
8400
  },
7261
8401
  "files.get": {
7262
8402
  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.",
@@ -7267,8 +8407,20 @@ var definitions = {
7267
8407
  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.",
7268
8408
  schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
7269
8409
  },
8410
+ "environments.request": {
8411
+ description: `Ask your owner to add a machine you need to work on, reached over SSH, on a card in a chat with them, and wait for the answer, as messages.ask does. The card shows the machine's host key fingerprints for them to check; they let you in there, with its login password or by adding a key made for it to the machine. You never see the password or the key, so never ask for that password in the chat. It returns {status:"added", id, name} or {status:"granted", id, name} when you may already use it, then environments.switch with that id moves your tools there; or {status:"declined"}.`,
8412
+ schema: Type3.Object({
8413
+ chatId: Type3.String(),
8414
+ host: Type3.String({ minLength: 1, maxLength: 253, description: "Host name or IP address" }),
8415
+ user: Type3.String({ minLength: 1, maxLength: 64, description: "The user to log in as" }),
8416
+ port: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 65535, description: "Default 22" })),
8417
+ directory: Type3.Optional(Type3.String({ maxLength: 1e3, description: "Where your tools start there: an absolute path or one under ~/; default the user's home" })),
8418
+ name: Type3.Optional(Type3.String({ maxLength: 40, description: "What to call the machine; default user@host" })),
8419
+ purpose: Type3.String({ minLength: 1, maxLength: 300, description: "What you need it for, in your owner's language; shown on the card" })
8420
+ }, { additionalProperties: false })
8421
+ },
7270
8422
  "secrets.request": {
7271
- description: 'Ask your owner for a secret you need (a token, password or key) on a card in a chat with them, and wait for the answer, as messages.ask does. They enter the value on the card, which never shows it in the chat, and let you use it until they revoke it or only until this run ends, or decline. It returns {status:"granted", placeholder, until} or {status:"declined"}; if you may already use it, it returns at once. Then write the placeholder in bash.',
8423
+ description: 'Ask your owner for a secret you need (a token, an API key or a password; to work on a machine over SSH, use environments.request) on a card in a chat with them, and wait for the answer, as messages.ask does. They enter the value on the card, which never shows it in the chat, and let you use it until they revoke it or only until this run ends, or decline. It returns {status:"granted", placeholder, until} or {status:"declined"}; if you may already use it, it returns at once. Then write the placeholder in bash.',
7272
8424
  schema: Type3.Object({
7273
8425
  chatId: Type3.String(),
7274
8426
  name: Type3.String({ pattern: "^[A-Z][A-Z0-9_]{0,63}$", description: "Capital letters, digits and underscores, such as GITHUB_TOKEN" }),
@@ -7430,7 +8582,7 @@ var Model2 = Type4.Object({
7430
8582
  }, { additionalProperties: false });
7431
8583
  var Thinking = Type4.Union(
7432
8584
  ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => Type4.Literal(level)),
7433
- { description: "How much the model thinks; auto lets Jarvy choose. models.list gives the levels each model has" }
8585
+ { description: "How much the model thinks; auto lets this chat platform choose. models.list gives the levels each model has" }
7434
8586
  );
7435
8587
  var Settings = {
7436
8588
  description: Type4.Optional(Type4.String({ maxLength: 1e3, description: "One line that other members see in members.list" })),
@@ -7439,7 +8591,7 @@ var Settings = {
7439
8591
  thinking: Type4.Optional(Thinking)
7440
8592
  };
7441
8593
  var Environment = Type4.Union([Type4.Literal("sandbox"), Type4.Literal("host")], {
7442
- 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"
8594
+ description: "Where its file and command tools run by default: sandbox, a Linux container that this chat platform makes and only it uses (the default when Docker is available), or host, the owner's computer, where you work too"
7443
8595
  });
7444
8596
  var Confirm = {
7445
8597
  askIn: Type4.String({ description: "ID of a chat the owner is in, where the confirmation card goes; usually the chat you were asked in" }),
@@ -7482,11 +8634,104 @@ var agentDefinitions = {
7482
8634
  }
7483
8635
  };
7484
8636
 
8637
+ // src/server/service/bot/tool-errors.ts
8638
+ import { Value } from "typebox/value";
8639
+ var MAX_PROBLEMS = 5;
8640
+ function argumentProblems(schema, args) {
8641
+ const lines = [];
8642
+ for (const error of Value.Errors(schema, args)) {
8643
+ if (error.keyword === "boolean" && error.schemaPath.endsWith("additionalProperties")) continue;
8644
+ const extra = error.keyword === "additionalProperties" ? error.params.additionalProperties : void 0;
8645
+ const at2 = error.instancePath ? error.instancePath.slice(1).replaceAll("/", ".") : "";
8646
+ if (extra?.length) for (const name3 of extra) lines.push(`${at2 ? `${at2}.` : ""}${name3}: not a parameter of this tool`);
8647
+ else lines.push(`${at2 || "args"}: ${error.message}`);
8648
+ if (lines.length >= MAX_PROBLEMS) break;
8649
+ }
8650
+ return [...new Set(lines)].slice(0, MAX_PROBLEMS);
8651
+ }
8652
+ function invalidArguments(tool, schema, args) {
8653
+ const problems = args === void 0 ? ["args: missing; call takes the tool's arguments in args"] : argumentProblems(schema, args);
8654
+ return [`Invalid arguments for ${tool}:`, ...problems.map((line) => `- ${line}`), `Its parameters: ${JSON.stringify(schema)}`].join("\n");
8655
+ }
8656
+ function unknownTool(tool, names) {
8657
+ return `${tool ? `Unknown chat tool "${tool}"` : "call and describe need tool"}. The tools: ${names.join(", ")}.`;
8658
+ }
8659
+
8660
+ // src/server/service/bot/task-tools.ts
8661
+ import { Type as Type5 } from "typebox";
8662
+ var Clock = { hour: Type5.Integer({ minimum: 0, maximum: 23 }), minute: Type5.Integer({ minimum: 0, maximum: 59 }) };
8663
+ var Schedule = Type5.Union([
8664
+ Type5.Object({ kind: Type5.Literal("once"), at: Type5.String({ minLength: 1, maxLength: 40, description: "ISO 8601; local time when it has no offset" }) }, { additionalProperties: false }),
8665
+ Type5.Object({ kind: Type5.Literal("every"), minutes: Type5.Integer({ minimum: TASK_MINUTES_MIN, maximum: 60 * 24 * 30, description: "Counted from when the task is made, or from when the interval changes" }) }, { additionalProperties: false }),
8666
+ Type5.Object({ kind: Type5.Literal("daily"), ...Clock }, { additionalProperties: false }),
8667
+ Type5.Object({ kind: Type5.Literal("weekly"), weekday: Type5.Integer({ minimum: 0, maximum: 6, description: "0 is Sunday" }), ...Clock }, { additionalProperties: false })
8668
+ ], { description: `When it runs, in this machine's local time: once at a time, every N minutes (${TASK_MINUTES_MIN} at the least), daily at hour:minute, or weekly on a weekday at hour:minute` });
8669
+ var Id = Type5.String({ minLength: 1, maxLength: 80, description: "The task's id, from tasks.list or a <task> in your batch" });
8670
+ var Fields = {
8671
+ name: Type5.String({ minLength: 1, maxLength: 80, description: "A short name, such as Morning news" }),
8672
+ instruction: Type5.String({ minLength: 1, maxLength: 8e3, description: "What to do each time, in full: you get it back when the task is due, so write it for your future self" })
8673
+ };
8674
+ var ByID = Type5.Object({ id: Id }, { additionalProperties: false });
8675
+ var taskDefinitions = {
8676
+ "tasks.list": {
8677
+ description: "List your scheduled tasks: each one's id, name, schedule, state (active, paused or done), next and last run. Times are this machine's local time, which timeZone names.",
8678
+ schema: Type5.Object({}, { additionalProperties: false })
8679
+ },
8680
+ "tasks.get": { description: "One of your scheduled tasks in full, with its instruction.", schema: ByID },
8681
+ "tasks.create": {
8682
+ description: "Schedule a task for yourself. When it is due you get its instruction in a batch, as a <task>, and do it then; it never interrupts a turn, it waits for it to end. Runs missed while this chat platform was not running are skipped.",
8683
+ schema: Type5.Object({ ...Fields, schedule: Schedule }, { additionalProperties: false })
8684
+ },
8685
+ "tasks.update": {
8686
+ description: "Change one of your tasks; give only what changes. A new schedule counts from now; an interval left as it was keeps its rhythm.",
8687
+ schema: Type5.Object({ id: Id, name: Type5.Optional(Fields.name), instruction: Type5.Optional(Fields.instruction), schedule: Type5.Optional(Schedule) }, { additionalProperties: false })
8688
+ },
8689
+ "tasks.pause": { description: "Stop a task from running until you resume it; it stays in the list.", schema: ByID },
8690
+ "tasks.resume": { description: "Start a paused task again, from its next time after now.", schema: ByID },
8691
+ "tasks.delete": { description: "Delete a task; it is gone for good. To stop it for a while, pause it instead.", schema: ByID }
8692
+ };
8693
+ function taskRow(task, full = false) {
8694
+ return {
8695
+ id: task.id,
8696
+ name: task.name,
8697
+ schedule: describeSchedule(task.schedule),
8698
+ state: task.state,
8699
+ ...task.nextRunAt !== null ? { next: localIso(task.nextRunAt) } : {},
8700
+ ...task.lastRunAt !== null ? { last: localIso(task.lastRunAt) } : {},
8701
+ ...full ? { instruction: task.instruction } : {}
8702
+ };
8703
+ }
8704
+ function callTask(tasks, botId, name3, args) {
8705
+ const id = args.id;
8706
+ switch (name3) {
8707
+ case "tasks.list":
8708
+ return { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, now: localIso(Date.now()), tasks: tasks.list(botId).map((task) => taskRow(task)) };
8709
+ case "tasks.get": {
8710
+ const task = tasks.get(botId, id);
8711
+ if (!task) throw new ChatError(404, "Task not found; see tasks.list");
8712
+ return taskRow(task, true);
8713
+ }
8714
+ case "tasks.create":
8715
+ return taskRow(tasks.create(botId, args), true);
8716
+ case "tasks.update": {
8717
+ const { id: _id, ...rest } = args;
8718
+ return taskRow(tasks.update(botId, id, rest), true);
8719
+ }
8720
+ case "tasks.pause":
8721
+ return taskRow(tasks.setState(botId, id, "paused"));
8722
+ case "tasks.resume":
8723
+ return taskRow(tasks.setState(botId, id, "active"));
8724
+ case "tasks.delete":
8725
+ tasks.remove(botId, id);
8726
+ return { id, deleted: true };
8727
+ }
8728
+ }
8729
+
7485
8730
  // src/server/service/bot/tools.ts
7486
- var Gateway = Type5.Object({
7487
- action: Type5.Union([Type5.Literal("list"), Type5.Literal("describe"), Type5.Literal("call")]),
7488
- tool: Type5.Optional(Type5.String()),
7489
- args: Type5.Optional(Type5.Record(Type5.String(), Type5.Unknown()))
8731
+ var Gateway = Type6.Object({
8732
+ action: Type6.Union([Type6.Literal("list"), Type6.Literal("describe"), Type6.Literal("call")]),
8733
+ tool: Type6.Optional(Type6.String()),
8734
+ args: Type6.Optional(Type6.Record(Type6.String(), Type6.Unknown()))
7490
8735
  }, { additionalProperties: false });
7491
8736
  var Tools = class {
7492
8737
  constructor(scope) {
@@ -7499,7 +8744,7 @@ var Tools = class {
7499
8744
  /** The agent Bot's own tools (A48); other Bots have none. */
7500
8745
  agent;
7501
8746
  get catalog() {
7502
- return this.agent ? { ...definitions, ...agentDefinitions } : definitions;
8747
+ return this.agent ? { ...definitions, ...taskDefinitions, ...agentDefinitions } : { ...definitions, ...taskDefinitions };
7503
8748
  }
7504
8749
  /** Posts a card and waits for the person's answer (messages.ask, and the agent's confirmations). */
7505
8750
  async ask(chatId, question, toolCallId, signal) {
@@ -7531,22 +8776,27 @@ var Tools = class {
7531
8776
  description: "Gateway to the chat and to your own History. Use list to see the tools, describe to check a tool's parameters, then call to run it. Only what messages.send or messages.ask sends successfully is visible in the chat.",
7532
8777
  parameters: Gateway,
7533
8778
  execute: async (raw, { toolCallId, signal }) => {
7534
- if (!Value.Check(Gateway, raw)) throw new ChatError(400, "Invalid chat tool request");
8779
+ if (!Value2.Check(Gateway, raw)) throw new ChatError(400, [
8780
+ "Invalid chat tool request:",
8781
+ ...argumentProblems(Gateway, raw).map((line) => `- ${line}`),
8782
+ 'It takes {action: "list" | "describe" | "call", tool, args}: list takes neither, describe takes tool, call takes both.'
8783
+ ].join("\n"));
7535
8784
  signal?.throwIfAborted();
7536
8785
  if (raw.action === "list") {
7537
8786
  if (raw.tool !== void 0 || raw.args !== void 0) throw new ChatError(400, "list does not accept tool or args");
7538
8787
  return Object.entries(this.catalog).map(([name4, tool]) => ({ name: name4, description: tool.description }));
7539
8788
  }
7540
- if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, "Unknown chat tool; use list first");
8789
+ if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, unknownTool(raw.tool, Object.keys(this.catalog)));
7541
8790
  const name3 = raw.tool, definition = this.catalog[name3];
7542
8791
  if (raw.action === "describe") {
7543
8792
  if (raw.args !== void 0) throw new ChatError(400, "describe does not accept args");
7544
8793
  return { name: name3, description: definition.description, parameters: definition.schema };
7545
8794
  }
7546
8795
  const args = raw.args;
7547
- if (!args || !Value.Check(definition.schema, args)) throw new ChatError(400, "Invalid tool arguments; use describe for the parameter schema");
8796
+ if (!args || !Value2.Check(definition.schema, args)) throw new ChatError(400, invalidArguments(name3, definition.schema, args));
7548
8797
  if (this.agent && Object.hasOwn(agentDefinitions, name3))
7549
8798
  return this.agent.call(name3, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
8799
+ if (Object.hasOwn(taskDefinitions, name3)) return callTask(this.scope.tasks, this.scope.botId, name3, args);
7550
8800
  switch (name3) {
7551
8801
  case "history.read":
7552
8802
  return readHistory(this.scope.memory, this.scope.workspace, args, signal);
@@ -7584,6 +8834,16 @@ var Tools = class {
7584
8834
  until: result.selected[0]?.value === "once" ? "this run ends" : "your owner revokes it"
7585
8835
  };
7586
8836
  }
8837
+ case "environments.request": {
8838
+ const parsed = args;
8839
+ if (!this.tools.chat(parsed.chatId).memberIds.some((id) => this.scope.chats.member(id)?.kind === "user")) throw new ChatError(400, "No person in this chat to answer; ask in a chat with your owner");
8840
+ const input = { ...parsed, purpose: parsed.purpose.trim() }, prepared = await this.scope.places.request(input);
8841
+ const added = (record3) => ({ id: record3.id, name: record3.name, note: "environments.switch with this id moves your tools there" });
8842
+ if ("granted" in prepared) return { status: "granted", ...added(prepared.granted), note: "You may already use it; environments.switch with this id moves your tools there" };
8843
+ const result = await this.ask(parsed.chatId, prepared.question, toolCallId, signal), found = this.scope.places.found(input);
8844
+ if (result.status !== "answered" || !found) return { messageId: result.messageId, status: "declined", note: "Your owner declined. Don't ask for its password in the chat; go on without it, or ask what they would like instead." };
8845
+ return { messageId: result.messageId, status: prepared.question.machine?.existing ? "granted" : "added", ...added(found) };
8846
+ }
7587
8847
  case "chats.create":
7588
8848
  return this.tools.create(args);
7589
8849
  case "chats.update":
@@ -7685,7 +8945,7 @@ var Tools = class {
7685
8945
  };
7686
8946
 
7687
8947
  // src/server/service/bot/inbox.ts
7688
- import { join as join10, dirname as dirname7 } from "node:path";
8948
+ import { join as join12, dirname as dirname8 } from "node:path";
7689
8949
 
7690
8950
  // src/server/shared/agent.ts
7691
8951
  var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
@@ -7708,15 +8968,21 @@ function remember(map, id, definition) {
7708
8968
  }
7709
8969
  var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
7710
8970
  var iso2 = (time2) => new Date(time2).toISOString().replace(".000Z", "Z");
7711
- 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.`;
7712
- function encodeNoticeBatch(botId, notices, environment) {
7713
- if (!notices.length) throw new Error("Empty notice batch");
8971
+ var welcome = (owner, language) => `${owner} has just set up this chat platform 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 it is and how you can help, such as building a team of Bots for their work and running the platform for them. Then ask what they want to get done: a software project, writing, research or something else.`;
8972
+ function encodeNoticeBatch(botId, notices, environment, prompt) {
8973
+ if (!notices.length && !prompt) throw new Error("Empty notice batch");
7714
8974
  const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map();
7715
8975
  const list = noticeTail(notices, chats, members);
7716
- return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${environmentLine(environment)}</notice_batch>`;
8976
+ return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${instructionsLine(prompt)}${environmentLine(environment)}</notice_batch>`;
8977
+ }
8978
+ function instructionsLine(change) {
8979
+ if (!change) return "";
8980
+ const how = change.whole ? "The new text of the changed parts follows." : "Lines starting with - were removed and lines starting with + were added; the others are context.";
8981
+ return `<instructions_changed${attribute("parts", change.parts.join(", "))}>${escape(change.diff)}<note>Your instructions changed. ${how} Follow the new version from now on. Your system prompt is not rewritten yet, so that the conversation stays cached; it will be after the next compaction.</note></instructions_changed>`;
7717
8982
  }
7718
8983
  function noticeTail(notices, chats, members) {
7719
8984
  for (const notice of notices) {
8985
+ if (notice.kind === "task") continue;
7720
8986
  if (!chats.has(notice.chat.id)) chats.set(notice.chat.id, `<chat${attribute("id", notice.chat.id)}${attribute("type", notice.chat.type)}${notice.chat.title ? attribute("title", notice.chat.title) : ""}/>`);
7721
8987
  remember(members, notice.actor.id, `<member${attribute("id", notice.actor.id)}${attribute("name", notice.actor.name)}${attribute("kind", notice.actor.kind)}/>`);
7722
8988
  }
@@ -7728,9 +8994,10 @@ function noticeTail(notices, chats, members) {
7728
8994
  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>`}`);
7729
8995
  const byAgent = changed.some((notice) => notice.actor.kind === "bot"), byOwner = changed.some((notice) => notice.actor.kind === "user");
7730
8996
  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);
7731
- 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("");
8997
+ const tasks = notices.flatMap((notice) => notice.kind !== "task" ? [] : [`<task${attribute("id", notice.task.id)}${attribute("name", notice.task.name)}${attribute("schedule", notice.task.schedule)}${attribute("due", iso2(notice.due))}>${escape(notice.task.instruction)}</task>`]);
8998
+ return (tasks.length ? `<tasks>${tasks.join("")}<note>These are your scheduled tasks, not messages from anyone. Do what each says, then send what is worth sending, to your owner unless the task says where; if nothing is, do nothing.</note></tasks>` : "") + (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 change to your name, description or persona is a change to your instructions too. 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("");
7732
8999
  }
7733
- function encodeChatBatch(botId, messages, digest, notices) {
9000
+ function encodeChatBatch(botId, messages, digest, notices, prompt) {
7734
9001
  if (!messages.length) throw new Error("Empty chat batch");
7735
9002
  const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map(), ids = /* @__PURE__ */ new Set();
7736
9003
  const encoded = messages.map((message) => {
@@ -7743,12 +9010,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
7743
9010
  const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at2);
7744
9011
  const reply = message.replyTo;
7745
9012
  if (reply) remember(members, reply.sender.id, `<member${attribute("id", reply.sender.id)}${attribute("name", reply.sender.name)}${attribute("kind", reply.sender.kind)}/>`);
7746
- const quote = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
9013
+ const quote2 = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
7747
9014
  const files = message.attachments.map((file) => `<file${attribute("id", file.id)}${attribute("name", file.name)}${attribute("type", file.type)}${attribute("size", file.size)}/>`).join("");
7748
- return `<message${attributes}>${quote}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
9015
+ return `<message${attributes}>${quote2}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
7749
9016
  });
7750
9017
  const ref = (key, item) => attribute(key, item.chatId) + (chats.has(item.chatId) ? "" : attribute("title", item.title) + attribute("type", item.type));
7751
- let tail = notices?.length ? noticeTail(notices, chats, members) : "";
9018
+ let tail = (notices?.length ? noticeTail(notices, chats, members) : "") + instructionsLine(prompt);
7752
9019
  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>`;
7753
9020
  tail += environmentLine(digest?.environment);
7754
9021
  return `<chat_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${encoded.join("")}${tail}</chat_batch>`;
@@ -7757,11 +9024,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
7757
9024
  // src/server/service/bot/inbox.ts
7758
9025
  var BotInbox = class {
7759
9026
  /** `where` is the work environment the Bot is in now, named at the end of each batch (A50). */
7760
- constructor(botId, repository, chats, files, where2) {
9027
+ constructor(botId, repository, chats, files, tasks, where2) {
7761
9028
  this.botId = botId;
7762
9029
  this.repository = repository;
7763
9030
  this.chats = chats;
7764
9031
  this.files = files;
9032
+ this.tasks = tasks;
7765
9033
  this.where = where2;
7766
9034
  }
7767
9035
  /** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. A digest delivery
@@ -7772,50 +9040,61 @@ var BotInbox = class {
7772
9040
  claim(selection) {
7773
9041
  return this.repository.claim(this.botId, selection, this.hasNotices());
7774
9042
  }
9043
+ /** Anything waiting that is not a message (A39, A41, A58): a task that is due also opens a run without messages. */
7775
9044
  hasNotices() {
7776
9045
  return this.chats.notices(this.botId).some((notice) => notice.urgency !== "digest");
7777
9046
  }
7778
9047
  reference(workspace) {
7779
9048
  if (this.repository.hasUncertainModel(this.botId)) throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.");
7780
- return this.repository.ensureSession(this.botId, join10(dirname7(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
9049
+ return this.repository.ensureSession(this.botId, join12(dirname8(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
7781
9050
  }
7782
- /** XML for the claimed messages plus the tail (other deliveries not told yet, unread); `notices` are told once
7783
- * the batch is submitted. */
7784
- async prepare(batch) {
9051
+ /** XML for the claimed messages plus the tail (other deliveries not told yet, how the instructions changed,
9052
+ * unread); `notices` are told once the batch is submitted. */
9053
+ async prepare(batch, prompt) {
7785
9054
  if (!batch.inputs.length) {
7786
- const pending2 = this.notices();
9055
+ const pending2 = this.notices(prompt);
7787
9056
  return pending2 && { xml: pending2.xml, notices: pending2.ids };
7788
9057
  }
7789
9058
  const inputs = await this.files.envelope(this.botId, batch.inputs.map((input) => input.messageId));
7790
9059
  for (const input of batch.inputs) if (!this.chats.get(this.botId, input.chatId)) throw new RuntimeFault("chat_access_changed", "Chat access has changed.");
7791
9060
  const digest = { ...this.chats.channels.digest(this.botId), ...this.where ? { environment: this.where() } : {} };
7792
9061
  const pending = this.envelope(this.chats.notices(this.botId));
7793
- return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0), notices: pending.map((notice) => notice.id) };
9062
+ return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0, prompt), notices: pending.map((notice) => notice.id) };
7794
9063
  }
7795
9064
  /** Inbox rows as the envelope shape; the steered and the batch tails share it. */
7796
9065
  envelope(notices) {
7797
- return notices.map((notice) => {
9066
+ return notices.flatMap((notice) => {
7798
9067
  const chat = { id: notice.chatId, type: notice.chatType, title: notice.chatTitle };
7799
- if (notice.kind === "note") return { kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt };
7800
- if (notice.kind === "onboard") return { kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt };
9068
+ if (notice.kind === "note") return [{ kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt }];
9069
+ if (notice.kind === "onboard") return [{ kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt }];
9070
+ if (notice.kind === "task") {
9071
+ const fired = JSON.parse(notice.detail), task = this.tasks.get(this.botId, fired.task);
9072
+ return task ? [{ kind: "task", id: notice.id, chat, task: {
9073
+ id: task.id,
9074
+ name: task.name,
9075
+ instruction: task.instruction,
9076
+ schedule: describeSchedule(task.schedule)
9077
+ }, due: fired.due, createdAt: notice.createdAt }] : [];
9078
+ }
7801
9079
  if (notice.kind === "change") {
7802
9080
  const change = JSON.parse(notice.detail);
7803
- return { kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt };
9081
+ return [{ kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt }];
7804
9082
  }
7805
9083
  const marked = notice.messageId ? this.chats.message(this.botId, notice.messageId) : null;
7806
- const text3 = marked ? previewQuotes(marked.content, marked.quotes).slice(0, 120) : "";
7807
- return { kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text: text3 }, actor: notice.actor, emoji: notice.detail, createdAt: notice.createdAt };
9084
+ const text4 = marked ? previewQuotes(marked.content, marked.quotes).slice(0, 120) : "";
9085
+ return [{ kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text: text4 }, actor: notice.actor, emoji: notice.detail, createdAt: notice.createdAt }];
7808
9086
  });
7809
9087
  }
7810
9088
  /** The batch went to the model: each delivery in its tail is told once. */
7811
9089
  acknowledge(notices) {
7812
9090
  if (notices.length) this.tell(notices);
7813
9091
  }
7814
- /** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a batch tail. */
7815
- notices() {
7816
- const pending = this.chats.notices(this.botId);
9092
+ /** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a run of notices alone.
9093
+ * `steer`: what may go into a running turn; a task waits for the turn to end and opens one of its own (A58). */
9094
+ notices(prompt, steer = false) {
9095
+ const pending = this.chats.notices(this.botId).filter((notice) => !steer || notice.kind !== "task");
7817
9096
  if (!pending.length) return null;
7818
- return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.()), ids: pending.map((notice) => notice.id) };
9097
+ return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.(), prompt), ids: pending.map((notice) => notice.id) };
7819
9098
  }
7820
9099
  /** These notices reached the model. */
7821
9100
  tell(ids) {
@@ -7910,7 +9189,7 @@ var AgentRuntime = class {
7910
9189
  }
7911
9190
  this.notices = this.notices.catch(() => {
7912
9191
  }).then(async () => {
7913
- const pending = this.inbox.notices();
9192
+ const pending = this.inbox.notices(null, true);
7914
9193
  if (!pending || this.stopping) return;
7915
9194
  if (this.prompting && this.session?.steer(pending.xml)) this.inbox.tell(pending.ids);
7916
9195
  else this.wake();
@@ -7945,7 +9224,8 @@ var AgentRuntime = class {
7945
9224
  this.update({ error: null });
7946
9225
  let submitted = false;
7947
9226
  try {
7948
- const prepared = await this.inbox.prepare(batch);
9227
+ const change = session.promptChange();
9228
+ const prepared = await this.inbox.prepare(batch, change);
7949
9229
  if (!prepared) {
7950
9230
  repository.releaseUnsubmitted(batch.run.id);
7951
9231
  continue;
@@ -7959,6 +9239,7 @@ var AgentRuntime = class {
7959
9239
  submitted = true;
7960
9240
  this.prompting = true;
7961
9241
  this.inbox.acknowledge(notices);
9242
+ if (change) session.promptTold(change);
7962
9243
  this.promptTask = session.prompt(xml2);
7963
9244
  const result = await this.promptTask;
7964
9245
  repository.finish(batch.run.id, result.outcome, result.outcome === "failed" ? "model_request_failed" : null);
@@ -7967,8 +9248,8 @@ var AgentRuntime = class {
7967
9248
  throw new RuntimeFault("model_request_failed", `Model request failed: ${detail}`, 502, { detail });
7968
9249
  }
7969
9250
  } catch (error) {
7970
- const run = repository.run(batch.run.id);
7971
- if (run?.state === "claimed" || run?.state === "submitted") repository.finish(batch.run.id, submitted ? "unknown" : "failed", publicFault(error).code);
9251
+ const run2 = repository.run(batch.run.id);
9252
+ if (run2?.state === "claimed" || run2?.state === "submitted") repository.finish(batch.run.id, submitted ? "unknown" : "failed", publicFault(error).code);
7972
9253
  throw error;
7973
9254
  } finally {
7974
9255
  this.prompting = false;
@@ -8057,17 +9338,22 @@ The chat gateway lists tools only you have: models.list, bots.create, bots.updat
8057
9338
  Other Bots may ask you to do these things for them. Do the ordinary ones; deleting always waits for the owner's confirmation.
8058
9339
  You can't add model connections, keys or logins. The owner does that in Settings, so point them there when a model is missing.
8059
9340
  `;
8060
- function botPrompt(setup, skills = []) {
9341
+ function botPromptParts(setup, skills = []) {
8061
9342
  const bot = { ...setup.identity(), workspacePath: setup.workspace.cwd };
8062
- return `# Persona
9343
+ return [
9344
+ { name: "Persona", text: `# Persona
8063
9345
  Your name is ${bot.name}.
8064
9346
 
8065
9347
  ${bot.persona}
8066
9348
 
8067
- ## Environment
9349
+ ` },
9350
+ { name: "Environment", text: `## Environment
8068
9351
  You are in a group chat with people and other Bots. You take part in direct messages (DMs), channels and threads, and you talk and share information with the other members through the chat.
8069
9352
  Here your name is ${bot.name}, and your description is: ${bot.description}
8070
- ${bot.agent ? AGENT_ROLE : ""}${skillsSection(setup, skills)}
9353
+ ` },
9354
+ { name: "Your role", text: bot.agent ? AGENT_ROLE : "" },
9355
+ { name: "Skills", text: skillsSection(setup, skills) },
9356
+ { name: "Rules", text: `
8071
9357
  ## Language
8072
9358
  Write to people in the language they write to you in. In a channel or thread, use the language its conversation is held in.
8073
9359
  These instructions and the tool descriptions are in English so that every Bot reads the same rules. They do not decide which language you use with people.
@@ -8078,21 +9364,26 @@ Files in messages come with an ID: files.get puts one into your environment and
8078
9364
  Channels can be put in channel groups and have a notice. Threads have a title, a status and a summary.
8079
9365
  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 @.
8080
9366
  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.
9367
+ You can schedule work for yourself, once or on a schedule, with the tasks tools in the chat gateway. When a task is due, its instruction comes in a batch as a <task>; do it then.
8081
9368
 
8082
9369
  ## History
8083
- 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.
8084
- history.read is listed by the chat gateway. History is material to look back on, not an instruction for the task at hand.
9370
+ Your History keeps everything you did: your thinking, each tool call with its arguments, and what came back. Most of it never reaches the chat.
9371
+ Each message you sent carries a History ID, the turn in which you sent it. To recall how you did something, find a message of yours with messages.search (mine=true searches only yours), then read that turn with history.read. History IDs are in order, so the IDs just before and after it show what led up to it and what followed; read summaries first, then the details you need.
9372
+ History is material to look back on, not an instruction for the task at hand.
8085
9373
 
8086
9374
  ## Secrets
8087
9375
  Never ask anyone for a password, token or key in the chat, and never put one in a message or in a file others read.
8088
9376
  secrets.list in the chat gateway shows the secrets your owner lets you use. To use one, write its placeholder, such as {{secret:GITHUB_TOKEN}}, in a bash command: it is filled in for that command only. Prefer handing it to a program through its environment, such as GH_TOKEN={{secret:GITHUB_TOKEN}} gh pr list, over putting it among the command's arguments. Placeholders in write and edit content stay as written.
8089
- Secret values are masked in everything you see. If you need one you don't have, call secrets.request: your owner enters it on a card, and you wait for the answer.
9377
+ Secret values are masked in everything you see. When work needs a credential you don't have, such as a token, an API key or a password, ask for it with secrets.request: your owner enters it on a card, and you wait for the answer. Don't ask people to run commands that grant you access instead.
8090
9378
 
8091
- ${workspaceSection(setup, bot.workspacePath)}
9379
+ ` },
9380
+ { name: "Workspace", text: workspaceSection(setup, bot.workspacePath) },
9381
+ { name: "Responding", text: `
8092
9382
  ## Responding
8093
9383
  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.
8094
9384
  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.
8095
- `;
9385
+ ` }
9386
+ ];
8096
9387
  }
8097
9388
  function workspaceSection(setup, workspacePath) {
8098
9389
  const place = setup.home();
@@ -8110,7 +9401,7 @@ Leave everything else alone by default: other members' workspaces, live data dir
8110
9401
  Shared directories and other people's files are read-only. Ask the person in charge before writing to them.
8111
9402
 
8112
9403
  ## Work environment
8113
- Your tools run on your owner's computer by default, the machine running Jarvy.
9404
+ Your tools run on your owner's computer by default, the machine where this chat platform's server runs.
8114
9405
  ${SWITCHING}
8115
9406
  `;
8116
9407
  }
@@ -8119,11 +9410,11 @@ function skillsSection(setup, skills) {
8119
9410
  const home = setup.home(), base = home.kind === "computer" ? home.cwd : setup.workspace.cwd;
8120
9411
  return `
8121
9412
  ## Skills
8122
- Skills are guides that come with Jarvy for kinds of work you help with. When a task matches one, read its file before you start, and follow it. They are suggestions for the owner: propose a plan from them and act once the owner agrees. Jarvy keeps these files in your workspace and writes them again each time you start, so changes to them don't last.
9413
+ Skills are guides that come with this chat platform for kinds of work you help with. When a task matches one, read its file before you start, and follow it. They are suggestions for the owner: propose a plan from them and act once the owner agrees. They are kept in your workspace and written again each time you start, so changes to them don't last.
8123
9414
  ${skills.map((skill) => `- ${skill.name}: ${skill.description} File: ${base}/${skillPath(skill.name)}`).join("\n")}
8124
9415
  `;
8125
9416
  }
8126
- 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.";
9417
+ 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. To work on another machine over SSH, ask your owner to add it with environments.request; they type its password on that card, not in the chat.";
8127
9418
 
8128
9419
  // src/server/shared/live.ts
8129
9420
  var LIVE_STEPS = 300;
@@ -8133,8 +9424,8 @@ var LIVE_INPUT = 300;
8133
9424
  var LIVE_MESSAGE = 2e3;
8134
9425
 
8135
9426
  // src/server/service/bot/live-input.ts
8136
- function clip(text3, length) {
8137
- return text3.length > length ? `${text3.slice(0, length - 1)}\u2026` : text3;
9427
+ function clip(text4, length) {
9428
+ return text4.length > length ? `${text4.slice(0, length - 1)}\u2026` : text4;
8138
9429
  }
8139
9430
  var field = (value2, length = LIVE_INPUT) => typeof value2 === "string" ? clip(value2, length) : void 0;
8140
9431
  var record2 = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
@@ -8163,7 +9454,7 @@ function liveInput(tool, args, chat) {
8163
9454
  }
8164
9455
  function gateway(values, chat) {
8165
9456
  const action = values.action === "call" && typeof values.tool === "string" ? values.tool : typeof values.action === "string" ? values.action : "";
8166
- const inner = record2(values.args), where2 = target(inner, chat);
9457
+ const inner = record2(values.args), where2 = target2(inner, chat);
8167
9458
  if (action === "messages.send") return { action, chat: where2, text: field(inner.message, LIVE_MESSAGE) };
8168
9459
  if (action === "messages.ask") return {
8169
9460
  action,
@@ -8173,7 +9464,7 @@ function gateway(values, chat) {
8173
9464
  };
8174
9465
  return where2 ? { action, chat: where2 } : { action };
8175
9466
  }
8176
- function target(inner, chat) {
9467
+ function target2(inner, chat) {
8177
9468
  if (typeof inner.to === "string") return { id: null, type: "dm", title: clip(inner.to, 80) };
8178
9469
  return typeof inner.chatId === "string" ? chat(inner.chatId) ?? void 0 : void 0;
8179
9470
  }
@@ -8188,10 +9479,10 @@ function sentChat(output, chat) {
8188
9479
 
8189
9480
  // src/server/service/bot/live.ts
8190
9481
  var FLUSH_MS = 100;
8191
- function liveOutput(text3) {
8192
- if (!text3) return null;
8193
- if (text3.length <= 2 * LIVE_OUTPUT_END) return { head: text3, tail: "", omitted: 0 };
8194
- return { head: text3.slice(0, LIVE_OUTPUT_END), tail: text3.slice(-LIVE_OUTPUT_END), omitted: text3.length - 2 * LIVE_OUTPUT_END };
9482
+ function liveOutput(text4) {
9483
+ if (!text4) return null;
9484
+ if (text4.length <= 2 * LIVE_OUTPUT_END) return { head: text4, tail: "", omitted: 0 };
9485
+ return { head: text4.slice(0, LIVE_OUTPUT_END), tail: text4.slice(-LIVE_OUTPUT_END), omitted: text4.length - 2 * LIVE_OUTPUT_END };
8195
9486
  }
8196
9487
  var LiveTurns = class {
8197
9488
  constructor(chat) {
@@ -8248,8 +9539,8 @@ var LiveTurns = class {
8248
9539
  }
8249
9540
  const step = this.steps.get(id);
8250
9541
  if (!step) return;
8251
- const text3 = (step.text ?? "") + event.delta, over = Math.max(0, text3.length - LIVE_TEXT);
8252
- step.text = text3.slice(over);
9542
+ const text4 = (step.text ?? "") + event.delta, over = Math.max(0, text4.length - LIVE_TEXT);
9543
+ step.text = text4.slice(over);
8253
9544
  step.omitted = (step.omitted ?? 0) + over;
8254
9545
  this.queue({ op: "text", id, append: event.delta });
8255
9546
  return;
@@ -8500,12 +9791,15 @@ var BotService = class {
8500
9791
  this.deps = deps;
8501
9792
  this.agent = new AgentOps(this, deps.store, deps.chats);
8502
9793
  this.computers = new Computers({ store: deps.store, changed: () => this.listChanged() });
8503
- this.environments = new BotEnvironments(deps.store, this.computers);
9794
+ this.machines = new SshMachines(deps.store, deps.chats, () => this.listChanged());
9795
+ this.environments = new BotEnvironments(deps.store, this.computers, this.machines);
8504
9796
  }
8505
9797
  bots = /* @__PURE__ */ new Map();
8506
9798
  lives = /* @__PURE__ */ new Map();
8507
9799
  computers;
8508
9800
  environments;
9801
+ /** The owner's SSH machines (A57). */
9802
+ machines;
8509
9803
  closed = false;
8510
9804
  started = false;
8511
9805
  /** What only the agent Bot does (A48). */
@@ -8526,14 +9820,14 @@ var BotService = class {
8526
9820
  const place = this.environments.here(id, record3.workspacePath);
8527
9821
  return { name: place.name, directory: place.directory };
8528
9822
  };
8529
- const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
9823
+ const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, store.tasks, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
8530
9824
  store,
8531
9825
  bot: record3,
8532
9826
  selection: model.selection,
8533
9827
  reference,
8534
9828
  credentials,
8535
9829
  tools: setup.tools,
8536
- systemPrompt: () => botPrompt(setup, skills),
9830
+ systemPrompt: () => botPromptParts(setup, skills),
8537
9831
  onHistory: setup.onHistory,
8538
9832
  activity,
8539
9833
  live: (event) => live.event(event),
@@ -8544,7 +9838,7 @@ var BotService = class {
8544
9838
  const place = this.environments.home(id, record3.workspacePath);
8545
9839
  return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
8546
9840
  }, skills);
8547
- const memory = new Memory(join11(dirname8(record3.workspacePath), "history.db"));
9841
+ const memory = new Memory(join13(dirname9(record3.workspacePath), "history.db"));
8548
9842
  bot = new Bot(id, {
8549
9843
  identity: new Identity(id, store),
8550
9844
  model: new Model(id, store, resolveThinking),
@@ -8561,9 +9855,12 @@ var BotService = class {
8561
9855
  places: {
8562
9856
  list: () => this.environments.list(id, record3.workspacePath),
8563
9857
  switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
8564
- summary: (botId) => this.environments.summary(botId)
9858
+ summary: (botId) => this.environments.summary(botId),
9859
+ request: (input) => this.machines.prepareRequest(id, input),
9860
+ found: (input) => this.machines.find(id, input)
8565
9861
  },
8566
9862
  secrets: { list: () => this.deps.secrets.listFor(id), prepare: (name3, purpose) => this.deps.secrets.prepareRequest(id, name3, purpose) },
9863
+ tasks: this.deps.tasks,
8567
9864
  ...id === AGENT_BOT_ID ? { agent: this.agent } : {}
8568
9865
  }),
8569
9866
  agentRuntime: runtime,
@@ -8633,10 +9930,33 @@ var BotService = class {
8633
9930
  }
8634
9931
  /** The owner lets a Bot use their computer, or takes it back; taking it back while the Bot is there sends it home. */
8635
9932
  grantHost(id, granted) {
8636
- const { changed, moved } = this.environments.grantHost(id, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
8637
- const changes = changed ? [{ what: "access", value: `your owner's computer: ${granted ? "you may switch to it" : "taken back"}` }] : [];
9933
+ return this.grant(id, "host", granted);
9934
+ }
9935
+ /** The owner lets a Bot use an SSH machine (A57), or takes it back, the same way. */
9936
+ grantMachine(id, machineId, granted) {
9937
+ this.machines.get(machineId);
9938
+ return this.grant(id, machineId, granted);
9939
+ }
9940
+ /** The owner deletes an SSH machine: Bots working there are back in their default, and told. */
9941
+ async removeMachine(machineId, force) {
9942
+ const name3 = this.machines.get(machineId).name, granted = this.machines.get(machineId).botIds;
9943
+ const moved = await this.machines.remove(machineId, force);
9944
+ for (const id of /* @__PURE__ */ new Set([...granted, ...moved])) {
9945
+ if (!this.deps.store.getBot(id)) continue;
9946
+ const changes = [{ what: "access", value: `${name3}: removed` }];
9947
+ if (moved.includes(id)) {
9948
+ const to = this.environments.here(id, this.record(id).workspacePath);
9949
+ changes.push({ what: "environment", value: `${to.name}, in ${to.directory}` });
9950
+ }
9951
+ this.tellOwner(id, changes, moved.includes(id));
9952
+ }
9953
+ }
9954
+ grant(id, environmentId, granted) {
9955
+ const { changed, moved, name: name3 } = this.environments.grant(id, environmentId, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
9956
+ const changes = changed ? [{ what: "access", value: `${name3}: ${granted ? "you may switch to it" : "taken back"}` }] : [];
8638
9957
  if (moved) changes.push({ what: "environment", value: where(moved) });
8639
9958
  if (changes.length) this.tellOwner(id, changes, !!moved);
9959
+ if (changed && environmentId !== "host") this.listChanged();
8640
9960
  return this.describe(id);
8641
9961
  }
8642
9962
  /** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
@@ -8764,7 +10084,7 @@ var BotService = class {
8764
10084
  this.lives.delete(id);
8765
10085
  await this.environments.release(id);
8766
10086
  if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
8767
- if (!keepFiles) await rm3(dirname8(workspacePath), { recursive: true, force: true });
10087
+ if (!keepFiles) await rm4(dirname9(workspacePath), { recursive: true, force: true });
8768
10088
  }
8769
10089
  async close() {
8770
10090
  if (this.closed) return;
@@ -8800,8 +10120,8 @@ var ConnectionService = class {
8800
10120
  }
8801
10121
  info() {
8802
10122
  const listening = this.listening, material = this.material;
8803
- const addresses = listening && material ? lanAddresses(listening.host).map((address) => `https://${address.includes(":") ? `[${address}]` : address}:${listening.port}`) : [];
8804
- return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null };
10123
+ const addresses = listening && material ? lanAddresses(listening.host).map((address2) => `https://${address2.includes(":") ? `[${address2}]` : address2}:${listening.port}`) : [];
10124
+ return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null, fingerprintSha1: material?.fingerprintSha1 ?? null };
8805
10125
  }
8806
10126
  /** The root certificate (DER) for devices to install, when serving HTTPS. */
8807
10127
  rootCertificate() {
@@ -8922,6 +10242,123 @@ var SecretService = class {
8922
10242
  }
8923
10243
  };
8924
10244
 
10245
+ // src/server/service/tasks.ts
10246
+ var MAX_WAIT_MS = 6e4;
10247
+ var TaskService = class {
10248
+ constructor(store, chats, wake) {
10249
+ this.store = store;
10250
+ this.chats = chats;
10251
+ this.wake = wake;
10252
+ }
10253
+ timer;
10254
+ stopped = false;
10255
+ /** Where a due task is told: the DM between the Bot and its owner, made when first needed. */
10256
+ dm(botId) {
10257
+ const bot = this.store.getBot(botId);
10258
+ if (!bot) throw new ChatError(404, "Bot not found");
10259
+ return this.chats.openDirect(botId, this.store.userMemberId()).id;
10260
+ }
10261
+ /** Checked against what a Bot and its owner can both mean: an existing schedule the Bot can compute. `previous`:
10262
+ * the task's schedule before; an interval left as it was keeps counting from when it was set, a new one from now. */
10263
+ schedule(value2, previous) {
10264
+ if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new ValidationError("A schedule is needed", "task_schedule_invalid");
10265
+ const input = value2;
10266
+ const clock = (hour, minute) => {
10267
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59)
10268
+ throw new ValidationError("Hour is 0-23 and minute 0-59", "task_schedule_invalid");
10269
+ return { hour, minute };
10270
+ };
10271
+ switch (input.kind) {
10272
+ case "once": {
10273
+ const at2 = typeof input.at === "string" ? Date.parse(input.at) : input.at;
10274
+ if (!Number.isSafeInteger(at2)) throw new ValidationError("A one-off task needs a time", "task_schedule_invalid");
10275
+ return { kind: "once", at: at2 };
10276
+ }
10277
+ case "every": {
10278
+ if (!Number.isInteger(input.minutes) || input.minutes < TASK_MINUTES_MIN || input.minutes > 60 * 24 * 30)
10279
+ throw new ValidationError(`Every ${TASK_MINUTES_MIN} minutes at the least, a month at most`, "task_schedule_invalid");
10280
+ const minutes = input.minutes;
10281
+ return { kind: "every", minutes, since: previous?.kind === "every" && previous.minutes === minutes ? previous.since : Date.now() };
10282
+ }
10283
+ case "daily":
10284
+ return { kind: "daily", ...clock(input.hour, input.minute) };
10285
+ case "weekly": {
10286
+ if (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6)
10287
+ throw new ValidationError("Weekday is 0 (Sunday) to 6", "task_schedule_invalid");
10288
+ return { kind: "weekly", weekday: input.weekday, ...clock(input.hour, input.minute) };
10289
+ }
10290
+ default:
10291
+ throw new ValidationError("Schedule kind is once, every, daily or weekly", "task_schedule_invalid");
10292
+ }
10293
+ }
10294
+ text(value2, label, max) {
10295
+ if (typeof value2 !== "string" || !value2.trim() || value2.length > max || value2.includes("\0")) throw new ValidationError(`${label} must be 1-${max} characters`, "task_invalid");
10296
+ return value2.trim();
10297
+ }
10298
+ list(botId) {
10299
+ return this.store.tasks.list(botId);
10300
+ }
10301
+ get(botId, id) {
10302
+ return this.store.tasks.get(botId, id);
10303
+ }
10304
+ create(botId, input) {
10305
+ if (!this.store.getBot(botId)) throw new ChatError(404, "Bot not found");
10306
+ const task = this.store.tasks.create({
10307
+ botId,
10308
+ name: this.text(input.name, "A name", 80),
10309
+ instruction: this.text(input.instruction, "An instruction", 8e3),
10310
+ schedule: this.schedule(input.schedule)
10311
+ });
10312
+ this.tick();
10313
+ return task;
10314
+ }
10315
+ update(botId, id, input) {
10316
+ const before = this.store.tasks.get(botId, id);
10317
+ if (!before) throw new ChatError(404, "Task not found");
10318
+ const task = this.store.tasks.update(botId, id, {
10319
+ ...input.name === void 0 ? {} : { name: this.text(input.name, "A name", 80) },
10320
+ ...input.instruction === void 0 ? {} : { instruction: this.text(input.instruction, "An instruction", 8e3) },
10321
+ ...input.schedule === void 0 ? {} : { schedule: this.schedule(input.schedule, before.schedule) }
10322
+ });
10323
+ this.tick();
10324
+ return task;
10325
+ }
10326
+ setState(botId, id, state) {
10327
+ const task = this.store.tasks.setState(botId, id, state);
10328
+ if (!task) throw new ChatError(404, "Task not found");
10329
+ this.tick();
10330
+ return task;
10331
+ }
10332
+ remove(botId, id) {
10333
+ if (!this.store.tasks.remove(botId, id)) throw new ChatError(404, "Task not found");
10334
+ this.tick();
10335
+ }
10336
+ /** Starts the timer. Runs missed while the server was not running are skipped, not made up (A58): each task moves on to
10337
+ * its next time after now, and a one-off already past is done. */
10338
+ start() {
10339
+ this.stopped = false;
10340
+ this.store.tasks.skipOverdue();
10341
+ this.tick();
10342
+ }
10343
+ stop() {
10344
+ this.stopped = true;
10345
+ if (this.timer) clearTimeout(this.timer);
10346
+ this.timer = void 0;
10347
+ }
10348
+ /** One pass: fire what is due, then sleep until the next one (or a minute, so a clock change is noticed). */
10349
+ tick() {
10350
+ if (this.stopped) return;
10351
+ if (this.timer) clearTimeout(this.timer);
10352
+ this.timer = void 0;
10353
+ const dms = new Map(this.store.tasks.dueBots().map((botId) => [botId, this.dm(botId)]));
10354
+ for (const fired of this.store.tasks.fire(dms)) this.wake(fired.botId);
10355
+ const soonest = this.store.tasks.soonest();
10356
+ const wait = soonest === null ? MAX_WAIT_MS : Math.min(MAX_WAIT_MS, Math.max(1e3, soonest - Date.now()));
10357
+ this.timer = setTimeout(() => this.tick(), wait);
10358
+ this.timer.unref?.();
10359
+ }
10360
+ };
10361
+
8925
10362
  // src/server/application.ts
8926
10363
  var Application = class _Application {
8927
10364
  constructor(store, dataDir, release, factory) {
@@ -8945,12 +10382,14 @@ var Application = class _Application {
8945
10382
  this.files = new ChatFiles(this.chats);
8946
10383
  store.runtime.recover();
8947
10384
  this.secrets = new SecretService(store, this.chats);
10385
+ this.tasks = new TaskService(store, this.chats, (botId) => this.bots.deliver(botId, urgencyOf("task", "schedule")));
8948
10386
  this.bots = new BotService({
8949
10387
  store,
8950
10388
  chats: this.chats,
8951
10389
  files: this.files,
8952
10390
  credentials: this.credentials,
8953
10391
  secrets: this.secrets,
10392
+ tasks: this.tasks,
8954
10393
  changed: (id) => this.events.publish({ kind: "bot", id, status: this.bots.status(id) }),
8955
10394
  listChanged: () => this.events.publish({ kind: "bots" }),
8956
10395
  factory
@@ -8971,9 +10410,11 @@ var Application = class _Application {
8971
10410
  models;
8972
10411
  connection;
8973
10412
  secrets;
10413
+ /** Scheduled tasks and their timer (A58). */
10414
+ tasks;
8974
10415
  closing;
8975
10416
  static async open(dataDir, options = {}) {
8976
- await mkdir6(resolve3(dataDir), { recursive: true, mode: 448 });
10417
+ await mkdir7(resolve3(dataDir), { recursive: true, mode: 448 });
8977
10418
  const root = await realpath(resolve3(dataDir));
8978
10419
  let app;
8979
10420
  let release;
@@ -8982,7 +10423,7 @@ var Application = class _Application {
8982
10423
  retries: 0,
8983
10424
  stale: 3e4,
8984
10425
  update: 1e4,
8985
- lockfilePath: join12(root, ".runtime.lock"),
10426
+ lockfilePath: join14(root, ".runtime.lock"),
8986
10427
  onCompromised: () => {
8987
10428
  console.error("[runtime] data lock compromised");
8988
10429
  options.onCompromised?.();
@@ -9005,10 +10446,12 @@ var Application = class _Application {
9005
10446
  }
9006
10447
  start() {
9007
10448
  this.bots.start();
10449
+ this.tasks.start();
9008
10450
  this.models.refreshCatalog();
9009
10451
  }
9010
10452
  close() {
9011
10453
  if (!this.closing) this.closing = (async () => {
10454
+ this.tasks.stop();
9012
10455
  await this.auth.close();
9013
10456
  await this.bots.close();
9014
10457
  await this.credentials.close();
@@ -9021,17 +10464,17 @@ var Application = class _Application {
9021
10464
  };
9022
10465
 
9023
10466
  // src/server/daemon.ts
9024
- import { spawn as spawn2 } from "node:child_process";
9025
- import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
9026
- import { join as join13, resolve as resolve4 } from "node:path";
9027
- var pidPath = (dir) => join13(resolve4(dir), "jarvy.pid");
9028
- var logPath = (dir) => join13(resolve4(dir), "jarvy.log");
10467
+ import { spawn as spawn3 } from "node:child_process";
10468
+ import { closeSync, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
10469
+ import { join as join15, resolve as resolve4 } from "node:path";
10470
+ var pidPath = (dir) => join15(resolve4(dir), "jarvy.pid");
10471
+ var logPath = (dir) => join15(resolve4(dir), "jarvy.log");
9029
10472
  function writePidFile(dir, url) {
9030
10473
  const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
9031
- writeFileSync3(pidPath(dir), JSON.stringify(record3), { mode: 384 });
10474
+ writeFileSync4(pidPath(dir), JSON.stringify(record3), { mode: 384 });
9032
10475
  }
9033
10476
  function removePidFile(dir) {
9034
- if (readRecord(dir)?.pid === process.pid) rmSync2(pidPath(dir), { force: true });
10477
+ if (readRecord(dir)?.pid === process.pid) rmSync3(pidPath(dir), { force: true });
9035
10478
  }
9036
10479
  function readRecord(dir) {
9037
10480
  try {
@@ -9060,15 +10503,15 @@ function runningServer(dir) {
9060
10503
  if (!record3) return null;
9061
10504
  const token = alive(record3.pid) ? startToken(record3.pid) : null;
9062
10505
  if (token === null || token && record3.startToken && token !== record3.startToken) {
9063
- rmSync2(pidPath(dir), { force: true });
10506
+ rmSync3(pidPath(dir), { force: true });
9064
10507
  return null;
9065
10508
  }
9066
10509
  return record3;
9067
10510
  }
9068
10511
  function startBackground(script, runArgs, dir) {
9069
- mkdirSync5(resolve4(dir), { recursive: true, mode: 448 });
10512
+ mkdirSync6(resolve4(dir), { recursive: true, mode: 448 });
9070
10513
  const log = openSync(logPath(dir), "a", 384);
9071
- const child = spawn2(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
10514
+ const child = spawn3(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
9072
10515
  closeSync(log);
9073
10516
  return new Promise((resolvePromise, reject) => {
9074
10517
  child.once("error", reject);
@@ -9101,7 +10544,7 @@ async function stopServer(dir, waitMs = 3e4) {
9101
10544
  process.kill(record3.pid, "SIGTERM");
9102
10545
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
9103
10546
  if (!alive(record3.pid)) {
9104
- rmSync2(pidPath(dir), { force: true });
10547
+ rmSync3(pidPath(dir), { force: true });
9105
10548
  return "stopped";
9106
10549
  }
9107
10550
  await new Promise((done) => setTimeout(done, 200));
@@ -9169,7 +10612,7 @@ async function initialize(store) {
9169
10612
  }
9170
10613
  function reachNotes(host, port, allowHttp) {
9171
10614
  if (isLoopbackHost(host)) return { info: [], warning: null };
9172
- const info = lanAddresses(host).map((address) => `On your network: https://${address.includes(":") ? `[${address}]` : address}:${port}`);
10615
+ const info = lanAddresses(host).map((address2) => `On your network: https://${address2.includes(":") ? `[${address2}]` : address2}:${port}`);
9173
10616
  info.push("To trust Jarvy on other devices, install its certificate from Settings \u2192 Network.");
9174
10617
  return { info, warning: allowHttp ? "WARNING: HTTP access is allowed in Settings \u2192 Network, so passwords and conversations reach other devices unencrypted." : null };
9175
10618
  }
@@ -9249,8 +10692,8 @@ async function main() {
9249
10692
  try {
9250
10693
  if (!store.isInitialized()) await initialize(store);
9251
10694
  const { host, port, url } = listenOptions(args);
9252
- const webRoot = join14(dirname9(fileURLToPath2(import.meta.url)), "../web");
9253
- if (!existsSync5(join14(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
10695
+ const webRoot = join16(dirname10(fileURLToPath2(import.meta.url)), "../web");
10696
+ if (!existsSync5(join16(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
9254
10697
  const server = createJarvyServer(app, webRoot, app.connection.prepare(host, port));
9255
10698
  closeHttp = () => server.close();
9256
10699
  await server.listen(port, host);