pinomad 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +4 -2
  2. package/dist/{chunk-837gh8nr.js → chunk-0xh6cy9v.js} +69 -17
  3. package/dist/{chunk-n73t5xzw.js → chunk-367n9je8.js} +2 -2
  4. package/dist/{chunk-xhwkx9bd.js → chunk-3dnnaz4j.js} +14 -15
  5. package/dist/{chunk-z5pmcscx.js → chunk-4btj08we.js} +676 -377
  6. package/dist/{chunk-7gmkgyaz.js → chunk-7qb9tq54.js} +324 -4
  7. package/dist/{chunk-d4y99pfy.js → chunk-7s1f4z4g.js} +4 -3
  8. package/dist/chunk-b1h374jr.js +159 -0
  9. package/dist/{chunk-fgx2sh7d.js → chunk-c8nxym2z.js} +1 -1
  10. package/dist/{chunk-akgxwg8f.js → chunk-d5ypmmr7.js} +29 -12
  11. package/dist/{chunk-374zqx5h.js → chunk-d93kvxvc.js} +18 -2
  12. package/dist/chunk-ggb1taqy.js +16 -0
  13. package/dist/chunk-jn7y815x.js +79 -0
  14. package/dist/{chunk-b3f0ced1.js → chunk-k2s1hfq5.js} +39 -16
  15. package/dist/chunk-kzbzacz9.js +12 -0
  16. package/dist/{chunk-myjp910s.js → chunk-n4tjhcb7.js} +25 -21
  17. package/dist/{chunk-cjfmfyjb.js → chunk-ngf1zatm.js} +7 -6
  18. package/dist/{chunk-vw5yb5pd.js → chunk-wqvp1fwh.js} +304 -38
  19. package/dist/{chunk-5785kwej.js → chunk-ywmm63ks.js} +9 -7
  20. package/dist/chunk-znhn8af7.js +33 -0
  21. package/dist/pinomad.js +40 -16
  22. package/package.json +1 -1
  23. package/web/apple-touch-icon.png +0 -0
  24. package/web/assets/index-C_aU_ZiV.js +383 -0
  25. package/web/assets/index-DaPcXZPM.css +1 -0
  26. package/web/favicon.svg +1 -0
  27. package/web/index.html +4 -2
  28. package/dist/chunk-17p6bfvy.js +0 -63
  29. package/dist/chunk-302y6by9.js +0 -50
  30. package/dist/chunk-knaw1a9g.js +0 -182
  31. package/dist/chunk-prhvksh3.js +0 -14
  32. package/dist/chunk-xrvmnymc.js +0 -33
  33. package/web/assets/index-8mNNPXPT.js +0 -383
  34. package/web/assets/index-BFJJgGGo.css +0 -1
@@ -1,32 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- normalizeOrigin,
4
- readServerBinding,
5
- readServerConfig,
6
- resolveRelayOrigin
7
- } from "./chunk-302y6by9.js";
8
2
  import {
9
3
  serveWebClient
10
4
  } from "./chunk-r6w5aqkw.js";
11
5
  import {
12
- relayWsBase,
13
- startRelayLink
14
- } from "./chunk-knaw1a9g.js";
15
- import {
16
- packagedVersion,
17
- webRoot
18
- } from "./chunk-fgx2sh7d.js";
19
- import {
20
- HOST_SETTING_KEYS,
21
- INVALID_FRAME_CLOSE_CODE,
22
- PROTOCOL,
23
- THINKING_LEVELS,
24
- UNAUTHORIZED_CLOSE_CODE,
25
- isQueueMode,
26
- isThinkingLevel,
27
- organize,
28
- parseClientFrame
29
- } from "./chunk-myjp910s.js";
6
+ Published,
7
+ constantValue,
8
+ openHostSettings,
9
+ sameList,
10
+ settingsDefaults
11
+ } from "./chunk-b1h374jr.js";
30
12
  import {
31
13
  DevicesDoc,
32
14
  RevocationsDoc,
@@ -34,24 +16,45 @@ import {
34
16
  ensureDevices,
35
17
  loadHostKey,
36
18
  loadRelayKey,
37
- pairingOffers
38
- } from "./chunk-837gh8nr.js";
19
+ normalizeOrigin,
20
+ pairingOffers,
21
+ readServerBinding,
22
+ readServerConfig,
23
+ resolveRelayOrigin
24
+ } from "./chunk-0xh6cy9v.js";
39
25
  import {
40
26
  SECURE_PROLOGUE,
41
27
  SecureSession,
42
28
  decodeClientHello,
43
29
  encodeHostHello,
30
+ relayWsBase,
44
31
  respondIK,
32
+ startRelayLink,
45
33
  toBase64Url
46
- } from "./chunk-7gmkgyaz.js";
34
+ } from "./chunk-7qb9tq54.js";
35
+ import {
36
+ packagedVersion,
37
+ webRoot
38
+ } from "./chunk-c8nxym2z.js";
39
+ import {
40
+ INVALID_FRAME_CLOSE_CODE,
41
+ PROTOCOL,
42
+ UNAUTHORIZED_CLOSE_CODE,
43
+ organize,
44
+ parseClientFrame
45
+ } from "./chunk-n4tjhcb7.js";
46
+ import {
47
+ THINKING_LEVELS,
48
+ isThinkingLevel
49
+ } from "./chunk-znhn8af7.js";
47
50
  import {
48
51
  DEFAULT_DATA_DIR
49
- } from "./chunk-cjfmfyjb.js";
52
+ } from "./chunk-ngf1zatm.js";
50
53
  import"./chunk-df32avw6.js";
51
54
 
52
55
  // apps/host/src/main.ts
53
56
  import { homedir as homedir3 } from "node:os";
54
- import { join as join8, resolve as resolve3 } from "node:path";
57
+ import { join as join9, resolve as resolve4 } from "node:path";
55
58
  import { parseArgs } from "node:util";
56
59
  import { createModels } from "@earendil-works/pi-ai/models";
57
60
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
@@ -185,7 +188,7 @@ function result(questions, resolution) {
185
188
  var askUserQuestion = defineTool({
186
189
  name: "ask_user_question",
187
190
  callers: ["model"],
188
- description: "Ask the user 1–4 multiple-choice questions when their decision is needed to proceed (ambiguous requirements, choosing between approaches, scope). Blocks until answered. The user can always type a custom answer, so never add an 'Other' option. Keep headers short.",
191
+ description: "Ask the user 1–4 multiple-choice questions when their decision is needed to proceed (ambiguous requirements, choosing between approaches, scope). Blocks until answered. The user can always type a custom answer, so never add an 'Other' option. Keep everything short: headers a word or two, option labels a few words. Usually leave option descriptions out; add one only when the label alone is ambiguous, as one line of at most ~60 characters.",
189
192
  parameters: Type2.Object({
190
193
  questions: Type2.Array(QuestionItemSchema, { minItems: 1, maxItems: 4 })
191
194
  }),
@@ -289,6 +292,7 @@ function createContext(options) {
289
292
  ].join(`
290
293
  `);
291
294
  }),
295
+ section("file_references", () => "When the user writes @./path in their message, it is a project file the user references; the path is relative to your current working directory. Its contents are not expanded for you — read the file yourself if the task needs them.", { tag: false }),
292
296
  section("project_context", async (input, context) => {
293
297
  const cwd = input.agent.cwd;
294
298
  if (cwd === undefined)
@@ -525,8 +529,8 @@ var todo = {
525
529
 
526
530
  // apps/host/src/host.ts
527
531
  import { randomBytes as randomBytes3 } from "node:crypto";
528
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
529
- import { basename as basename2, join as join7, sep as sep4 } from "node:path";
532
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
533
+ import { basename as basename2, join as join8, sep as sep5 } from "node:path";
530
534
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
531
535
  import { createRegistry, Harness } from "@earendil-works/pi-durable";
532
536
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
@@ -541,7 +545,7 @@ import {
541
545
  } from "@earendil-works/pi-durable";
542
546
 
543
547
  // apps/host/src/checkout.ts
544
- import { execFile } from "node:child_process";
548
+ import { execFile, spawn } from "node:child_process";
545
549
  import { randomBytes } from "node:crypto";
546
550
  import { realpath, rm, stat } from "node:fs/promises";
547
551
  import { tmpdir } from "node:os";
@@ -549,6 +553,42 @@ import { join as join2, relative, sep as sep2 } from "node:path";
549
553
  import { promisify } from "node:util";
550
554
  var run = promisify(execFile);
551
555
  var git = (dir, args, env) => run("git", ["-C", dir, ...args], env === undefined ? {} : { env: { ...process.env, ...env } });
556
+ function gitCapped(dir, args, maxBytes, env) {
557
+ return new Promise((resolve, reject) => {
558
+ const child = spawn("git", ["-C", dir, ...args], env === undefined ? {} : { env: { ...process.env, ...env } });
559
+ const chunks = [];
560
+ let size = 0;
561
+ let killed = false;
562
+ let stderr = "";
563
+ child.stdout.on("data", (chunk) => {
564
+ if (killed)
565
+ return;
566
+ chunks.push(chunk);
567
+ size += chunk.length;
568
+ if (size > maxBytes) {
569
+ killed = true;
570
+ child.kill();
571
+ child.stdout.destroy();
572
+ child.stderr.destroy();
573
+ }
574
+ });
575
+ child.stderr.on("data", (chunk) => {
576
+ if (stderr.length < 8192)
577
+ stderr += chunk.toString("utf8");
578
+ });
579
+ child.on("error", reject);
580
+ child.on("close", (code) => {
581
+ const stdout = Buffer.concat(chunks).toString("utf8");
582
+ if (killed)
583
+ resolve({ stdout, truncated: true });
584
+ else if (code === 0)
585
+ resolve({ stdout, truncated: false });
586
+ else
587
+ reject(Object.assign(new Error(`Command failed: git ${args.join(" ")}
588
+ ${stderr}`), { code, stderr }));
589
+ });
590
+ });
591
+ }
552
592
  var worktreeRoot = (dataDir) => join2(dataDir, "worktrees");
553
593
  var worktreePath = (dataDir, id) => join2(worktreeRoot(dataDir), String(id));
554
594
  var worktreeBranch = (id, suffix) => `pinomad/${id}-${suffix}`;
@@ -568,6 +608,15 @@ async function gitBase(projectPath) {
568
608
  return;
569
609
  return { repo, subdir: relative(repo, projectPath), base };
570
610
  }
611
+ async function branchOf(dir) {
612
+ const ref = await git(dir, ["symbolic-ref", "--quiet", "HEAD"]).then(({ stdout }) => stdout.trim(), () => {
613
+ return;
614
+ });
615
+ const prefix = "refs/heads/";
616
+ if (ref === undefined || !ref.startsWith(prefix))
617
+ return;
618
+ return ref.slice(prefix.length) || undefined;
619
+ }
571
620
  var repoLocks = new Map;
572
621
  function locked(repo, work) {
573
622
  const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
@@ -618,12 +667,11 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
618
667
  try {
619
668
  await git(dir, ["read-tree", "HEAD"], env);
620
669
  await git(dir, ["add", "-A"], env);
621
- const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], env);
622
- if (Buffer.byteLength(stdout) <= maxBytes)
670
+ const { stdout, truncated } = await gitCapped(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], maxBytes, env);
671
+ if (!truncated && Buffer.byteLength(stdout) <= maxBytes)
623
672
  return { base, patch: stdout, truncated: false };
624
- const cut = stdout.slice(0, maxBytes);
625
- const patch = cut.slice(0, cut.lastIndexOf(`
626
- `) + 1);
673
+ const cut = Buffer.from(stdout, "utf8").subarray(0, maxBytes);
674
+ const patch = cut.subarray(0, cut.lastIndexOf(10) + 1).toString("utf8");
627
675
  return { base, patch, truncated: true };
628
676
  } finally {
629
677
  await rm(indexFile, { force: true });
@@ -686,7 +734,23 @@ class DuplicateRequest extends Error {
686
734
  this.conversationId = conversationId;
687
735
  }
688
736
  }
689
- async function createConversation(harness, dataDir, input, defaults, models, context) {
737
+ function requireRole(agents, name) {
738
+ const found = agents.roles.find((role) => role.name === name);
739
+ if (found !== undefined)
740
+ return found;
741
+ const broken = agents.problems.find((problem) => problem.file === `${name}.md`);
742
+ if (broken !== undefined)
743
+ throw new Error(`Agent "${name}" failed to load: ${broken.reason}`);
744
+ const defined = agents.roles.map((role) => role.name).join(", ");
745
+ throw new Error(defined === "" ? `Unknown agent "${name}"` : `Unknown agent "${name}". Available agents: ${defined}`);
746
+ }
747
+ function roleModelRef(role) {
748
+ if (role.model === undefined)
749
+ return;
750
+ const slash = role.model.indexOf("/");
751
+ return { provider: role.model.slice(0, slash), modelId: role.model.slice(slash + 1) };
752
+ }
753
+ async function createConversation(harness, dataDir, input, defaults, models, modelSummaries, agents, context) {
690
754
  const index = await harness.snapshot(IndexDoc, context);
691
755
  const prior = index?.conversations.find((entry) => entry.requestId === input.requestId);
692
756
  if (prior !== undefined) {
@@ -696,12 +760,24 @@ async function createConversation(harness, dataDir, input, defaults, models, con
696
760
  }
697
761
  return prior.id;
698
762
  }
699
- const modelRef = input.model ?? defaults.model;
763
+ const role = input.agent === undefined ? undefined : requireRole(agents, input.agent);
764
+ const roleModel = role === undefined ? undefined : roleModelRef(role);
765
+ const modelRef = input.model ?? roleModel ?? defaults.model;
700
766
  const model = modelRef === undefined ? undefined : models.getModel(modelRef.provider, modelRef.modelId);
701
767
  if (input.model !== undefined && model === undefined) {
702
768
  throw new Error(`Unknown model: ${input.model.provider}/${input.model.modelId}`);
703
769
  }
704
- const thinkingLevel = model === undefined ? input.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel2(model, input.thinkingLevel ?? defaults.thinkingLevel ?? "off");
770
+ if (role !== undefined) {
771
+ const listed = modelSummaries().map((m) => `${m.provider}/${m.modelId}`).join(", ");
772
+ const suffix2 = listed === "" ? "" : `. Available models: ${listed}`;
773
+ if (modelRef === undefined) {
774
+ throw new Error(`Agent "${role.name}" has no model: the role pins none and the host has no default${suffix2}`);
775
+ }
776
+ if (model === undefined || !modelSummaries().some((m) => m.provider === modelRef.provider && m.modelId === modelRef.modelId)) {
777
+ throw new Error(`Model ${modelRef.provider}/${modelRef.modelId} is not available${suffix2}`);
778
+ }
779
+ }
780
+ const thinkingLevel = model === undefined ? input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel2(model, input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel ?? "off");
705
781
  const home = input.home;
706
782
  const projectPath = home.kind === "project" ? await normalizePath(home.path).catch(() => home.path) : undefined;
707
783
  const repo = home.kind === "project" && input.checkout !== "project" && projectPath !== undefined ? await gitBase(projectPath) : undefined;
@@ -712,7 +788,8 @@ async function createConversation(harness, dataDir, input, defaults, models, con
712
788
  agent: {
713
789
  ...projectPath === undefined || repo !== undefined ? {} : { cwd: projectPath },
714
790
  ...modelRef === undefined ? {} : { model: modelRef },
715
- ...thinkingLevel === undefined ? {} : { thinkingLevel }
791
+ ...thinkingLevel === undefined ? {} : { thinkingLevel },
792
+ ...role === undefined || role.prompt === "" ? {} : { instructions: role.prompt }
716
793
  },
717
794
  init: async (tx, id) => {
718
795
  const doc = await tx.doc(IndexDoc);
@@ -723,7 +800,13 @@ async function createConversation(harness, dataDir, input, defaults, models, con
723
800
  throw new Error(`Unknown project: ${home.path}`);
724
801
  }
725
802
  const stored = home.kind === "chat" ? { kind: "chat" } : { kind: "project", path: projectPath };
726
- doc.conversations.push({ id, home: stored, createdAt: Date.now(), requestId: input.requestId });
803
+ doc.conversations.push({
804
+ id,
805
+ home: stored,
806
+ createdAt: Date.now(),
807
+ requestId: input.requestId,
808
+ ...role === undefined ? {} : { role: role.name }
809
+ });
727
810
  if (repo !== undefined && suffix !== undefined) {
728
811
  const path = worktreePath(dataDir, id);
729
812
  if (doc.checkouts === undefined)
@@ -1164,39 +1247,6 @@ function agentRoleProblem(role) {
1164
1247
  return;
1165
1248
  }
1166
1249
 
1167
- // apps/host/src/host-value.ts
1168
- function constantValue(value) {
1169
- return {
1170
- value,
1171
- subscribe: () => () => {}
1172
- };
1173
- }
1174
-
1175
- class Published {
1176
- #listeners = new Set;
1177
- #equal;
1178
- #value;
1179
- constructor(value, equal) {
1180
- this.#value = value;
1181
- this.#equal = equal;
1182
- }
1183
- get value() {
1184
- return this.#value;
1185
- }
1186
- subscribe(listener) {
1187
- this.#listeners.add(listener);
1188
- return () => this.#listeners.delete(listener);
1189
- }
1190
- set(next) {
1191
- if (this.#equal(this.#value, next))
1192
- return;
1193
- this.#value = next;
1194
- for (const listener of this.#listeners)
1195
- listener(next);
1196
- }
1197
- }
1198
- var sameList = (a, b, same) => a.length === b.length && a.every((entry, index) => same(entry, b[index]));
1199
-
1200
1250
  // apps/host/src/agent-roles.ts
1201
1251
  var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
1202
1252
  async function load(dir) {
@@ -1350,114 +1400,6 @@ async function openAgentRoles(options) {
1350
1400
  };
1351
1401
  }
1352
1402
 
1353
- // apps/host/src/host-settings.ts
1354
- import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1355
- import { dirname as dirname2 } from "node:path";
1356
- function settingsDefaults(settings) {
1357
- return {
1358
- ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
1359
- ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
1360
- };
1361
- }
1362
- var isModelRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.provider === "string" && value.provider !== "" && typeof value.modelId === "string" && value.modelId !== "";
1363
- var fileValueError = (key, value, models) => {
1364
- switch (key) {
1365
- case "defaultModel":
1366
- if (!isModelRef(value))
1367
- return "malformed";
1368
- return models.some((each) => each.provider === value.provider && each.modelId === value.modelId) ? undefined : "unavailable";
1369
- case "defaultThinkingLevel":
1370
- return isThinkingLevel(value) ? undefined : "malformed";
1371
- case "steeringMode":
1372
- case "followUpMode":
1373
- return isQueueMode(value) ? undefined : "malformed";
1374
- case "acceptAccountDevices":
1375
- return typeof value === "boolean" ? undefined : "malformed";
1376
- }
1377
- };
1378
- var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
1379
- var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.acceptAccountDevices === b.acceptAccountDevices && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && sameList(a.custom, b.custom, (x, y) => x === y);
1380
- var parse = (path, text) => {
1381
- let parsed;
1382
- try {
1383
- parsed = JSON.parse(text);
1384
- } catch (error) {
1385
- throw new Error(`Invalid settings file ${path}: ${error instanceof Error ? error.message : String(error)}`);
1386
- }
1387
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1388
- throw new Error(`Invalid settings file ${path}: expected a JSON object`);
1389
- }
1390
- return parsed;
1391
- };
1392
- async function openHostSettings(options) {
1393
- let file = await readFile3(options.path, "utf8").then((text) => parse(options.path, text), (error) => {
1394
- if (error.code === "ENOENT")
1395
- return {};
1396
- throw new Error(`Cannot read settings file ${options.path}: ${error instanceof Error ? error.message : String(error)}`);
1397
- });
1398
- const warned = new Set;
1399
- const compute = () => {
1400
- const fileValues = {};
1401
- const custom = [];
1402
- for (const key of HOST_SETTING_KEYS) {
1403
- if (!Object.hasOwn(file, key))
1404
- continue;
1405
- const value = file[key];
1406
- const invalid = fileValueError(key, value, options.models.value);
1407
- if (invalid === "malformed" && !warned.has(key)) {
1408
- warned.add(key);
1409
- console.error(`${options.path}: ignoring invalid value for settings key "${key}": ${JSON.stringify(value)}`);
1410
- }
1411
- if (invalid !== undefined)
1412
- continue;
1413
- fileValues[key] = value;
1414
- custom.push(key);
1415
- }
1416
- const { fallback } = options;
1417
- return {
1418
- defaultModel: fileValues.defaultModel ?? fallback.defaultModel(),
1419
- defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
1420
- steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
1421
- followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
1422
- acceptAccountDevices: fileValues.acceptAccountDevices ?? true,
1423
- custom
1424
- };
1425
- };
1426
- const published = new Published(compute(), sameSettings);
1427
- options.models.subscribe(() => published.set(compute()));
1428
- let tail = Promise.resolve();
1429
- const write = async (patch) => {
1430
- const model = patch.defaultModel;
1431
- if (model !== undefined && model !== null && !options.models.value.some((each) => each.provider === model.provider && each.modelId === model.modelId)) {
1432
- throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
1433
- }
1434
- const next = { ...file };
1435
- for (const key of HOST_SETTING_KEYS) {
1436
- const value = patch[key];
1437
- if (value === undefined)
1438
- continue;
1439
- if (value === null)
1440
- delete next[key];
1441
- else
1442
- next[key] = value;
1443
- }
1444
- const tmp = `${options.path}.tmp`;
1445
- await mkdir3(dirname2(options.path), { recursive: true });
1446
- await writeFile2(tmp, `${JSON.stringify(next, null, 2)}
1447
- `, { mode: 384 });
1448
- await rename2(tmp, options.path);
1449
- file = next;
1450
- published.set(compute());
1451
- };
1452
- return {
1453
- get value() {
1454
- return published.value;
1455
- },
1456
- subscribe: (listener) => published.subscribe(listener),
1457
- update: (patch) => tail = tail.then(() => write(patch), () => write(patch))
1458
- };
1459
- }
1460
-
1461
1403
  // apps/host/src/host.ts
1462
1404
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1463
1405
  import lockfile from "proper-lockfile";
@@ -1465,11 +1407,11 @@ import lockfile from "proper-lockfile";
1465
1407
  // apps/host/src/gateway.ts
1466
1408
  import { createServer } from "node:http";
1467
1409
  import { homedir as homedir2, networkInterfaces } from "node:os";
1468
- import { join as join6, sep as sep3 } from "node:path";
1410
+ import { join as join7, sep as sep4 } from "node:path";
1469
1411
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1470
1412
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1471
1413
  import {
1472
- AgentDoc as AgentDoc3,
1414
+ AgentDoc as AgentDoc4,
1473
1415
  LiveDoc
1474
1416
  } from "@earendil-works/pi-durable";
1475
1417
  import { WebSocket, WebSocketServer } from "ws";
@@ -1516,6 +1458,273 @@ async function listFolders(path, home) {
1516
1458
  return { path: dir, home: await realpath3(home).catch(() => home), folders };
1517
1459
  }
1518
1460
 
1461
+ // apps/host/src/files.ts
1462
+ import { spawn as spawn2 } from "node:child_process";
1463
+ import { opendir, realpath as realpath4, stat as stat4 } from "node:fs/promises";
1464
+ import { join as join6, resolve as resolve3, sep as sep3 } from "node:path";
1465
+ import { AgentDoc as AgentDoc3 } from "@earendil-works/pi-durable";
1466
+ var CACHE_TTL_MS = 3000;
1467
+ var MAX_CACHED_ROOTS = 64;
1468
+ var MAX_CONCURRENT_SCANS = 4;
1469
+ var SCAN_BUDGET_MS = 5000;
1470
+ var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
1471
+ var MAX_WALK_ENTRIES = 20000;
1472
+ var MAX_RESULTS = 50;
1473
+ var UNUSABLE_PATH = /[\s`\x00-\x1f\x7f-\x9f]/;
1474
+ function createCandidateLister(enumerate, options = {}) {
1475
+ const now = options.now ?? Date.now;
1476
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
1477
+ const maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_SCANS;
1478
+ const maxCached = options.maxCached ?? MAX_CACHED_ROOTS;
1479
+ const cache = new Map;
1480
+ const inflight = new Map;
1481
+ let running = 0;
1482
+ const waiting = [];
1483
+ return (root, mode) => {
1484
+ const key = `${mode}${root}`;
1485
+ const hit = cache.get(key);
1486
+ if (hit !== undefined) {
1487
+ if (hit.expiresAt > now())
1488
+ return Promise.resolve(hit.value);
1489
+ cache.delete(key);
1490
+ }
1491
+ const pending = inflight.get(key);
1492
+ if (pending !== undefined)
1493
+ return pending;
1494
+ const scan = (async () => {
1495
+ if (running >= maxConcurrent)
1496
+ await new Promise((release) => waiting.push(release));
1497
+ running += 1;
1498
+ try {
1499
+ return await enumerate(root, mode);
1500
+ } finally {
1501
+ running -= 1;
1502
+ waiting.shift()?.();
1503
+ }
1504
+ })();
1505
+ inflight.set(key, scan);
1506
+ scan.then((value) => {
1507
+ const at = now();
1508
+ for (const [cached, entry] of cache) {
1509
+ if (entry.expiresAt <= at)
1510
+ cache.delete(cached);
1511
+ }
1512
+ while (cache.size >= maxCached)
1513
+ cache.delete(cache.keys().next().value);
1514
+ cache.set(key, { value, expiresAt: at + ttlMs });
1515
+ }, () => {});
1516
+ return scan.finally(() => inflight.delete(key));
1517
+ };
1518
+ }
1519
+ function gitLines(cwd, args, limits) {
1520
+ return new Promise((resolvePromise, rejectPromise) => {
1521
+ const child = spawn2("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "pipe"] });
1522
+ const chunks = [];
1523
+ let bytes = 0;
1524
+ let truncated = false;
1525
+ let settled = false;
1526
+ const kill = (markTruncated) => {
1527
+ truncated ||= markTruncated;
1528
+ child.kill("SIGKILL");
1529
+ };
1530
+ const timer = setTimeout(() => kill(true), Math.max(0, limits.deadline - limits.now()));
1531
+ const finish = (error) => {
1532
+ if (settled)
1533
+ return;
1534
+ settled = true;
1535
+ clearTimeout(timer);
1536
+ if (error !== undefined) {
1537
+ rejectPromise(error);
1538
+ return;
1539
+ }
1540
+ const output = Buffer.concat(chunks).toString("utf8");
1541
+ const parts = output.split("\x00");
1542
+ if (truncated)
1543
+ parts.pop();
1544
+ resolvePromise({ lines: parts.filter((part) => part !== ""), truncated });
1545
+ };
1546
+ child.stdout.on("data", (chunk) => {
1547
+ bytes += chunk.length;
1548
+ if (bytes > limits.maxBytes) {
1549
+ kill(true);
1550
+ return;
1551
+ }
1552
+ chunks.push(chunk);
1553
+ if (limits.now() > limits.deadline)
1554
+ kill(true);
1555
+ });
1556
+ child.on("error", (error) => finish(error));
1557
+ child.stderr.resume();
1558
+ child.on("close", (code) => {
1559
+ if (code === 0 || truncated) {
1560
+ finish(undefined);
1561
+ } else {
1562
+ finish(new Error(`git ${args.join(" ")} exited with code ${code}`));
1563
+ }
1564
+ });
1565
+ });
1566
+ }
1567
+ async function inWorkTree(root) {
1568
+ return await new Promise((resolvePromise) => {
1569
+ const child = spawn2("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] });
1570
+ let out = "";
1571
+ child.stdout.on("data", (chunk) => out += chunk.toString("utf8"));
1572
+ child.on("error", () => resolvePromise(false));
1573
+ child.on("close", (code) => resolvePromise(code === 0 && out.trim() === "true"));
1574
+ });
1575
+ }
1576
+ async function hasHead(root) {
1577
+ return await new Promise((resolvePromise) => {
1578
+ const child = spawn2("git", ["-C", root, "rev-parse", "--verify", "-q", "HEAD"], { stdio: ["ignore", "ignore", "ignore"] });
1579
+ child.on("error", () => resolvePromise(false));
1580
+ child.on("close", (code) => resolvePromise(code === 0));
1581
+ });
1582
+ }
1583
+ var STAGED_RECORD = /^([0-7]{6}) [0-9a-f]{40,64} [0-7]\t/;
1584
+ async function gitWorkingFiles(root, limits) {
1585
+ const listing = await gitLines(root, ["ls-files", "-z", "-s", "--cached", "--others", "--exclude-standard"], limits);
1586
+ const deleted = await gitLines(root, ["ls-files", "-z", "--deleted"], limits);
1587
+ const gone = new Set(deleted.lines);
1588
+ const files = new Set;
1589
+ for (const line of listing.lines) {
1590
+ const staged = STAGED_RECORD.exec(line);
1591
+ const path = staged === null ? line : line.slice(staged[0].length);
1592
+ if (staged !== null && staged[1] === "160000")
1593
+ continue;
1594
+ if (path === "" || path.endsWith("/") || gone.has(path))
1595
+ continue;
1596
+ files.add(path);
1597
+ }
1598
+ return { files: [...files], truncated: listing.truncated || deleted.truncated };
1599
+ }
1600
+ async function gitHeadFiles(root, limits) {
1601
+ const listing = await gitLines(root, ["ls-tree", "-r", "-z", "HEAD"], limits);
1602
+ const files = [];
1603
+ for (const line of listing.lines) {
1604
+ const tab = line.indexOf("\t");
1605
+ if (tab === -1)
1606
+ continue;
1607
+ const kind = line.slice(0, tab).split(" ")[1];
1608
+ if (kind === "blob")
1609
+ files.push(line.slice(tab + 1));
1610
+ }
1611
+ return { files, truncated: listing.truncated };
1612
+ }
1613
+ async function walkFiles(root, limits) {
1614
+ const files = [];
1615
+ let entries = 0;
1616
+ let truncated = false;
1617
+ const dirs = [{ dir: root, prefix: "" }];
1618
+ while (dirs.length > 0) {
1619
+ if (limits.now() > limits.deadline) {
1620
+ truncated = true;
1621
+ break;
1622
+ }
1623
+ const { dir, prefix } = dirs.pop();
1624
+ let handle;
1625
+ try {
1626
+ handle = await opendir(dir);
1627
+ } catch (error) {
1628
+ if (prefix === "")
1629
+ throw error;
1630
+ truncated = true;
1631
+ continue;
1632
+ }
1633
+ for await (const entry of handle) {
1634
+ if (entries++ >= limits.maxEntries || limits.now() > limits.deadline) {
1635
+ truncated = true;
1636
+ break;
1637
+ }
1638
+ if (entry.isDirectory()) {
1639
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
1640
+ continue;
1641
+ dirs.push({ dir: join6(dir, entry.name), prefix: `${prefix}${entry.name}/` });
1642
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
1643
+ files.push(`${prefix}${entry.name}`);
1644
+ }
1645
+ }
1646
+ if (truncated)
1647
+ break;
1648
+ }
1649
+ return { files, truncated };
1650
+ }
1651
+ async function enumerateRoot(root, mode, limits = {}) {
1652
+ const now = limits.now ?? Date.now;
1653
+ const bounded = {
1654
+ now,
1655
+ deadline: now() + (limits.budgetMs ?? SCAN_BUDGET_MS),
1656
+ maxBytes: limits.maxBytes ?? MAX_OUTPUT_BYTES,
1657
+ maxEntries: limits.maxEntries ?? MAX_WALK_ENTRIES
1658
+ };
1659
+ if (!await inWorkTree(root)) {
1660
+ return walkFiles(root, bounded);
1661
+ }
1662
+ if (mode === "head" && !await hasHead(root)) {
1663
+ return walkFiles(root, bounded);
1664
+ }
1665
+ return mode === "head" ? gitHeadFiles(root, bounded) : gitWorkingFiles(root, bounded);
1666
+ }
1667
+ function matchFiles(candidates, query, max = MAX_RESULTS) {
1668
+ const usable = candidates.filter((path) => !UNUSABLE_PATH.test(path));
1669
+ if (query === "") {
1670
+ const sorted = [...usable].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1671
+ return { files: sorted.slice(0, max), truncated: sorted.length > max };
1672
+ }
1673
+ const needle = query.toLowerCase();
1674
+ const tiers = [[], [], []];
1675
+ for (const path of usable) {
1676
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
1677
+ const lower = path.toLowerCase();
1678
+ if (base.startsWith(needle))
1679
+ tiers[0].push(path);
1680
+ else if (base.includes(needle))
1681
+ tiers[1].push(path);
1682
+ else if (lower.includes(needle))
1683
+ tiers[2].push(path);
1684
+ }
1685
+ const shorter = (a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
1686
+ const matched = [...tiers[0].sort(shorter), ...tiers[1].sort(shorter), ...tiers[2].sort(shorter)];
1687
+ return { files: matched.slice(0, max), truncated: matched.length > max };
1688
+ }
1689
+ async function listFiles(harness, dataDir, enumerate, scope, query, context) {
1690
+ let root;
1691
+ let mode = "live";
1692
+ if (scope.kind === "conversation") {
1693
+ const agent = await harness.snapshot(AgentDoc3, scope.conversationId, context);
1694
+ if (agent === undefined)
1695
+ throw new Error(`Conversation ${scope.conversationId} does not exist`);
1696
+ const cwd = agent.cwd;
1697
+ if (cwd === undefined)
1698
+ throw new Error(`Conversation ${scope.conversationId} has no checkout`);
1699
+ if (cwd.startsWith(join6(dataDir, "chats") + sep3))
1700
+ return { files: [], truncated: false };
1701
+ const info = await stat4(cwd).catch(() => {
1702
+ return;
1703
+ });
1704
+ if (info === undefined || !info.isDirectory()) {
1705
+ throw new Error(`Conversation ${scope.conversationId}'s checkout is unavailable: ${cwd}`);
1706
+ }
1707
+ root = cwd;
1708
+ } else {
1709
+ const normalized = await realpath4(resolve3(scope.path)).catch(() => resolve3(scope.path));
1710
+ const index = await harness.snapshot(IndexDoc, context);
1711
+ if (!index?.projects.some((project) => project.path === normalized)) {
1712
+ throw new Error(`Unknown project: ${scope.path}`);
1713
+ }
1714
+ const info = await stat4(normalized).catch(() => {
1715
+ return;
1716
+ });
1717
+ if (info === undefined || !info.isDirectory()) {
1718
+ throw new Error(`Not a directory: ${normalized}`);
1719
+ }
1720
+ root = normalized;
1721
+ mode = scope.checkout === "worktree" ? "head" : "live";
1722
+ }
1723
+ const candidates = await enumerate(root, mode);
1724
+ const matched = matchFiles(candidates.files, query);
1725
+ return { files: matched.files, truncated: matched.truncated || candidates.truncated };
1726
+ }
1727
+
1519
1728
  // packages/protocol/src/images.ts
1520
1729
  var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1521
1730
  var MAX_IMAGES_PER_MESSAGE = 4;
@@ -1660,6 +1869,7 @@ ${answer}`}`,
1660
1869
  }
1661
1870
 
1662
1871
  // apps/host/src/push.ts
1872
+ import { createHash } from "node:crypto";
1663
1873
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1664
1874
  import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
1665
1875
 
@@ -1721,26 +1931,38 @@ function diffRunNotices(previous, organized) {
1721
1931
  var context2 = BACKGROUND_CONTEXT2;
1722
1932
  var PushDoc = defineDoc6({
1723
1933
  kind: "pinomad.push",
1724
- version: 1,
1934
+ version: 2,
1725
1935
  scope: "session",
1726
- initial: () => ({ registrations: [] })
1936
+ initial: () => ({ registrations: [] }),
1937
+ migrate: () => ({ registrations: [] })
1727
1938
  });
1728
- var PUSH_TOKEN_PATTERN = /^Expo(?:nent)?PushToken\[[^\]]+\]$/;
1729
- var PUSH_TOKEN_MAX_LENGTH = 256;
1939
+ var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
1940
+ var APNS_TOKEN_MAX_CHARS = 256;
1941
+ var isApnsEnvironment = (value) => value === "production" || value === "development";
1730
1942
  function pushTokenProblem(token) {
1731
1943
  if (token === null)
1732
1944
  return;
1733
- if (token.length > PUSH_TOKEN_MAX_LENGTH) {
1734
- return `Push token is too long (${PUSH_TOKEN_MAX_LENGTH} characters at most)`;
1945
+ if (typeof token === "string") {
1946
+ return "Push notifications no longer go through Expo — register the device's APNs token instead";
1947
+ }
1948
+ if (!isApnsEnvironment(token.environment)) {
1949
+ return 'environment must be "production" or "development"';
1735
1950
  }
1736
- if (!PUSH_TOKEN_PATTERN.test(token))
1737
- return "Not an Expo push token";
1951
+ const apns = token.apns.toLowerCase();
1952
+ if (apns.length > APNS_TOKEN_MAX_CHARS) {
1953
+ return `APNs token is too long (${APNS_TOKEN_MAX_CHARS} characters at most)`;
1954
+ }
1955
+ if (!APNS_TOKEN_PATTERN.test(apns))
1956
+ return "Not an APNs device token (expected hex)";
1738
1957
  return;
1739
1958
  }
1740
- async function setPushToken(harness, publicKey, token, context3) {
1959
+ async function setPushToken(harness, publicKey, token, context3, joined) {
1741
1960
  const problem = pushTokenProblem(token);
1742
1961
  if (problem !== undefined)
1743
1962
  throw new Error(problem);
1963
+ if (token !== null && typeof token !== "string" && !await joined()) {
1964
+ throw new Error("Push notifications need the host joined to an account (`pinomad join`)");
1965
+ }
1744
1966
  await harness.commit(async (tx) => {
1745
1967
  const devices = await tx.doc(DevicesDoc);
1746
1968
  if (!devices.devices.some((entry) => entry.publicKey === publicKey)) {
@@ -1748,8 +1970,14 @@ async function setPushToken(harness, publicKey, token, context3) {
1748
1970
  }
1749
1971
  const push = await tx.doc(PushDoc);
1750
1972
  push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
1751
- if (token !== null)
1752
- push.registrations.push({ publicKey, token, updatedAt: Date.now() });
1973
+ if (token !== null && typeof token !== "string") {
1974
+ push.registrations.push({
1975
+ publicKey,
1976
+ token: token.apns.toLowerCase(),
1977
+ environment: token.environment,
1978
+ updatedAt: Date.now()
1979
+ });
1980
+ }
1753
1981
  }, context3);
1754
1982
  }
1755
1983
  async function revokeDevice(harness, publicKey, context3) {
@@ -1763,38 +1991,43 @@ async function revokeDevice(harness, publicKey, context3) {
1763
1991
  revocations.revoked.push(publicKey);
1764
1992
  }, context3);
1765
1993
  }
1766
- async function dropTokenIfCurrent(harness, token, context3) {
1994
+ async function dropStaleRegistration(harness, token, environment, unregisteredAt, context3) {
1767
1995
  await harness.commit(async (tx) => {
1768
1996
  const push = await tx.doc(PushDoc);
1769
- push.registrations = push.registrations.filter((entry) => entry.token !== token);
1997
+ push.registrations = push.registrations.filter((entry) => entry.token !== token || entry.environment !== environment || entry.updatedAt >= unregisteredAt);
1770
1998
  }, context3);
1771
1999
  }
1772
- var EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
1773
- var EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
1774
2000
  var FALLBACK_TITLE = "PiNomad conversation";
1775
2001
  var TITLE_WAIT_MS = 15000;
1776
- var TTL_SECONDS = 3600;
1777
- var REQUEST_TIMEOUT_MS = 1e4;
2002
+ var TITLE_MAX_CHARS = 160;
1778
2003
  var MAX_ATTEMPTS = 4;
1779
2004
  var RETRY_BASE_MS = 5000;
1780
- var RECEIPT_DELAY_MS = 15 * 60 * 1000;
2005
+ var MAX_INFLIGHT = 8;
1781
2006
  var MAX_PENDING = 128;
2007
+ var UNAVAILABLE_TRANSPORT = {
2008
+ send: () => Promise.resolve({ result: "unavailable", reason: "no server connection" })
2009
+ };
1782
2010
  var realTimers = {
1783
2011
  setTimeout: (fn, ms) => setTimeout(fn, ms),
1784
2012
  clearTimeout: (handle) => clearTimeout(handle)
1785
2013
  };
1786
2014
  var tokenHint = (token) => `…${token.slice(-6)}`;
2015
+ var collapseId = (hostKey, id) => createHash("sha256").update(`${hostKey}:${id}`).digest("hex").slice(0, 32);
1787
2016
  async function attachPushSender(options) {
1788
2017
  const { harness, conversations } = options;
1789
- const fetcher = options.fetch ?? fetch;
2018
+ const transport = options.transport ?? UNAVAILABLE_TRANSPORT;
1790
2019
  const timers = options.timers ?? realTimers;
2020
+ const maxPending = options.limits?.maxPending ?? MAX_PENDING;
2021
+ const maxInflight = options.limits?.maxInflight ?? MAX_INFLIGHT;
1791
2022
  const indexWatch = await harness.watchDoc(IndexDoc, context2);
1792
2023
  let index = indexWatch?.value ?? undefined;
1793
2024
  let baseline;
1794
2025
  let disposed = false;
1795
2026
  const pending = new Set;
1796
2027
  const titleWaiters = new Map;
1797
- const receiptTimers = new Set;
2028
+ let inflight = 0;
2029
+ const readyQueue = [];
2030
+ let unavailableLogged = false;
1798
2031
  const generation = new Map;
1799
2032
  const update = () => {
1800
2033
  if (index === undefined)
@@ -1816,32 +2049,9 @@ async function attachPushSender(options) {
1816
2049
  const fresh = (item) => baseline?.has(item.notice.id) === true && generation.get(item.notice.id) === item.generation;
1817
2050
  const registrations = async () => {
1818
2051
  const doc = await harness.snapshot(PushDoc, context2);
1819
- return new Set((doc?.registrations ?? []).map((entry) => entry.token));
1820
- };
1821
- const post = async (url, payload) => {
1822
- const response = await fetcher(url, {
1823
- method: "POST",
1824
- headers: { accept: "application/json", "content-type": "application/json" },
1825
- body: JSON.stringify(payload),
1826
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1827
- });
1828
- return { status: response.status, body: await response.json() };
1829
- };
1830
- const ticketOf = (body) => {
1831
- const data = body?.data;
1832
- const ticket = Array.isArray(data) ? data[0] : data;
1833
- return ticket !== null && typeof ticket === "object" ? ticket : undefined;
2052
+ return doc?.registrations ?? [];
1834
2053
  };
1835
- const message = (item, title) => ({
1836
- to: item.token,
1837
- title,
1838
- body: item.notice.body,
1839
- sound: "default",
1840
- ttl: TTL_SECONDS,
1841
- data: { hostKey: options.hostKey, conversationId: item.notice.id },
1842
- threadId: `${options.hostKey}:${item.notice.id}`,
1843
- collapseId: `${options.hostKey}:${item.notice.id}`
1844
- });
2054
+ const findRegistration = (entries, item) => entries.find((entry) => entry.token === item.token && entry.environment === item.environment);
1845
2055
  const namedTitle = async (id) => {
1846
2056
  const doc = await harness.snapshot(TitleDoc, id, context2);
1847
2057
  return typeof doc?.title === "string" && doc.title !== "" ? doc.title : undefined;
@@ -1852,7 +2062,7 @@ async function attachPushSender(options) {
1852
2062
  return named;
1853
2063
  if (notice.body !== "Finished" || disposed)
1854
2064
  return FALLBACK_TITLE;
1855
- return await new Promise((resolve3) => {
2065
+ return await new Promise((resolve4) => {
1856
2066
  const waiters = titleWaiters.get(notice.id) ?? new Set;
1857
2067
  titleWaiters.set(notice.id, waiters);
1858
2068
  const settle = (title) => {
@@ -1860,7 +2070,7 @@ async function attachPushSender(options) {
1860
2070
  if (waiters.size === 0)
1861
2071
  titleWaiters.delete(notice.id);
1862
2072
  timers.clearTimeout(timer);
1863
- resolve3(title);
2073
+ resolve4(title);
1864
2074
  };
1865
2075
  const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
1866
2076
  waiters.add(settle);
@@ -1874,78 +2084,96 @@ async function attachPushSender(options) {
1874
2084
  pending.delete(item);
1875
2085
  if (item.timer !== undefined)
1876
2086
  timers.clearTimeout(item.timer);
2087
+ const queuedAt = readyQueue.indexOf(item);
2088
+ if (queuedAt >= 0)
2089
+ readyQueue.splice(queuedAt, 1);
2090
+ };
2091
+ const pump = () => {
2092
+ while (inflight < maxInflight) {
2093
+ const next = readyQueue.shift();
2094
+ if (next === undefined)
2095
+ return;
2096
+ next.queued = false;
2097
+ attempt(next);
2098
+ }
2099
+ };
2100
+ const scheduleRetry = (item, retryAfterMs) => {
2101
+ if (item.attempts + 1 >= MAX_ATTEMPTS) {
2102
+ console.error(`push: send to ${tokenHint(item.token)} gave up`);
2103
+ drop(item);
2104
+ return;
2105
+ }
2106
+ const delayMs = retryAfterMs ?? RETRY_BASE_MS * 2 ** item.attempts;
2107
+ item.attempts += 1;
2108
+ item.timer = timers.setTimeout(() => void attempt(item), delayMs);
1877
2109
  };
1878
2110
  const attempt = async (item) => {
1879
2111
  if (disposed || !pending.has(item))
1880
2112
  return;
1881
2113
  try {
1882
- if (!(await registrations()).has(item.token) || !fresh(item))
2114
+ if (findRegistration(await registrations(), item) === undefined || !fresh(item))
1883
2115
  return drop(item);
1884
2116
  if (item.title === undefined) {
1885
2117
  item.title = await titleFor(item.notice);
1886
- if (disposed || !pending.has(item) || !(await registrations()).has(item.token) || !fresh(item))
2118
+ if (disposed || !pending.has(item) || findRegistration(await registrations(), item) === undefined || !fresh(item)) {
1887
2119
  return drop(item);
2120
+ }
1888
2121
  }
1889
- const { status, body } = await post(EXPO_SEND_URL, message(item, item.title));
1890
- const ticket = ticketOf(body);
1891
- if (ticket?.details?.error === "DeviceNotRegistered") {
1892
- drop(item);
1893
- console.error(`push: token ${tokenHint(item.token)} is not registered (ticket)`);
1894
- await dropTokenIfCurrent(harness, item.token, context2);
1895
- return;
1896
- }
1897
- if (status >= 200 && status < 300 && ticket?.status === "ok") {
1898
- drop(item);
1899
- if (typeof ticket.id === "string")
1900
- scheduleReceiptCheck(item.token, ticket.id);
1901
- return;
1902
- }
1903
- if ((status === 429 || status >= 500) && item.attempts + 1 < MAX_ATTEMPTS) {
1904
- item.attempts += 1;
1905
- item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
2122
+ if (inflight >= maxInflight) {
2123
+ if (!item.queued) {
2124
+ item.queued = true;
2125
+ readyQueue.push(item);
2126
+ }
1906
2127
  return;
1907
2128
  }
1908
- console.error(`push: send to ${tokenHint(item.token)} failed permanently (HTTP ${status})`);
1909
- drop(item);
1910
- } catch (error) {
1911
- if (item.attempts + 1 < MAX_ATTEMPTS) {
1912
- item.attempts += 1;
1913
- item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
1914
- return;
2129
+ inflight += 1;
2130
+ let verdict;
2131
+ try {
2132
+ verdict = await transport.send({
2133
+ token: item.token,
2134
+ environment: item.environment,
2135
+ notification: {
2136
+ title: item.title.slice(0, TITLE_MAX_CHARS),
2137
+ body: item.notice.body,
2138
+ collapseId: collapseId(options.hostKey, item.notice.id),
2139
+ hostKey: options.hostKey,
2140
+ conversationId: item.notice.id
2141
+ }
2142
+ });
2143
+ } catch {
2144
+ verdict = { result: "retry", reason: "transport error" };
2145
+ } finally {
2146
+ inflight -= 1;
2147
+ pump();
1915
2148
  }
1916
- console.error(`push: send to ${tokenHint(item.token)} gave up: ${error instanceof Error ? error.message : String(error)}`);
1917
- drop(item);
1918
- }
1919
- };
1920
- const scheduleReceiptCheck = (token, ticketId) => {
1921
- const timer = timers.setTimeout(() => {
1922
- receiptTimers.delete(timer);
1923
- checkReceipt(token, ticketId);
1924
- }, RECEIPT_DELAY_MS);
1925
- receiptTimers.add(timer);
1926
- while (receiptTimers.size > MAX_PENDING) {
1927
- const oldest = receiptTimers.values().next().value;
1928
- receiptTimers.delete(oldest);
1929
- timers.clearTimeout(oldest);
1930
- }
1931
- };
1932
- const checkReceipt = async (token, ticketId) => {
1933
- if (disposed)
1934
- return;
1935
- try {
1936
- const { status, body } = await post(EXPO_RECEIPTS_URL, { ids: [ticketId] });
1937
- const receipt = status >= 200 && status < 300 ? body?.data?.[ticketId] : undefined;
1938
- if (receipt === undefined || disposed)
1939
- return;
1940
- if (receipt.status === "error" && receipt.details?.error === "DeviceNotRegistered") {
1941
- console.error(`push: token ${tokenHint(token)} is not registered (receipt)`);
1942
- await dropTokenIfCurrent(harness, token, context2);
1943
- return;
2149
+ if (disposed || !pending.has(item))
2150
+ return drop(item);
2151
+ switch (verdict.result) {
2152
+ case "accepted":
2153
+ drop(item);
2154
+ return;
2155
+ case "unregistered":
2156
+ drop(item);
2157
+ await dropStaleRegistration(harness, item.token, item.environment, verdict.unregisteredAt, context2);
2158
+ return;
2159
+ case "retry":
2160
+ scheduleRetry(item, verdict.retryAfterMs);
2161
+ return;
2162
+ case "rejected":
2163
+ console.error(`push: send to ${tokenHint(item.token)} rejected: ${verdict.reason}`);
2164
+ drop(item);
2165
+ return;
2166
+ case "unavailable":
2167
+ if (!unavailableLogged) {
2168
+ unavailableLogged = true;
2169
+ console.error(`push: ${verdict.reason} — notifications are not being delivered`);
2170
+ }
2171
+ drop(item);
2172
+ return;
1944
2173
  }
1945
- if (receipt.status === "error")
1946
- console.error(`push: receipt ${ticketId} reports ${receipt.details?.error ?? "error"}`);
1947
2174
  } catch (error) {
1948
- console.error(`push: receipt check ${ticketId} failed: ${error instanceof Error ? error.message : String(error)}`);
2175
+ console.error(`push: send to ${tokenHint(item.token)} failed: ${error instanceof Error ? error.message : String(error)}`);
2176
+ scheduleRetry(item);
1949
2177
  }
1950
2178
  };
1951
2179
  const enqueue = async (notice) => {
@@ -1953,15 +2181,20 @@ async function attachPushSender(options) {
1953
2181
  return;
1954
2182
  const itemGeneration = generation.get(notice.id) ?? 0;
1955
2183
  try {
1956
- const tokens = await registrations();
1957
- for (const token of tokens) {
2184
+ const entries = await registrations();
2185
+ const seen = new Set;
2186
+ for (const entry of entries) {
1958
2187
  if (disposed)
1959
2188
  return;
1960
- if (pending.size >= MAX_PENDING) {
2189
+ const identity = `${entry.environment}:${entry.token}`;
2190
+ if (seen.has(identity))
2191
+ continue;
2192
+ seen.add(identity);
2193
+ if (pending.size >= maxPending) {
1961
2194
  const [oldest] = pending;
1962
2195
  drop(oldest);
1963
2196
  }
1964
- const item = { token, notice, generation: itemGeneration, attempts: 0 };
2197
+ const item = { token: entry.token, environment: entry.environment, notice, generation: itemGeneration, attempts: 0, queued: false };
1965
2198
  pending.add(item);
1966
2199
  attempt(item);
1967
2200
  }
@@ -1991,18 +2224,19 @@ async function attachPushSender(options) {
1991
2224
  update();
1992
2225
  });
1993
2226
  update();
1994
- return async () => {
1995
- disposed = true;
1996
- unsubscribeConversations();
1997
- unsubscribeCommits();
1998
- await indexWatch?.stop();
1999
- for (const item of pending)
2000
- drop(item);
2001
- pending.clear();
2002
- titleWaiters.clear();
2003
- for (const timer of receiptTimers)
2004
- timers.clearTimeout(timer);
2005
- receiptTimers.clear();
2227
+ return {
2228
+ stats: () => ({ pending: pending.size, queued: readyQueue.length }),
2229
+ async close() {
2230
+ disposed = true;
2231
+ unsubscribeConversations();
2232
+ unsubscribeCommits();
2233
+ await indexWatch?.stop();
2234
+ for (const item of pending)
2235
+ drop(item);
2236
+ pending.clear();
2237
+ readyQueue.length = 0;
2238
+ titleWaiters.clear();
2239
+ }
2006
2240
  };
2007
2241
  }
2008
2242
 
@@ -2049,18 +2283,12 @@ function serializedFork(harness, work) {
2049
2283
  var LOOPBACK_SECURE_PATH = "/secure";
2050
2284
  async function startGateway(options) {
2051
2285
  const conversations = await ConversationList.open(options.harness, options.docs);
2286
+ const fileLister = createCandidateLister(enumerateRoot);
2052
2287
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
2053
2288
  harness: options.harness,
2054
2289
  models: options.models,
2055
2290
  settings: options.settings
2056
2291
  });
2057
- const detachPush = options.push === undefined ? undefined : await attachPushSender({
2058
- harness: options.harness,
2059
- conversations,
2060
- hostKey: options.push.hostKey,
2061
- ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
2062
- ...options.push.timers === undefined ? {} : { timers: options.push.timers }
2063
- });
2064
2292
  const http = createServer((request, response) => {
2065
2293
  serveWebClient(request, response, options.webRoot).catch(() => {
2066
2294
  if (!response.headersSent)
@@ -2069,8 +2297,8 @@ async function startGateway(options) {
2069
2297
  });
2070
2298
  });
2071
2299
  const server = new WebSocketServer({ server: http });
2072
- await new Promise((resolve3, reject) => {
2073
- http.once("listening", resolve3);
2300
+ await new Promise((resolve4, reject) => {
2301
+ http.once("listening", resolve4);
2074
2302
  http.once("error", reject);
2075
2303
  http.listen(options.port, "127.0.0.1");
2076
2304
  });
@@ -2087,7 +2315,7 @@ async function startGateway(options) {
2087
2315
  if (joined?.source === "account") {
2088
2316
  broadcast("info", `New device "${joined.name}" joined through your account`);
2089
2317
  }
2090
- const client = new GatewayClient(connection, options, conversations, {
2318
+ const client = new GatewayClient(connection, options, conversations, fileLister, {
2091
2319
  devicePublicKey,
2092
2320
  closed: (self) => {
2093
2321
  clients.delete(self);
@@ -2161,6 +2389,12 @@ async function startGateway(options) {
2161
2389
  signingKey: secure.relay.signingKey,
2162
2390
  noisePublicKey: secure.hostKey.publicKey,
2163
2391
  accept: (socket) => secureHandshake(socket, secure, admission, register),
2392
+ hostName: {
2393
+ get value() {
2394
+ return options.settings.value.hostName;
2395
+ },
2396
+ subscribe: (listener) => options.settings.subscribe((settings) => listener(settings.hostName))
2397
+ },
2164
2398
  accountOffer: async () => {
2165
2399
  if (!options.settings.value.acceptAccountDevices)
2166
2400
  return { ok: false, reason: "disabled" };
@@ -2177,7 +2411,8 @@ async function startGateway(options) {
2177
2411
  },
2178
2412
  onStateChange: (state) => {
2179
2413
  if (state.status === "registered") {
2180
- console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId }));
2414
+ const push = state.push === undefined ? "undeclared" : state.push.length === 0 ? "none" : state.push.join(",");
2415
+ console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId, push }));
2181
2416
  } else if (state.status === "connecting") {
2182
2417
  console.log(JSON.stringify({ event: "relay", status: "connecting", origin: link.origin }));
2183
2418
  } else {
@@ -2190,6 +2425,13 @@ async function startGateway(options) {
2190
2425
  });
2191
2426
  relay = link;
2192
2427
  }
2428
+ const detachPush = options.push === undefined ? undefined : await attachPushSender({
2429
+ harness: options.harness,
2430
+ conversations,
2431
+ hostKey: options.push.hostKey,
2432
+ ...options.push.transport === undefined ? relay === undefined ? {} : { transport: { send: (request) => relay.sendPush(request) } } : { transport: options.push.transport },
2433
+ ...options.push.timers === undefined ? {} : { timers: options.push.timers }
2434
+ });
2193
2435
  return {
2194
2436
  url: `ws://127.0.0.1:${port}`,
2195
2437
  ...remote === undefined ? {} : { remote: { url: remote.url, advertiseUrl: remote.advertiseUrl } },
@@ -2199,14 +2441,14 @@ async function startGateway(options) {
2199
2441
  await relay?.close();
2200
2442
  detachAutoTitle?.();
2201
2443
  detachSettingsWatch();
2202
- await detachPush?.();
2444
+ await detachPush?.close();
2203
2445
  conversations.dispose();
2204
2446
  await Promise.all([...clients].map((client) => client.dispose()));
2205
2447
  for (const socket of server.clients)
2206
2448
  socket.terminate();
2207
2449
  await remote?.close();
2208
- await new Promise((resolve3) => server.close(() => resolve3()));
2209
- await new Promise((resolve3) => http.close(() => resolve3()));
2450
+ await new Promise((resolve4) => server.close(() => resolve4()));
2451
+ await new Promise((resolve4) => http.close(() => resolve4()));
2210
2452
  }
2211
2453
  };
2212
2454
  }
@@ -2215,7 +2457,7 @@ function tokenConnection(socket) {
2215
2457
  get open() {
2216
2458
  return socket.readyState === WebSocket.OPEN;
2217
2459
  },
2218
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => socket.send(text, () => resolve3())) : Promise.resolve(),
2460
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => socket.send(text, () => resolve4())) : Promise.resolve(),
2219
2461
  close: (code, reason) => socket.close(code, reason),
2220
2462
  handle(handlers) {
2221
2463
  socket.on("message", (data) => handlers.message(String(data)));
@@ -2228,9 +2470,9 @@ function secureConnection(socket, session) {
2228
2470
  get open() {
2229
2471
  return socket.readyState === WebSocket.OPEN;
2230
2472
  },
2231
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => {
2473
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => {
2232
2474
  const chunks = session.seal(text);
2233
- chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve3() : undefined));
2475
+ chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve4() : undefined));
2234
2476
  }) : Promise.resolve(),
2235
2477
  close: (code, reason) => socket.close(code, reason),
2236
2478
  handle(handlers) {
@@ -2349,8 +2591,8 @@ async function startRemote(options, admission, register) {
2349
2591
  async close() {
2350
2592
  for (const socket of secure.clients)
2351
2593
  socket.terminate();
2352
- await new Promise((resolve3) => secure.close(() => resolve3()));
2353
- await new Promise((resolve3) => http.close(() => resolve3()));
2594
+ await new Promise((resolve4) => secure.close(() => resolve4()));
2595
+ await new Promise((resolve4) => http.close(() => resolve4()));
2354
2596
  }
2355
2597
  };
2356
2598
  }
@@ -2361,6 +2603,7 @@ class ConversationList {
2361
2603
  #questionKinds;
2362
2604
  #records = new Map;
2363
2605
  #facts = new Map;
2606
+ #roles = new Map;
2364
2607
  #index = new Map;
2365
2608
  #runEnd = new Set;
2366
2609
  #list = [];
@@ -2374,19 +2617,26 @@ class ConversationList {
2374
2617
  const list = new ConversationList(docs);
2375
2618
  const summaries = [];
2376
2619
  let cursor;
2620
+ const records = [];
2377
2621
  do {
2378
2622
  const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context3);
2379
2623
  for (const record of page.items) {
2380
2624
  list.#records.set(record.id, record);
2381
- const facts = await list.#load(harness, record);
2382
- list.#facts.set(record.id, facts);
2383
- list.#index.set(record.id, summaries.length);
2384
- summaries.push(summaryOf(record, facts));
2625
+ list.#facts.set(record.id, await list.#load(harness, record));
2626
+ records.push(record);
2385
2627
  }
2386
2628
  cursor = page.next;
2387
2629
  } while (cursor !== undefined);
2630
+ list.#roles = rolesOf(await harness.snapshot(IndexDoc, context3));
2631
+ for (const record of records) {
2632
+ list.#index.set(record.id, summaries.length);
2633
+ summaries.push(list.#summaryOf(record));
2634
+ }
2388
2635
  list.#list = summaries;
2389
2636
  list.#unsubscribe = harness.subscribeCommits((publication) => list.#observe(publication));
2637
+ const touched = new Set;
2638
+ list.#reindex(await harness.snapshot(IndexDoc, context3), touched);
2639
+ list.#refreshSummaries(touched);
2390
2640
  return list;
2391
2641
  }
2392
2642
  async#load(harness, record) {
@@ -2440,8 +2690,11 @@ class ConversationList {
2440
2690
  facts.calls = entry.kind === "pi.assistant" ? callsOf(message) : undefined;
2441
2691
  } else if (change.type === "document") {
2442
2692
  const id = change.conversationId;
2443
- if (id === undefined)
2693
+ if (id === undefined) {
2694
+ if (change.record.kind === IndexDoc.definition.kind)
2695
+ this.#reindex(change.value, touched);
2444
2696
  continue;
2697
+ }
2445
2698
  const facts = this.#fact(id);
2446
2699
  if (change.record.kind === "pi.live") {
2447
2700
  const live = change.value;
@@ -2454,12 +2707,12 @@ class ConversationList {
2454
2707
  } else if (this.#questionKinds.has(change.record.kind)) {
2455
2708
  const value = change.value;
2456
2709
  const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
2457
- const next2 = new Set(facts.pending);
2710
+ const next = new Set(facts.pending);
2458
2711
  if (pending)
2459
- next2.add(change.record.kind);
2712
+ next.add(change.record.kind);
2460
2713
  else
2461
- next2.delete(change.record.kind);
2462
- facts.pending = next2;
2714
+ next.delete(change.record.kind);
2715
+ facts.pending = next;
2463
2716
  touched.add(id);
2464
2717
  } else if (change.record.kind === SubagentDoc.definition.kind) {
2465
2718
  const value = change.value;
@@ -2473,12 +2726,35 @@ class ConversationList {
2473
2726
  }
2474
2727
  }
2475
2728
  }
2729
+ this.#refreshSummaries(touched);
2730
+ for (const id of ended)
2731
+ for (const listener of this.#runEnd)
2732
+ listener(id);
2733
+ }
2734
+ onRunEnd(listener) {
2735
+ this.#runEnd.add(listener);
2736
+ return () => this.#runEnd.delete(listener);
2737
+ }
2738
+ #reindex(index, touched) {
2739
+ const next = rolesOf(index);
2740
+ for (const record of this.#records.values()) {
2741
+ if (record.owner === undefined && topLevelRole(record, this.#records, this.#roles) !== topLevelRole(record, this.#records, next)) {
2742
+ touched.add(record.id);
2743
+ }
2744
+ }
2745
+ this.#roles = next;
2746
+ }
2747
+ #summaryOf(record) {
2748
+ const facts = this.#fact(record.id);
2749
+ return summaryOf(record, facts, record.owner === undefined ? topLevelRole(record, this.#records, this.#roles) : facts.role);
2750
+ }
2751
+ #refreshSummaries(touched) {
2476
2752
  let next;
2477
2753
  for (const id of touched) {
2478
2754
  const record = this.#records.get(id);
2479
2755
  if (record === undefined)
2480
2756
  continue;
2481
- const summary = summaryOf(record, this.#fact(id));
2757
+ const summary = this.#summaryOf(record);
2482
2758
  const index = this.#index.get(id);
2483
2759
  if (index === undefined) {
2484
2760
  next ??= this.#list.slice();
@@ -2491,13 +2767,6 @@ class ConversationList {
2491
2767
  }
2492
2768
  if (next !== undefined)
2493
2769
  this.#set(next);
2494
- for (const id of ended)
2495
- for (const listener of this.#runEnd)
2496
- listener(id);
2497
- }
2498
- onRunEnd(listener) {
2499
- this.#runEnd.add(listener);
2500
- return () => this.#runEnd.delete(listener);
2501
2770
  }
2502
2771
  #fact(id) {
2503
2772
  let facts = this.#facts.get(id);
@@ -2528,7 +2797,26 @@ class ConversationList {
2528
2797
  this.#listeners.clear();
2529
2798
  }
2530
2799
  }
2531
- function summaryOf(record, facts) {
2800
+ function rolesOf(index) {
2801
+ const roles = new Map;
2802
+ for (const entry of index?.conversations ?? []) {
2803
+ if (typeof entry.role === "string" && entry.role !== "")
2804
+ roles.set(entry.id, entry.role);
2805
+ }
2806
+ return roles;
2807
+ }
2808
+ function topLevelRole(record, records, roles) {
2809
+ let current = record;
2810
+ const seen = new Set;
2811
+ while (current !== undefined && !seen.has(current.id)) {
2812
+ seen.add(current.id);
2813
+ if (current.parent === undefined)
2814
+ return roles.get(current.id);
2815
+ current = records.get(current.parent.conversationId);
2816
+ }
2817
+ return;
2818
+ }
2819
+ function summaryOf(record, facts, role) {
2532
2820
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2533
2821
  const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2534
2822
  const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
@@ -2545,7 +2833,7 @@ function summaryOf(record, facts) {
2545
2833
  ...status === undefined ? {} : { status },
2546
2834
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2547
2835
  ...facts.label === undefined ? {} : { label: facts.label },
2548
- ...facts.role === undefined ? {} : { role: facts.role },
2836
+ ...role === undefined ? {} : { role },
2549
2837
  ...activity === undefined ? {} : { activity }
2550
2838
  };
2551
2839
  }
@@ -2601,12 +2889,14 @@ class GatewayClient {
2601
2889
  #connection;
2602
2890
  #hooks;
2603
2891
  #options;
2892
+ #files;
2604
2893
  #hostValues;
2605
2894
  #subscriptions = new Map;
2606
2895
  #disposed = false;
2607
- constructor(connection, options, conversations, hooks) {
2896
+ constructor(connection, options, conversations, files, hooks) {
2608
2897
  this.#connection = connection;
2609
2898
  this.#options = options;
2899
+ this.#files = files;
2610
2900
  this.#hostValues = {
2611
2901
  conversations,
2612
2902
  mcp: options.mcp ?? constantValue(null),
@@ -2741,8 +3031,8 @@ class GatewayClient {
2741
3031
  await this.#conversation(id);
2742
3032
  const { harness } = this.#options;
2743
3033
  let signalCreation;
2744
- const created = new Promise((resolve3) => {
2745
- signalCreation = resolve3;
3034
+ const created = new Promise((resolve4) => {
3035
+ signalCreation = resolve4;
2746
3036
  });
2747
3037
  let inner;
2748
3038
  let stopped = false;
@@ -2833,8 +3123,9 @@ class GatewayClient {
2833
3123
  ...create.images === undefined ? {} : { images: create.images },
2834
3124
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2835
3125
  ...create.model === undefined ? {} : { model: create.model },
2836
- ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2837
- }, settingsDefaults(this.#options.settings.value), this.#options.models, context3);
3126
+ ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel },
3127
+ ...create.agent === undefined ? {} : { agent: create.agent }
3128
+ }, settingsDefaults(this.#options.settings.value), this.#options.models, this.#options.modelSummaries, this.#options.agents?.value ?? { roles: [], problems: [] }, context3);
2838
3129
  return { conversationId };
2839
3130
  }
2840
3131
  case "archive": {
@@ -2864,7 +3155,10 @@ class GatewayClient {
2864
3155
  const device = this.devicePublicKey;
2865
3156
  if (device === undefined)
2866
3157
  throw new Error("Push tokens register over the secure channel only");
2867
- await setPushToken(this.#options.harness, device, args.token, context3);
3158
+ const joined = async () => await readServerConfig(this.#options.dataDir).catch(() => {
3159
+ return;
3160
+ }) !== undefined;
3161
+ await setPushToken(this.#options.harness, device, args.token, context3, joined);
2868
3162
  return null;
2869
3163
  }
2870
3164
  case "logout": {
@@ -2901,6 +3195,10 @@ class GatewayClient {
2901
3195
  }
2902
3196
  case "listFolders":
2903
3197
  return listFolders(args.path, homedir2());
3198
+ case "listFiles": {
3199
+ const input = args;
3200
+ return listFiles(this.#options.harness, this.#options.dataDir, this.#files, input.scope, input.query, context3);
3201
+ }
2904
3202
  }
2905
3203
  const conversation = await this.#conversation(args.conversationId);
2906
3204
  switch (method) {
@@ -3061,7 +3359,7 @@ class GatewayClient {
3061
3359
  }, context4);
3062
3360
  }
3063
3361
  get #chatsPrefix() {
3064
- return join6(this.#options.dataDir, "chats") + sep3;
3362
+ return join7(this.#options.dataDir, "chats") + sep4;
3065
3363
  }
3066
3364
  async#changes(conversation, context4) {
3067
3365
  const cwd = (await conversation.agent(context4)).cwd;
@@ -3074,15 +3372,16 @@ class GatewayClient {
3074
3372
  if (record !== undefined) {
3075
3373
  if (!await worktreeExists(record.path))
3076
3374
  return { available: false, reason: "No changes yet" };
3077
- return { available: true, ...await changesOf(record.path, record.base) };
3375
+ const [changes, branch2] = await Promise.all([changesOf(record.path, record.base), branchOf(record.path)]);
3376
+ return { available: true, ...changes, branch: branch2 };
3078
3377
  }
3079
- const base = await gitBase(cwd);
3378
+ const [base, branch] = await Promise.all([gitBase(cwd), branchOf(cwd)]);
3080
3379
  if (base === undefined)
3081
- return { available: false, reason: "Not in a git repository" };
3082
- return { available: true, ...await changesOf(base.repo, base.base) };
3380
+ return { available: false, reason: "Not in a git repository", branch };
3381
+ return { available: true, ...await changesOf(base.repo, base.base), branch };
3083
3382
  }
3084
3383
  async#agent(id) {
3085
- return await this.#options.harness.snapshot(AgentDoc3, id, context3) ?? {};
3384
+ return await this.#options.harness.snapshot(AgentDoc4, id, context3) ?? {};
3086
3385
  }
3087
3386
  async dispose() {
3088
3387
  if (this.#disposed)
@@ -3098,18 +3397,18 @@ class GatewayClient {
3098
3397
  // apps/host/src/host.ts
3099
3398
  var context4 = BACKGROUND_CONTEXT4;
3100
3399
  async function hostToken(dataDir) {
3101
- const path = join7(dataDir, "token");
3400
+ const path = join8(dataDir, "token");
3102
3401
  try {
3103
- return (await readFile4(path, "utf8")).trim();
3402
+ return (await readFile3(path, "utf8")).trim();
3104
3403
  } catch {
3105
3404
  const token = randomBytes3(24).toString("base64url");
3106
- await writeFile3(path, `${token}
3405
+ await writeFile2(path, `${token}
3107
3406
  `, { mode: 384 });
3108
3407
  return token;
3109
3408
  }
3110
3409
  }
3111
3410
  async function openHost(options) {
3112
- await mkdir4(options.dataDir, { recursive: true });
3411
+ await mkdir3(options.dataDir, { recursive: true });
3113
3412
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
3114
3413
  const release = await lockfile.lock(options.dataDir, {
3115
3414
  realpath: false,
@@ -3127,7 +3426,7 @@ async function openHost(options) {
3127
3426
  const reports = [];
3128
3427
  let report = (error) => void reports.push(error);
3129
3428
  const registry = createRegistry();
3130
- agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
3429
+ agentRoles = await openAgentRoles({ dir: join8(options.dataDir, "agents") });
3131
3430
  extensions = typeof options.extensions === "function" ? options.extensions({
3132
3431
  harness: () => {
3133
3432
  if (harness === undefined)
@@ -3150,13 +3449,13 @@ async function openHost(options) {
3150
3449
  }
3151
3450
  });
3152
3451
  const hostSettings = await openHostSettings({
3153
- path: join7(options.dataDir, "settings.json"),
3452
+ path: join8(options.dataDir, "settings.json"),
3154
3453
  fallback: options.settingsFallback,
3155
3454
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
3156
3455
  });
3157
3456
  const environments = new Map;
3158
- const chatsDir = join7(options.dataDir, "chats");
3159
- harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join7(options.dataDir, "session.sqlite")), {
3457
+ const chatsDir = join8(options.dataDir, "chats");
3458
+ harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join8(options.dataDir, "session.sqlite")), {
3160
3459
  models: options.models,
3161
3460
  registry,
3162
3461
  env: async (target) => {
@@ -3164,16 +3463,16 @@ async function openHost(options) {
3164
3463
  if (cwd === undefined)
3165
3464
  return;
3166
3465
  const worktrees = worktreeRoot(options.dataDir);
3167
- if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
3466
+ if (cwd === worktrees || cwd.startsWith(worktrees + sep5)) {
3168
3467
  const index = await target.read.snapshot(IndexDoc, context4);
3169
3468
  const record = checkoutAt(index?.checkouts, cwd);
3170
3469
  if (record !== undefined) {
3171
3470
  await ensureWorktree(record);
3172
- await mkdir4(cwd, { recursive: true });
3471
+ await mkdir3(cwd, { recursive: true });
3173
3472
  }
3174
3473
  }
3175
3474
  if (cwd.startsWith(`${chatsDir}/`))
3176
- await mkdir4(cwd, { recursive: true });
3475
+ await mkdir3(cwd, { recursive: true });
3177
3476
  let env = environments.get(cwd);
3178
3477
  if (env === undefined) {
3179
3478
  env = new NodeExecutionEnv({ cwd });
@@ -3213,7 +3512,7 @@ async function openHost(options) {
3213
3512
  ...options.push === undefined ? {} : {
3214
3513
  push: {
3215
3514
  hostKey: toBase64Url(hostKey.publicKey),
3216
- ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
3515
+ ...options.push.transport === undefined ? {} : { transport: options.push.transport },
3217
3516
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3218
3517
  }
3219
3518
  },
@@ -3289,7 +3588,7 @@ async function openHost(options) {
3289
3588
 
3290
3589
  // apps/host/src/model-auth.ts
3291
3590
  import { existsSync as existsSync2, watch as watch2 } from "node:fs";
3292
- import { basename as basename3, dirname as dirname3 } from "node:path";
3591
+ import { basename as basename3, dirname as dirname2 } from "node:path";
3293
3592
 
3294
3593
  // apps/host/src/pi-setup.ts
3295
3594
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -3404,7 +3703,7 @@ function modelSummaries(modelRuntime) {
3404
3703
  }
3405
3704
 
3406
3705
  // apps/host/src/model-auth.ts
3407
- var sameModel2 = (a, b) => a.provider === b.provider && a.modelId === b.modelId && a.name === b.name && a.contextWindow === b.contextWindow && sameList(a.thinkingLevels, b.thinkingLevels, (x, y) => x === y);
3706
+ var sameModel = (a, b) => a.provider === b.provider && a.modelId === b.modelId && a.name === b.name && a.contextWindow === b.contextWindow && sameList(a.thinkingLevels, b.thinkingLevels, (x, y) => x === y);
3408
3707
  var sameLogin = (a, b) => a.providerId === b.providerId && a.name === b.name && a.kind === b.kind && a.subscription === b.subscription;
3409
3708
  async function storedLogins(modelRuntime) {
3410
3709
  const credentials = await modelRuntime.listCredentials();
@@ -3419,7 +3718,7 @@ async function storedLogins(modelRuntime) {
3419
3718
  }
3420
3719
  async function watchModelAuth(modelRuntime, options) {
3421
3720
  const debounceMs = options.debounceMs ?? 150;
3422
- const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel2));
3721
+ const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel));
3423
3722
  const auth = new Published(await storedLogins(modelRuntime), (a, b) => sameList(a.logins, b.logins, sameLogin));
3424
3723
  const fileName = basename3(options.authPath);
3425
3724
  let watcher;
@@ -3438,7 +3737,7 @@ async function watchModelAuth(modelRuntime, options) {
3438
3737
  reported = true;
3439
3738
  }
3440
3739
  };
3441
- const directory = dirname3(options.authPath);
3740
+ const directory = dirname2(options.authPath);
3442
3741
  if (existsSync2(directory)) {
3443
3742
  watcher = watch2(directory, (_event, changed) => {
3444
3743
  if (closed || changed !== null && changed !== fileName)
@@ -3489,7 +3788,7 @@ async function main(argv) {
3489
3788
  relay: { type: "string" }
3490
3789
  }
3491
3790
  });
3492
- const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
3791
+ const dataDir = resolve4(values["data-dir"] ?? DEFAULT_DATA_DIR);
3493
3792
  let relayOrigin;
3494
3793
  try {
3495
3794
  relayOrigin = resolveRelayOrigin(values.relay, await readServerConfig(dataDir));
@@ -3499,7 +3798,7 @@ async function main(argv) {
3499
3798
  }
3500
3799
  const extensionsFor = (models, summaries, options2) => (host2) => [
3501
3800
  createContext({
3502
- agentsHome: join8(homedir3(), ".agents"),
3801
+ agentsHome: join9(homedir3(), ".agents"),
3503
3802
  checkout: (input, context5) => checkoutInfo(host2.harness(), input, context5)
3504
3803
  }),
3505
3804
  createCoding(options2),
@@ -3517,7 +3816,7 @@ async function main(argv) {
3517
3816
  dataDir,
3518
3817
  projects: values.project ?? [],
3519
3818
  port: Number(values.port),
3520
- mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
3819
+ mcpConfig: join9(homedir3(), ".agents", "mcp.json"),
3521
3820
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
3522
3821
  webRoot,
3523
3822
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -3561,7 +3860,7 @@ async function main(argv) {
3561
3860
  }
3562
3861
  async function piOptions() {
3563
3862
  const modelRuntime = await ModelRuntime.create();
3564
- const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
3863
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join9(getAgentDir(), "auth.json") });
3565
3864
  const settingsManager = SettingsManager.create(process.cwd());
3566
3865
  configureHarnessHttp(settingsManager);
3567
3866
  const summaries = () => modelAuth.models.value;
@@ -3593,7 +3892,7 @@ async function main(argv) {
3593
3892
  console.log(JSON.stringify({
3594
3893
  event: "ready",
3595
3894
  url: host.url,
3596
- tokenFile: join8(dataDir, "token"),
3895
+ tokenFile: join9(dataDir, "token"),
3597
3896
  dataDir,
3598
3897
  model,
3599
3898
  web,