pinomad 0.1.0 → 0.2.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,13 +3,15 @@ import {
3
3
  serveWebClient
4
4
  } from "./chunk-r6w5aqkw.js";
5
5
  import {
6
+ packagedVersion,
6
7
  webRoot
7
- } from "./chunk-v7yf5752.js";
8
+ } from "./chunk-rkfdsacq.js";
8
9
  import {
9
- PROTOCOL_VERSION,
10
+ INVALID_FRAME_CLOSE_CODE,
11
+ PROTOCOL,
10
12
  UNAUTHORIZED_CLOSE_CODE,
11
- isClientFrame
12
- } from "./chunk-wgn3cx5q.js";
13
+ parseClientFrame
14
+ } from "./chunk-h35fermn.js";
13
15
  import {
14
16
  DevicesDoc,
15
17
  ensureDevices,
@@ -618,6 +620,7 @@ import { dirname, join, sep } from "node:path";
618
620
  import { defineExtension as defineExtension3, section as section2 } from "@earendil-works/pi-durable";
619
621
  import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
620
622
  var PREAMBLE = "You are PiNomad, a coding agent running on the user's own machine. You work in the project directory below with the read, write, edit, and bash tools: read files before changing them, prefer edit over rewriting whole files, and run commands to check your work. Keep answers short and concrete. When it is unclear what the user wants, ask instead of guessing.";
623
+ var FORK_SHARED_DIR = "This conversation is a fork: it shares this working directory with the conversation it was forked from, which may be editing the same files. If your task needs isolation, create your own git worktree (for example `git worktree add <dir> -b <branch>`) and use absolute paths inside it for every read, write, edit and bash command.";
621
624
  function createContext(options) {
622
625
  const { agentsHome } = options;
623
626
  const resolveCheckout = (input, context) => options.checkout?.(input, context);
@@ -630,14 +633,19 @@ function createContext(options) {
630
633
  const cwd = input.agent.cwd;
631
634
  if (cwd === undefined)
632
635
  return;
633
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
634
- if (checkout === undefined)
635
- return `Working directory: ${cwd}`;
636
+ const resolved = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
637
+ const checkout = resolved?.checkout;
638
+ const forkLine = resolved?.fork === true && checkout?.own !== true ? [FORK_SHARED_DIR] : [];
639
+ if (checkout === undefined) {
640
+ return [`Working directory: ${cwd}`, ...forkLine].join(`
641
+ `);
642
+ }
636
643
  return [
637
644
  `Working directory: ${cwd}`,
638
- `This directory is a git worktree PiNomad created for this conversation, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.`,
645
+ checkout.own ? `This directory is a git worktree PiNomad created for this conversation, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.` : `This directory is a git worktree PiNomad created for the conversation this one belongs to, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.`,
639
646
  `The user's project directory is ${checkout.projectPath} and must not be modified.`,
640
- "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed."
647
+ "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed.",
648
+ ...forkLine
641
649
  ].join(`
642
650
  `);
643
651
  }),
@@ -645,7 +653,7 @@ function createContext(options) {
645
653
  const cwd = input.agent.cwd;
646
654
  if (cwd === undefined)
647
655
  return;
648
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
656
+ const checkout = (await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context))?.checkout;
649
657
  let files;
650
658
  if (checkout === undefined) {
651
659
  files = loadProjectContextFiles({ cwd, agentDir: agentsHome });
@@ -695,6 +703,19 @@ import {
695
703
  defineTool as defineTool3
696
704
  } from "@earendil-works/pi-durable";
697
705
  import { Type as Type4 } from "typebox";
706
+
707
+ // apps/host/src/subagent-doc.ts
708
+ import { defineDoc as defineDoc2 } from "@earendil-works/pi-durable";
709
+ var SubagentDoc = defineDoc2({
710
+ kind: "pinomad.subagent",
711
+ version: 1,
712
+ scope: "conversation",
713
+ history: "latest",
714
+ fork: "initial",
715
+ initial: () => ({})
716
+ });
717
+
718
+ // apps/host/src/extensions/subagent.ts
698
719
  var INSTRUCTIONS = "You are a subagent working on one delegated task. You cannot ask the user questions; if you need a decision or clarification, stop and state exactly what you need in your final answer. Your final message is returned to the delegating agent as the result, so make it a complete, self-contained report.";
699
720
  var THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
700
721
  function createSubagent(options) {
@@ -715,8 +736,14 @@ function createSubagent(options) {
715
736
  execute: async (args, api, context) => {
716
737
  const child = await api.commit(async (tx) => {
717
738
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
718
- if (existing !== undefined)
719
- return existing.id;
739
+ if (existing !== undefined) {
740
+ const doc2 = await tx.doc(SubagentDoc, existing.id);
741
+ return {
742
+ id: existing.id,
743
+ ...doc2?.model === undefined ? {} : { model: doc2.model },
744
+ ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
745
+ };
746
+ }
720
747
  let model;
721
748
  let thinkingLevel;
722
749
  let override;
@@ -747,12 +774,22 @@ function createSubagent(options) {
747
774
  };
748
775
  const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
749
776
  await configure(tx, created.id, change);
750
- return created.id;
777
+ const childAgent = await tx.doc(AgentDoc, created.id);
778
+ const ref = childAgent.model ?? model ?? (await tx.doc(AgentDoc, api.conversationId)).model;
779
+ const startedAt = Date.now();
780
+ const doc = await tx.doc(SubagentDoc, created.id);
781
+ doc.startedAt = startedAt;
782
+ if (ref !== undefined)
783
+ doc.model = `${ref.provider}/${ref.modelId}`;
784
+ const label = args.description?.trim();
785
+ if (label !== undefined && label !== "")
786
+ doc.label = label;
787
+ return { id: created.id, ...ref === undefined ? {} : { model: doc.model }, startedAt };
751
788
  }, context);
752
- await api.details({ conversationId: child }, context);
753
- const handle = await api.conversation(child, context);
789
+ await api.details({ conversationId: child.id, ...child.model === undefined ? {} : { model: child.model }, ...child.startedAt === undefined ? {} : { startedAt: child.startedAt } }, context);
790
+ const handle = await api.conversation(child.id, context);
754
791
  if (handle === undefined)
755
- throw new Error(`Subagent conversation ${child} does not exist`);
792
+ throw new Error(`Subagent conversation ${child.id} does not exist`);
756
793
  const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` };
757
794
  const settled = await (await handle.submit(request, context)).wait(context);
758
795
  if (settled.status !== "done" || settled.type !== "input")
@@ -762,7 +799,11 @@ function createSubagent(options) {
762
799
  const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
763
800
  const result2 = {
764
801
  content: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
765
- details: { conversationId: child }
802
+ details: {
803
+ conversationId: child.id,
804
+ ...child.model === undefined ? {} : { model: child.model },
805
+ ...child.startedAt === undefined ? {} : { startedAt: child.startedAt }
806
+ }
766
807
  };
767
808
  return result2;
768
809
  }
@@ -772,9 +813,9 @@ function createSubagent(options) {
772
813
  }
773
814
 
774
815
  // apps/host/src/extensions/todo.ts
775
- import { defineDoc as defineDoc2, defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
816
+ import { defineDoc as defineDoc3, defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
776
817
  import { Type as Type5 } from "typebox";
777
- var TodoDoc = defineDoc2({
818
+ var TodoDoc = defineDoc3({
778
819
  kind: "todo.list",
779
820
  version: 1,
780
821
  scope: "conversation",
@@ -817,7 +858,7 @@ import { basename, join as join3, resolve } from "node:path";
817
858
  import { clampThinkingLevel as clampThinkingLevel2 } from "@earendil-works/pi-ai";
818
859
  import {
819
860
  AgentDoc as AgentDoc2,
820
- defineDoc as defineDoc3
861
+ defineDoc as defineDoc4
821
862
  } from "@earendil-works/pi-durable";
822
863
 
823
864
  // apps/host/src/checkout.ts
@@ -848,27 +889,6 @@ async function gitBase(projectPath) {
848
889
  return;
849
890
  return { repo, subdir: relative(repo, projectPath), base };
850
891
  }
851
- var SNAPSHOT_IDENTITY = {
852
- GIT_AUTHOR_NAME: "PiNomad",
853
- GIT_AUTHOR_EMAIL: "pinomad@localhost",
854
- GIT_COMMITTER_NAME: "PiNomad",
855
- GIT_COMMITTER_EMAIL: "pinomad@localhost"
856
- };
857
- async function snapshotOf(sourceDir) {
858
- const indexFile = join2(tmpdir(), `pinomad-index-${randomBytes(8).toString("hex")}`);
859
- const env = { GIT_INDEX_FILE: indexFile };
860
- try {
861
- const { stdout: head } = await git(sourceDir, ["rev-parse", "--verify", "HEAD"], env);
862
- const base = head.trim();
863
- await git(sourceDir, ["read-tree", base], env);
864
- await git(sourceDir, ["add", "-A"], env);
865
- const { stdout: tree } = await git(sourceDir, ["write-tree"], env);
866
- const { stdout: commit } = await git(sourceDir, ["commit-tree", tree.trim(), "-p", base, "-m", "pinomad fork snapshot"], { ...env, ...SNAPSHOT_IDENTITY });
867
- return { base, snapshot: commit.trim() };
868
- } finally {
869
- await rm(indexFile, { force: true });
870
- }
871
- }
872
892
  var repoLocks = new Map;
873
893
  function locked(repo, work) {
874
894
  const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
@@ -932,7 +952,7 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
932
952
  }
933
953
 
934
954
  // apps/host/src/organization.ts
935
- var IndexDoc = defineDoc3({
955
+ var IndexDoc = defineDoc4({
936
956
  kind: "pinomad.index",
937
957
  version: 1,
938
958
  scope: "session",
@@ -1056,21 +1076,42 @@ async function archive(harness, id, archived, context) {
1056
1076
  entry.archived = archived;
1057
1077
  }, context);
1058
1078
  }
1059
- async function checkoutInfo(input, context) {
1079
+ var forkFlags = new WeakMap;
1080
+ async function forkFlagOf(harness, id, context) {
1081
+ const cached = forkFlags.get(harness)?.get(id);
1082
+ if (cached !== undefined)
1083
+ return cached;
1084
+ const record = await harness.commit((tx) => tx.conversation(id), context);
1085
+ if (record === undefined)
1086
+ return false;
1087
+ let flags = forkFlags.get(harness);
1088
+ if (flags === undefined)
1089
+ forkFlags.set(harness, flags = new Map);
1090
+ flags.set(id, record.parent !== undefined);
1091
+ return record.parent !== undefined;
1092
+ }
1093
+ async function checkoutInfo(harness, input, context) {
1060
1094
  const agent = await input.read.snapshot(AgentDoc2, input.conversationId, context);
1061
1095
  const cwd = agent?.cwd;
1062
1096
  if (cwd === undefined)
1063
1097
  return;
1064
- const index = await input.read.snapshot(IndexDoc, context);
1065
- const record = checkoutAt(index?.checkouts, cwd);
1066
- if (record === undefined)
1067
- return;
1098
+ const [index, fork] = await Promise.all([
1099
+ input.read.snapshot(IndexDoc, context),
1100
+ forkFlagOf(harness, input.conversationId, context)
1101
+ ]);
1102
+ const checkout = checkoutAt(index?.checkouts, cwd);
1068
1103
  return {
1069
- branch: record.branch,
1070
- base: record.base,
1071
- projectPath: join3(record.repo, record.subdir),
1072
- repo: record.repo,
1073
- worktreeRoot: record.path
1104
+ fork,
1105
+ ...checkout === undefined ? {} : {
1106
+ checkout: {
1107
+ branch: checkout.branch,
1108
+ base: checkout.base,
1109
+ projectPath: join3(checkout.repo, checkout.subdir),
1110
+ repo: checkout.repo,
1111
+ worktreeRoot: checkout.path,
1112
+ own: checkout.conversationId === input.conversationId
1113
+ }
1114
+ }
1074
1115
  };
1075
1116
  }
1076
1117
  async function cleanupArchivedWorktrees(harness, rootId, context) {
@@ -1409,15 +1450,43 @@ import lockfile from "proper-lockfile";
1409
1450
  // apps/host/src/gateway.ts
1410
1451
  import { createServer } from "node:http";
1411
1452
  import { networkInterfaces } from "node:os";
1412
- import { join as join4, relative as relative2, sep as sep3 } from "node:path";
1453
+ import { join as join4, sep as sep3 } from "node:path";
1413
1454
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1414
1455
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1415
1456
  import {
1416
- AgentDoc as AgentDoc3
1457
+ AgentDoc as AgentDoc3,
1458
+ LiveDoc
1417
1459
  } from "@earendil-works/pi-durable";
1418
1460
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1419
1461
  import { Value as Value2 } from "typebox/value";
1420
1462
 
1463
+ // packages/protocol/src/tool-target.ts
1464
+ var TARGET_KEYS = [
1465
+ "path",
1466
+ "file_path",
1467
+ "filePath",
1468
+ "command",
1469
+ "cmd",
1470
+ "query",
1471
+ "pattern",
1472
+ "url",
1473
+ "name",
1474
+ "description",
1475
+ "task"
1476
+ ];
1477
+ var TARGET_MAX_LENGTH = 120;
1478
+ function toolTarget(args) {
1479
+ if (typeof args !== "object" || args === null)
1480
+ return;
1481
+ for (const key of TARGET_KEYS) {
1482
+ const value = args[key];
1483
+ if (typeof value === "string" && value.length > 0) {
1484
+ return value.length > TARGET_MAX_LENGTH ? `${value.slice(0, TARGET_MAX_LENGTH)}…` : value;
1485
+ }
1486
+ }
1487
+ return;
1488
+ }
1489
+
1421
1490
  // apps/host/src/relay-link.ts
1422
1491
  import { WebSocket } from "ws";
1423
1492
  function relayWsBase(origin) {
@@ -1446,6 +1515,12 @@ function startRelayLink(options) {
1446
1515
  let delay = minDelay;
1447
1516
  let retryTimer;
1448
1517
  let pendingAccepts = 0;
1518
+ function setState(next) {
1519
+ const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
1520
+ state = next;
1521
+ if (changed)
1522
+ options.onStateChange?.(next);
1523
+ }
1449
1524
  const watched = new Set;
1450
1525
  const ponged = new WeakMap;
1451
1526
  const pingIntervalMs = options.pingIntervalMs ?? 30000;
@@ -1505,7 +1580,7 @@ function startRelayLink(options) {
1505
1580
  break;
1506
1581
  case "registered":
1507
1582
  delay = minDelay;
1508
- state = { status: "registered" };
1583
+ setState({ status: "registered" });
1509
1584
  break;
1510
1585
  case "incoming": {
1511
1586
  if (pendingAccepts >= maxPendingAccepts)
@@ -1532,14 +1607,12 @@ function startRelayLink(options) {
1532
1607
  const rejection = REJECTION_REASONS[code];
1533
1608
  if (rejection !== undefined) {
1534
1609
  const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
1535
- if (state.status !== "rejected" || state.reason !== reason)
1536
- options.onRejected?.(reason);
1537
- state = { status: "rejected", reason };
1610
+ setState({ status: "rejected", reason });
1538
1611
  delay = maxDelay;
1539
1612
  reconnect(maxDelay);
1540
1613
  return;
1541
1614
  }
1542
- state = { status: "connecting" };
1615
+ setState({ status: "connecting" });
1543
1616
  reconnect(delay);
1544
1617
  delay = Math.min(maxDelay, delay * 2);
1545
1618
  }
@@ -1562,8 +1635,20 @@ function startRelayLink(options) {
1562
1635
 
1563
1636
  // apps/host/src/gateway.ts
1564
1637
  var context = BACKGROUND_CONTEXT;
1638
+ var forkTails = new WeakMap;
1639
+ function serializedFork(harness, work) {
1640
+ const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
1641
+ const settled = next.then(() => {}, () => {});
1642
+ forkTails.set(harness, settled);
1643
+ settled.then(() => {
1644
+ if (forkTails.get(harness) === settled)
1645
+ forkTails.delete(harness);
1646
+ });
1647
+ return next;
1648
+ }
1649
+ var LOOPBACK_SECURE_PATH = "/secure";
1565
1650
  async function startGateway(options) {
1566
- const conversations = await ConversationList.open(options.harness);
1651
+ const conversations = await ConversationList.open(options.harness, options.docs);
1567
1652
  const http = createServer((request, response) => {
1568
1653
  serveWebClient(request, response, options.webRoot).catch(() => {
1569
1654
  if (!response.headersSent)
@@ -1601,22 +1686,35 @@ async function startGateway(options) {
1601
1686
  },
1602
1687
  createPairing: () => {
1603
1688
  const spec = options.secure;
1604
- if (spec === undefined || remote === undefined && relay === undefined)
1689
+ if (spec === undefined)
1605
1690
  return;
1606
1691
  const { secret, expiresAt } = spec.offers.create();
1607
1692
  const pair = `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret}`;
1608
1693
  if (relay !== undefined) {
1609
- const target = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1610
- return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
1694
+ const target2 = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1695
+ return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target2)}`, expiresAt };
1611
1696
  }
1612
- return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
1697
+ if (remote !== undefined)
1698
+ return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
1699
+ const target = `ws://127.0.0.1:${port}${LOOPBACK_SECURE_PATH}`;
1700
+ return { url: `http://127.0.0.1:${port}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
1613
1701
  }
1614
1702
  });
1615
1703
  clients.add(client);
1616
1704
  };
1617
1705
  server.on("connection", (socket, request) => {
1618
1706
  socket.on("error", () => socket.terminate());
1619
- const token = new URL(request.url ?? "/", "ws://127.0.0.1").searchParams.get("token");
1707
+ const url = new URL(request.url ?? "/", "ws://127.0.0.1");
1708
+ if (url.pathname === LOOPBACK_SECURE_PATH) {
1709
+ const secure2 = options.secure;
1710
+ if (secure2 === undefined) {
1711
+ socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1712
+ return;
1713
+ }
1714
+ secureHandshake(socket, secure2, options.harness, register);
1715
+ return;
1716
+ }
1717
+ const token = url.searchParams.get("token");
1620
1718
  const origin = request.headers.origin;
1621
1719
  if (token !== options.token || origin !== undefined && !browserOrigins.includes(origin)) {
1622
1720
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
@@ -1627,17 +1725,25 @@ async function startGateway(options) {
1627
1725
  remote = await startRemote(options, register);
1628
1726
  const secure = options.secure;
1629
1727
  if (secure?.relay !== undefined) {
1630
- relay = startRelayLink({
1728
+ let link;
1729
+ link = startRelayLink({
1631
1730
  origin: secure.relay.origin,
1632
1731
  signingKey: secure.relay.signingKey,
1633
1732
  accept: (socket) => secureHandshake(socket, secure, options.harness, register),
1634
- onRejected: (reason) => {
1635
- broadcast("warning", reason);
1636
- console.error(reason);
1733
+ onStateChange: (state) => {
1734
+ if (state.status === "registered") {
1735
+ console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId }));
1736
+ } else if (state.status === "connecting") {
1737
+ console.log(JSON.stringify({ event: "relay", status: "connecting", origin: link.origin }));
1738
+ } else {
1739
+ broadcast("warning", state.reason);
1740
+ console.error(state.reason);
1741
+ }
1637
1742
  },
1638
1743
  ...secure.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: secure.relay.reconnectDelayMs },
1639
1744
  ...secure.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: secure.relay.pingIntervalMs }
1640
1745
  });
1746
+ relay = link;
1641
1747
  }
1642
1748
  return {
1643
1749
  url: `ws://127.0.0.1:${port}`,
@@ -1794,36 +1900,131 @@ async function startRemote(options, register) {
1794
1900
 
1795
1901
  class ConversationList {
1796
1902
  #listeners = new Set;
1797
- #list;
1903
+ #questionTokens;
1904
+ #questionKinds;
1905
+ #records = new Map;
1906
+ #facts = new Map;
1907
+ #index = new Map;
1908
+ #list = [];
1798
1909
  #unsubscribe = () => {};
1799
1910
  #notifying = false;
1800
- constructor(list) {
1801
- this.#list = list;
1911
+ constructor(docs) {
1912
+ this.#questionTokens = (docs ?? []).filter((doc) => doc.presentation === "pinomad.question").map((doc) => doc.token);
1913
+ this.#questionKinds = new Set(this.#questionTokens.map((token) => token.definition.kind));
1802
1914
  }
1803
- static async open(harness) {
1915
+ static async open(harness, docs) {
1916
+ const list = new ConversationList(docs);
1804
1917
  const summaries = [];
1805
1918
  let cursor;
1806
1919
  do {
1807
1920
  const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context);
1808
- for (const record of page.items)
1809
- summaries.push({ ...summaryOf(record), ...await firstInput(harness, record.id) });
1921
+ for (const record of page.items) {
1922
+ list.#records.set(record.id, record);
1923
+ const facts = await list.#load(harness, record);
1924
+ list.#facts.set(record.id, facts);
1925
+ list.#index.set(record.id, summaries.length);
1926
+ summaries.push(summaryOf(record, facts));
1927
+ }
1810
1928
  cursor = page.next;
1811
1929
  } while (cursor !== undefined);
1812
- const list = new ConversationList(summaries);
1813
- list.#unsubscribe = harness.subscribeCommits((publication) => {
1814
- let next = list.#list;
1815
- for (const change of publication.changes) {
1816
- if (change.type === "conversation") {
1817
- next = [...next, summaryOf(change.value)];
1818
- } else if (change.type === "entry" && change.value.kind === "pi.user") {
1819
- const id = change.value.conversationId;
1820
- next = next.map((summary) => summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary);
1930
+ list.#list = summaries;
1931
+ list.#unsubscribe = harness.subscribeCommits((publication) => list.#observe(publication));
1932
+ return list;
1933
+ }
1934
+ async#load(harness, record) {
1935
+ const history = await historyFacts(harness, record.id);
1936
+ const facts = { pending: new Set, ...history };
1937
+ const live = await harness.snapshot(LiveDoc, record.id, context);
1938
+ facts.running = live?.run !== undefined;
1939
+ facts.tools = live?.tools;
1940
+ for (const token of this.#questionTokens) {
1941
+ const value = await harness.snapshot(token, record.id, context);
1942
+ if (Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined)) {
1943
+ facts.pending.add(token.definition.kind);
1944
+ }
1945
+ }
1946
+ if (record.owner !== undefined) {
1947
+ const label = (await harness.snapshot(SubagentDoc, record.id, context))?.label;
1948
+ if (typeof label === "string" && label !== "")
1949
+ facts.label = label;
1950
+ }
1951
+ return facts;
1952
+ }
1953
+ #observe(publication) {
1954
+ const touched = new Set;
1955
+ for (const change of publication.changes) {
1956
+ if (change.type === "conversation") {
1957
+ this.#records.set(change.value.id, change.value);
1958
+ touched.add(change.value.id);
1959
+ }
1960
+ }
1961
+ for (const change of publication.changes) {
1962
+ if (change.type === "entry") {
1963
+ const entry = change.value;
1964
+ if (entry.kind !== "pi.user" && entry.kind !== "pi.assistant")
1965
+ continue;
1966
+ const facts = this.#fact(entry.conversationId);
1967
+ if (entry.kind === "pi.user" && facts.title === undefined)
1968
+ Object.assign(facts, titleOf(entry));
1969
+ if (facts.newest !== undefined && entry.id <= facts.newest)
1970
+ continue;
1971
+ touched.add(entry.conversationId);
1972
+ facts.newest = entry.id;
1973
+ const message = entry.model?.[0];
1974
+ facts.updatedAt = typeof message?.timestamp === "number" ? message.timestamp : undefined;
1975
+ facts.failed = entry.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error";
1976
+ facts.calls = entry.kind === "pi.assistant" ? callsOf(message) : undefined;
1977
+ } else if (change.type === "document") {
1978
+ const id = change.conversationId;
1979
+ if (id === undefined)
1980
+ continue;
1981
+ const facts = this.#fact(id);
1982
+ if (change.record.kind === "pi.live") {
1983
+ const live = change.value;
1984
+ facts.running = live?.run !== undefined;
1985
+ facts.tools = live?.tools;
1986
+ touched.add(id);
1987
+ } else if (this.#questionKinds.has(change.record.kind)) {
1988
+ const value = change.value;
1989
+ const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
1990
+ const next2 = new Set(facts.pending);
1991
+ if (pending)
1992
+ next2.add(change.record.kind);
1993
+ else
1994
+ next2.delete(change.record.kind);
1995
+ facts.pending = next2;
1996
+ touched.add(id);
1997
+ } else if (change.record.kind === SubagentDoc.definition.kind) {
1998
+ const label = change.value?.label;
1999
+ facts.label = typeof label === "string" && label !== "" ? label : undefined;
2000
+ touched.add(id);
1821
2001
  }
1822
2002
  }
1823
- if (next !== list.#list)
1824
- list.#set(next);
1825
- });
1826
- return list;
2003
+ }
2004
+ let next;
2005
+ for (const id of touched) {
2006
+ const record = this.#records.get(id);
2007
+ if (record === undefined)
2008
+ continue;
2009
+ const summary = summaryOf(record, this.#fact(id));
2010
+ const index = this.#index.get(id);
2011
+ if (index === undefined) {
2012
+ next ??= this.#list.slice();
2013
+ this.#index.set(id, next.length);
2014
+ next.push(summary);
2015
+ } else if (!sameSummary(this.#list[index], summary)) {
2016
+ next ??= this.#list.slice();
2017
+ next[index] = summary;
2018
+ }
2019
+ }
2020
+ if (next !== undefined)
2021
+ this.#set(next);
2022
+ }
2023
+ #fact(id) {
2024
+ let facts = this.#facts.get(id);
2025
+ if (facts === undefined)
2026
+ this.#facts.set(id, facts = { pending: new Set });
2027
+ return facts;
1827
2028
  }
1828
2029
  get value() {
1829
2030
  return this.#list;
@@ -1848,14 +2049,29 @@ class ConversationList {
1848
2049
  this.#listeners.clear();
1849
2050
  }
1850
2051
  }
1851
- function summaryOf(record) {
2052
+ function summaryOf(record, facts) {
1852
2053
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2054
+ const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2055
+ const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
2056
+ const activity = runningSlot === undefined ? undefined : {
2057
+ tool: runningSlot.name,
2058
+ ...facts.calls?.get(runningSlot.callId) === undefined ? {} : { target: facts.calls.get(runningSlot.callId) }
2059
+ };
1853
2060
  return {
1854
2061
  id: record.id,
1855
2062
  kind: record.owner !== undefined ? "subagent" : record.parent !== undefined ? "fork" : "conversation",
1856
- ...parent === undefined ? {} : { parent }
2063
+ ...parent === undefined ? {} : { parent },
2064
+ ...record.parent === undefined ? {} : { forkedAt: String(record.parent.at) },
2065
+ ...facts.title === undefined ? {} : { title: facts.title },
2066
+ ...status === undefined ? {} : { status },
2067
+ ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2068
+ ...facts.label === undefined ? {} : { label: facts.label },
2069
+ ...activity === undefined ? {} : { activity }
1857
2070
  };
1858
2071
  }
2072
+ function sameSummary(a, b) {
2073
+ return a.id === b.id && a.kind === b.kind && a.parent === b.parent && a.forkedAt === b.forkedAt && a.title === b.title && a.status === b.status && a.updatedAt === b.updatedAt && a.label === b.label && a.activity?.tool === b.activity?.tool && a.activity?.target === b.activity?.target;
2074
+ }
1859
2075
  function titleOf(entry) {
1860
2076
  const message = entry?.model?.[0];
1861
2077
  if (message?.role !== "user")
@@ -1863,16 +2079,40 @@ function titleOf(entry) {
1863
2079
  const text = typeof message.content === "string" ? message.content : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
1864
2080
  return { title: text.replace(/\s+/g, " ").trim() };
1865
2081
  }
1866
- async function firstInput(harness, id) {
2082
+ async function historyFacts(harness, id) {
1867
2083
  const conversation = await harness.conversation(id, context);
1868
2084
  let first;
2085
+ let newest;
1869
2086
  let cursor;
1870
2087
  do {
1871
2088
  const page = await conversation.entries({}, 256, cursor, context);
1872
2089
  first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id) ?? first;
2090
+ newest ??= page.items.find((entry) => entry.conversationId === id && (entry.kind === "pi.user" || entry.kind === "pi.assistant"));
1873
2091
  cursor = page.next;
1874
2092
  } while (cursor !== undefined);
1875
- return titleOf(first);
2093
+ const message = newest?.model?.[0];
2094
+ return {
2095
+ ...titleOf(first),
2096
+ ...newest === undefined ? {} : {
2097
+ newest: newest.id,
2098
+ ...typeof message?.timestamp === "number" ? { updatedAt: message.timestamp } : {},
2099
+ failed: newest.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error",
2100
+ ...newest.kind === "pi.assistant" ? { calls: callsOf(message) } : {}
2101
+ }
2102
+ };
2103
+ }
2104
+ function callsOf(message) {
2105
+ if (message?.role !== "assistant")
2106
+ return;
2107
+ const content = message.content;
2108
+ if (!Array.isArray(content))
2109
+ return;
2110
+ const calls = new Map;
2111
+ for (const block of content) {
2112
+ if (block?.type === "toolCall" && typeof block.id === "string")
2113
+ calls.set(block.id, toolTarget(block.arguments));
2114
+ }
2115
+ return calls.size === 0 ? undefined : calls;
1876
2116
  }
1877
2117
 
1878
2118
  class GatewayClient {
@@ -1891,24 +2131,38 @@ class GatewayClient {
1891
2131
  this.devicePublicKey = hooks.devicePublicKey;
1892
2132
  connection.handle({
1893
2133
  message: (text) => {
1894
- let frame;
2134
+ let parsed;
1895
2135
  try {
1896
- frame = JSON.parse(text);
2136
+ parsed = parseClientFrame(JSON.parse(text));
1897
2137
  } catch {
1898
- connection.close(1008, "Invalid client frame");
2138
+ connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
1899
2139
  return;
1900
2140
  }
1901
- if (!isClientFrame(frame)) {
1902
- connection.close(1008, "Invalid client frame");
1903
- return;
2141
+ switch (parsed.kind) {
2142
+ case "frame":
2143
+ this.#receive(parsed.frame).catch(() => connection.close(1011, "Gateway request failed"));
2144
+ return;
2145
+ case "unsupportedCall":
2146
+ this.send({ type: "result", id: parsed.id, ok: false, error: `Unsupported method: ${parsed.method}` });
2147
+ return;
2148
+ case "unsupportedStream":
2149
+ if (parsed.type === "subscribe") {
2150
+ this.send({ type: "ended", stream: parsed.stream, reason: "Unsupported stream" });
2151
+ }
2152
+ return;
2153
+ case "unknownType":
2154
+ return;
2155
+ case "malformed":
2156
+ connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
2157
+ return;
1904
2158
  }
1905
- this.#receive(frame).catch(() => connection.close(1011, "Gateway request failed"));
1906
2159
  },
1907
2160
  closed: () => hooks.closed(this)
1908
2161
  });
1909
2162
  this.send({
1910
2163
  type: "hello",
1911
- protocol: PROTOCOL_VERSION,
2164
+ protocol: PROTOCOL,
2165
+ ...packagedVersion === undefined ? {} : { hostVersion: packagedVersion },
1912
2166
  session: options.session,
1913
2167
  models: options.modelSummaries(),
1914
2168
  defaults: options.defaults,
@@ -2162,15 +2416,34 @@ class GatewayClient {
2162
2416
  }
2163
2417
  case "fork": {
2164
2418
  const { entryId, removeTools = [] } = args;
2165
- const agent = await conversation.agent(context);
2166
- const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2167
- const init = await this.#forkCheckout(agent.cwd);
2168
- const fork = await conversation.fork(Number(entryId), {
2169
- ownership: { kind: "ownerless" },
2170
- ...remove.length === 0 ? {} : { agent: { tools: { remove } } },
2171
- ...init === undefined ? {} : { init }
2172
- }, context);
2173
- return { conversationId: fork.id };
2419
+ return await serializedFork(this.#options.harness, async () => {
2420
+ const at = Number(entryId);
2421
+ await this.#options.harness.commit(async (tx) => {
2422
+ const record = await tx.conversation(conversation.id);
2423
+ if (record === undefined)
2424
+ throw new Error(`Conversation ${conversation.id} does not exist`);
2425
+ if (record.parent !== undefined || record.owner !== undefined) {
2426
+ throw new Error("Only top-level conversations can be forked");
2427
+ }
2428
+ let cursor;
2429
+ do {
2430
+ const page = await tx.scanConversations({}, 256, cursor);
2431
+ for (const candidate of page.items) {
2432
+ if (candidate.parent?.conversationId === conversation.id && candidate.parent.at === at) {
2433
+ throw new Error("This message already has a fork");
2434
+ }
2435
+ }
2436
+ cursor = page.next;
2437
+ } while (cursor !== undefined);
2438
+ }, context);
2439
+ const agent = await conversation.agent(context);
2440
+ const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2441
+ const fork = await conversation.fork(at, {
2442
+ ownership: { kind: "ownerless" },
2443
+ ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
2444
+ }, context);
2445
+ return { conversationId: fork.id };
2446
+ });
2174
2447
  }
2175
2448
  case "answer": {
2176
2449
  const { kind, requestId, answers } = args;
@@ -2254,41 +2527,6 @@ class GatewayClient {
2254
2527
  return { available: false, reason: "Not in a git repository" };
2255
2528
  return { available: true, ...await changesOf(base.repo, base.base) };
2256
2529
  }
2257
- async#forkCheckout(cwd) {
2258
- if (cwd === undefined)
2259
- return;
2260
- const index = await this.#options.harness.snapshot(IndexDoc, context);
2261
- const existing = checkoutAt(index?.checkouts, cwd);
2262
- let source;
2263
- if (existing !== undefined) {
2264
- source = { repo: existing.repo, dir: existing.path, subdir: relative2(existing.path, cwd) };
2265
- } else {
2266
- if (cwd.startsWith(this.#chatsPrefix))
2267
- return;
2268
- const base2 = await gitBase(cwd);
2269
- if (base2 === undefined)
2270
- return;
2271
- source = { repo: base2.repo, dir: base2.repo, subdir: base2.subdir };
2272
- }
2273
- const { base, snapshot } = await snapshotOf(source.dir);
2274
- const suffix = branchSuffix();
2275
- return async (tx, id) => {
2276
- const path = worktreePath(this.#options.dataDir, id);
2277
- const doc = await tx.doc(IndexDoc);
2278
- if (doc.checkouts === undefined)
2279
- doc.checkouts = [];
2280
- doc.checkouts.push({
2281
- conversationId: id,
2282
- path,
2283
- repo: source.repo,
2284
- subdir: source.subdir,
2285
- branch: worktreeBranch(id, suffix),
2286
- base,
2287
- snapshot
2288
- });
2289
- (await tx.doc(AgentDoc3, id)).cwd = join4(path, source.subdir);
2290
- };
2291
- }
2292
2530
  async#agent(id) {
2293
2531
  return await this.#options.harness.snapshot(AgentDoc3, id, context) ?? {};
2294
2532
  }
@@ -2333,7 +2571,14 @@ async function openHost(options) {
2333
2571
  const reports = [];
2334
2572
  let report = (error) => void reports.push(error);
2335
2573
  const registry = createRegistry();
2336
- const extensions = typeof options.extensions === "function" ? options.extensions({ scriptTools: () => mcp?.scriptTools() ?? [] }) : options.extensions ?? [];
2574
+ const extensions = typeof options.extensions === "function" ? options.extensions({
2575
+ scriptTools: () => mcp?.scriptTools() ?? [],
2576
+ harness: () => {
2577
+ if (harness === undefined)
2578
+ throw new Error("harness is not open yet");
2579
+ return harness;
2580
+ }
2581
+ }) : options.extensions ?? [];
2337
2582
  for (const { extension } of extensions)
2338
2583
  registry.install(extension);
2339
2584
  mcp = options.mcpConfig === undefined ? undefined : startMcp({ configPath: options.mcpConfig, registry });
@@ -2372,9 +2617,8 @@ async function openHost(options) {
2372
2617
  await ensureDevices(harness, context2);
2373
2618
  for (const path of options.projects ?? [])
2374
2619
  await addProject(harness, path, context2);
2375
- const remoteAccess = options.remote !== undefined || options.relay !== undefined;
2376
- const hostKey = remoteAccess ? await loadHostKey(options.dataDir) : undefined;
2377
- const offers = remoteAccess ? pairingOffers(options.remote?.pairingTtlMs) : undefined;
2620
+ const hostKey = await loadHostKey(options.dataDir);
2621
+ const offers = pairingOffers(options.remote?.pairingTtlMs);
2378
2622
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
2379
2623
  const defaults = initialModel === undefined ? {} : {
2380
2624
  model: { provider: initialModel.provider, modelId: initialModel.modelId },
@@ -2394,24 +2638,22 @@ async function openHost(options) {
2394
2638
  toolPresentations: Object.assign({}, ...extensions.map((extension) => extension.tools ?? {})),
2395
2639
  ...mcp === undefined ? {} : { mcp: mcp.status },
2396
2640
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
2397
- ...hostKey === undefined || offers === undefined ? {} : {
2398
- secure: {
2399
- hostKey,
2400
- offers,
2401
- ...options.remote?.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: options.remote.handshakeTimeoutMs },
2402
- ...options.remote === undefined ? {} : {
2403
- lan: {
2404
- port: options.remote.port,
2405
- ...options.remote.publicUrl === undefined ? {} : { publicUrl: options.remote.publicUrl }
2406
- }
2407
- },
2408
- ...options.relay === undefined || relayKey === undefined ? {} : {
2409
- relay: {
2410
- origin: options.relay.origin,
2411
- signingKey: relayKey,
2412
- ...options.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: options.relay.reconnectDelayMs },
2413
- ...options.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: options.relay.pingIntervalMs }
2414
- }
2641
+ secure: {
2642
+ hostKey,
2643
+ offers,
2644
+ ...options.remote?.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: options.remote.handshakeTimeoutMs },
2645
+ ...options.remote === undefined ? {} : {
2646
+ lan: {
2647
+ port: options.remote.port,
2648
+ ...options.remote.publicUrl === undefined ? {} : { publicUrl: options.remote.publicUrl }
2649
+ }
2650
+ },
2651
+ ...options.relay === undefined || relayKey === undefined ? {} : {
2652
+ relay: {
2653
+ origin: options.relay.origin,
2654
+ signingKey: relayKey,
2655
+ ...options.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: options.relay.reconnectDelayMs },
2656
+ ...options.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: options.relay.pingIntervalMs }
2415
2657
  }
2416
2658
  }
2417
2659
  }
@@ -2584,7 +2826,10 @@ async function main(argv) {
2584
2826
  const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
2585
2827
  const extensionsFor = (models, summaries) => (host2) => {
2586
2828
  const others = [
2587
- createContext({ agentsHome: join6(homedir2(), ".agents"), checkout: checkoutInfo }),
2829
+ createContext({
2830
+ agentsHome: join6(homedir2(), ".agents"),
2831
+ checkout: (input, context3) => checkoutInfo(host2.harness(), input, context3)
2832
+ }),
2588
2833
  coding,
2589
2834
  todo,
2590
2835
  question,
@@ -2656,7 +2901,7 @@ async function main(argv) {
2656
2901
  const options = values.faux !== undefined ? fauxOptions(() => () => fauxAssistantMessage(values.faux)) : await piOptions();
2657
2902
  const host = await openHost(options);
2658
2903
  const model = options.initialModel === undefined ? null : `${options.initialModel.provider}/${options.initialModel.modelId}`;
2659
- const web = "http://127.0.0.1:5199/";
2904
+ const web = packagedVersion === undefined ? "http://127.0.0.1:5199/" : new URL(host.url.replace(/^ws/, "http")).href;
2660
2905
  console.log(JSON.stringify({
2661
2906
  event: "ready",
2662
2907
  url: host.url,