pinomad 0.1.1 → 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.
@@ -5,12 +5,13 @@ import {
5
5
  import {
6
6
  packagedVersion,
7
7
  webRoot
8
- } from "./chunk-fpb6f9n5.js";
8
+ } from "./chunk-rkfdsacq.js";
9
9
  import {
10
- PROTOCOL_VERSION,
10
+ INVALID_FRAME_CLOSE_CODE,
11
+ PROTOCOL,
11
12
  UNAUTHORIZED_CLOSE_CODE,
12
- isClientFrame
13
- } from "./chunk-wgn3cx5q.js";
13
+ parseClientFrame
14
+ } from "./chunk-h35fermn.js";
14
15
  import {
15
16
  DevicesDoc,
16
17
  ensureDevices,
@@ -619,6 +620,7 @@ import { dirname, join, sep } from "node:path";
619
620
  import { defineExtension as defineExtension3, section as section2 } from "@earendil-works/pi-durable";
620
621
  import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
621
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.";
622
624
  function createContext(options) {
623
625
  const { agentsHome } = options;
624
626
  const resolveCheckout = (input, context) => options.checkout?.(input, context);
@@ -631,14 +633,19 @@ function createContext(options) {
631
633
  const cwd = input.agent.cwd;
632
634
  if (cwd === undefined)
633
635
  return;
634
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
635
- if (checkout === undefined)
636
- 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
+ }
637
643
  return [
638
644
  `Working directory: ${cwd}`,
639
- `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)}.`,
640
646
  `The user's project directory is ${checkout.projectPath} and must not be modified.`,
641
- "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
642
649
  ].join(`
643
650
  `);
644
651
  }),
@@ -646,7 +653,7 @@ function createContext(options) {
646
653
  const cwd = input.agent.cwd;
647
654
  if (cwd === undefined)
648
655
  return;
649
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
656
+ const checkout = (await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context))?.checkout;
650
657
  let files;
651
658
  if (checkout === undefined) {
652
659
  files = loadProjectContextFiles({ cwd, agentDir: agentsHome });
@@ -696,6 +703,19 @@ import {
696
703
  defineTool as defineTool3
697
704
  } from "@earendil-works/pi-durable";
698
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
699
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.";
700
720
  var THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
701
721
  function createSubagent(options) {
@@ -716,8 +736,14 @@ function createSubagent(options) {
716
736
  execute: async (args, api, context) => {
717
737
  const child = await api.commit(async (tx) => {
718
738
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
719
- if (existing !== undefined)
720
- 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
+ }
721
747
  let model;
722
748
  let thinkingLevel;
723
749
  let override;
@@ -748,12 +774,22 @@ function createSubagent(options) {
748
774
  };
749
775
  const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
750
776
  await configure(tx, created.id, change);
751
- 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 };
752
788
  }, context);
753
- await api.details({ conversationId: child }, context);
754
- 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);
755
791
  if (handle === undefined)
756
- throw new Error(`Subagent conversation ${child} does not exist`);
792
+ throw new Error(`Subagent conversation ${child.id} does not exist`);
757
793
  const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` };
758
794
  const settled = await (await handle.submit(request, context)).wait(context);
759
795
  if (settled.status !== "done" || settled.type !== "input")
@@ -763,7 +799,11 @@ function createSubagent(options) {
763
799
  const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
764
800
  const result2 = {
765
801
  content: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
766
- details: { conversationId: child }
802
+ details: {
803
+ conversationId: child.id,
804
+ ...child.model === undefined ? {} : { model: child.model },
805
+ ...child.startedAt === undefined ? {} : { startedAt: child.startedAt }
806
+ }
767
807
  };
768
808
  return result2;
769
809
  }
@@ -773,9 +813,9 @@ function createSubagent(options) {
773
813
  }
774
814
 
775
815
  // apps/host/src/extensions/todo.ts
776
- 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";
777
817
  import { Type as Type5 } from "typebox";
778
- var TodoDoc = defineDoc2({
818
+ var TodoDoc = defineDoc3({
779
819
  kind: "todo.list",
780
820
  version: 1,
781
821
  scope: "conversation",
@@ -818,7 +858,7 @@ import { basename, join as join3, resolve } from "node:path";
818
858
  import { clampThinkingLevel as clampThinkingLevel2 } from "@earendil-works/pi-ai";
819
859
  import {
820
860
  AgentDoc as AgentDoc2,
821
- defineDoc as defineDoc3
861
+ defineDoc as defineDoc4
822
862
  } from "@earendil-works/pi-durable";
823
863
 
824
864
  // apps/host/src/checkout.ts
@@ -849,27 +889,6 @@ async function gitBase(projectPath) {
849
889
  return;
850
890
  return { repo, subdir: relative(repo, projectPath), base };
851
891
  }
852
- var SNAPSHOT_IDENTITY = {
853
- GIT_AUTHOR_NAME: "PiNomad",
854
- GIT_AUTHOR_EMAIL: "pinomad@localhost",
855
- GIT_COMMITTER_NAME: "PiNomad",
856
- GIT_COMMITTER_EMAIL: "pinomad@localhost"
857
- };
858
- async function snapshotOf(sourceDir) {
859
- const indexFile = join2(tmpdir(), `pinomad-index-${randomBytes(8).toString("hex")}`);
860
- const env = { GIT_INDEX_FILE: indexFile };
861
- try {
862
- const { stdout: head } = await git(sourceDir, ["rev-parse", "--verify", "HEAD"], env);
863
- const base = head.trim();
864
- await git(sourceDir, ["read-tree", base], env);
865
- await git(sourceDir, ["add", "-A"], env);
866
- const { stdout: tree } = await git(sourceDir, ["write-tree"], env);
867
- const { stdout: commit } = await git(sourceDir, ["commit-tree", tree.trim(), "-p", base, "-m", "pinomad fork snapshot"], { ...env, ...SNAPSHOT_IDENTITY });
868
- return { base, snapshot: commit.trim() };
869
- } finally {
870
- await rm(indexFile, { force: true });
871
- }
872
- }
873
892
  var repoLocks = new Map;
874
893
  function locked(repo, work) {
875
894
  const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
@@ -933,7 +952,7 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
933
952
  }
934
953
 
935
954
  // apps/host/src/organization.ts
936
- var IndexDoc = defineDoc3({
955
+ var IndexDoc = defineDoc4({
937
956
  kind: "pinomad.index",
938
957
  version: 1,
939
958
  scope: "session",
@@ -1057,21 +1076,42 @@ async function archive(harness, id, archived, context) {
1057
1076
  entry.archived = archived;
1058
1077
  }, context);
1059
1078
  }
1060
- 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) {
1061
1094
  const agent = await input.read.snapshot(AgentDoc2, input.conversationId, context);
1062
1095
  const cwd = agent?.cwd;
1063
1096
  if (cwd === undefined)
1064
1097
  return;
1065
- const index = await input.read.snapshot(IndexDoc, context);
1066
- const record = checkoutAt(index?.checkouts, cwd);
1067
- if (record === undefined)
1068
- 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);
1069
1103
  return {
1070
- branch: record.branch,
1071
- base: record.base,
1072
- projectPath: join3(record.repo, record.subdir),
1073
- repo: record.repo,
1074
- 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
+ }
1075
1115
  };
1076
1116
  }
1077
1117
  async function cleanupArchivedWorktrees(harness, rootId, context) {
@@ -1410,15 +1450,43 @@ import lockfile from "proper-lockfile";
1410
1450
  // apps/host/src/gateway.ts
1411
1451
  import { createServer } from "node:http";
1412
1452
  import { networkInterfaces } from "node:os";
1413
- import { join as join4, relative as relative2, sep as sep3 } from "node:path";
1453
+ import { join as join4, sep as sep3 } from "node:path";
1414
1454
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1415
1455
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1416
1456
  import {
1417
- AgentDoc as AgentDoc3
1457
+ AgentDoc as AgentDoc3,
1458
+ LiveDoc
1418
1459
  } from "@earendil-works/pi-durable";
1419
1460
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1420
1461
  import { Value as Value2 } from "typebox/value";
1421
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
+
1422
1490
  // apps/host/src/relay-link.ts
1423
1491
  import { WebSocket } from "ws";
1424
1492
  function relayWsBase(origin) {
@@ -1567,8 +1635,20 @@ function startRelayLink(options) {
1567
1635
 
1568
1636
  // apps/host/src/gateway.ts
1569
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";
1570
1650
  async function startGateway(options) {
1571
- const conversations = await ConversationList.open(options.harness);
1651
+ const conversations = await ConversationList.open(options.harness, options.docs);
1572
1652
  const http = createServer((request, response) => {
1573
1653
  serveWebClient(request, response, options.webRoot).catch(() => {
1574
1654
  if (!response.headersSent)
@@ -1606,22 +1686,35 @@ async function startGateway(options) {
1606
1686
  },
1607
1687
  createPairing: () => {
1608
1688
  const spec = options.secure;
1609
- if (spec === undefined || remote === undefined && relay === undefined)
1689
+ if (spec === undefined)
1610
1690
  return;
1611
1691
  const { secret, expiresAt } = spec.offers.create();
1612
1692
  const pair = `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret}`;
1613
1693
  if (relay !== undefined) {
1614
- const target = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1615
- 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 };
1616
1696
  }
1617
- 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 };
1618
1701
  }
1619
1702
  });
1620
1703
  clients.add(client);
1621
1704
  };
1622
1705
  server.on("connection", (socket, request) => {
1623
1706
  socket.on("error", () => socket.terminate());
1624
- 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");
1625
1718
  const origin = request.headers.origin;
1626
1719
  if (token !== options.token || origin !== undefined && !browserOrigins.includes(origin)) {
1627
1720
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
@@ -1807,36 +1900,131 @@ async function startRemote(options, register) {
1807
1900
 
1808
1901
  class ConversationList {
1809
1902
  #listeners = new Set;
1810
- #list;
1903
+ #questionTokens;
1904
+ #questionKinds;
1905
+ #records = new Map;
1906
+ #facts = new Map;
1907
+ #index = new Map;
1908
+ #list = [];
1811
1909
  #unsubscribe = () => {};
1812
1910
  #notifying = false;
1813
- constructor(list) {
1814
- 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));
1815
1914
  }
1816
- static async open(harness) {
1915
+ static async open(harness, docs) {
1916
+ const list = new ConversationList(docs);
1817
1917
  const summaries = [];
1818
1918
  let cursor;
1819
1919
  do {
1820
1920
  const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context);
1821
- for (const record of page.items)
1822
- 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
+ }
1823
1928
  cursor = page.next;
1824
1929
  } while (cursor !== undefined);
1825
- const list = new ConversationList(summaries);
1826
- list.#unsubscribe = harness.subscribeCommits((publication) => {
1827
- let next = list.#list;
1828
- for (const change of publication.changes) {
1829
- if (change.type === "conversation") {
1830
- next = [...next, summaryOf(change.value)];
1831
- } else if (change.type === "entry" && change.value.kind === "pi.user") {
1832
- const id = change.value.conversationId;
1833
- 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);
1834
2001
  }
1835
2002
  }
1836
- if (next !== list.#list)
1837
- list.#set(next);
1838
- });
1839
- 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;
1840
2028
  }
1841
2029
  get value() {
1842
2030
  return this.#list;
@@ -1861,14 +2049,29 @@ class ConversationList {
1861
2049
  this.#listeners.clear();
1862
2050
  }
1863
2051
  }
1864
- function summaryOf(record) {
2052
+ function summaryOf(record, facts) {
1865
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
+ };
1866
2060
  return {
1867
2061
  id: record.id,
1868
2062
  kind: record.owner !== undefined ? "subagent" : record.parent !== undefined ? "fork" : "conversation",
1869
- ...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 }
1870
2070
  };
1871
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
+ }
1872
2075
  function titleOf(entry) {
1873
2076
  const message = entry?.model?.[0];
1874
2077
  if (message?.role !== "user")
@@ -1876,16 +2079,40 @@ function titleOf(entry) {
1876
2079
  const text = typeof message.content === "string" ? message.content : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
1877
2080
  return { title: text.replace(/\s+/g, " ").trim() };
1878
2081
  }
1879
- async function firstInput(harness, id) {
2082
+ async function historyFacts(harness, id) {
1880
2083
  const conversation = await harness.conversation(id, context);
1881
2084
  let first;
2085
+ let newest;
1882
2086
  let cursor;
1883
2087
  do {
1884
2088
  const page = await conversation.entries({}, 256, cursor, context);
1885
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"));
1886
2091
  cursor = page.next;
1887
2092
  } while (cursor !== undefined);
1888
- 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;
1889
2116
  }
1890
2117
 
1891
2118
  class GatewayClient {
@@ -1904,24 +2131,38 @@ class GatewayClient {
1904
2131
  this.devicePublicKey = hooks.devicePublicKey;
1905
2132
  connection.handle({
1906
2133
  message: (text) => {
1907
- let frame;
2134
+ let parsed;
1908
2135
  try {
1909
- frame = JSON.parse(text);
2136
+ parsed = parseClientFrame(JSON.parse(text));
1910
2137
  } catch {
1911
- connection.close(1008, "Invalid client frame");
2138
+ connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
1912
2139
  return;
1913
2140
  }
1914
- if (!isClientFrame(frame)) {
1915
- connection.close(1008, "Invalid client frame");
1916
- 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;
1917
2158
  }
1918
- this.#receive(frame).catch(() => connection.close(1011, "Gateway request failed"));
1919
2159
  },
1920
2160
  closed: () => hooks.closed(this)
1921
2161
  });
1922
2162
  this.send({
1923
2163
  type: "hello",
1924
- protocol: PROTOCOL_VERSION,
2164
+ protocol: PROTOCOL,
2165
+ ...packagedVersion === undefined ? {} : { hostVersion: packagedVersion },
1925
2166
  session: options.session,
1926
2167
  models: options.modelSummaries(),
1927
2168
  defaults: options.defaults,
@@ -2175,15 +2416,34 @@ class GatewayClient {
2175
2416
  }
2176
2417
  case "fork": {
2177
2418
  const { entryId, removeTools = [] } = args;
2178
- const agent = await conversation.agent(context);
2179
- const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2180
- const init = await this.#forkCheckout(agent.cwd);
2181
- const fork = await conversation.fork(Number(entryId), {
2182
- ownership: { kind: "ownerless" },
2183
- ...remove.length === 0 ? {} : { agent: { tools: { remove } } },
2184
- ...init === undefined ? {} : { init }
2185
- }, context);
2186
- 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
+ });
2187
2447
  }
2188
2448
  case "answer": {
2189
2449
  const { kind, requestId, answers } = args;
@@ -2267,41 +2527,6 @@ class GatewayClient {
2267
2527
  return { available: false, reason: "Not in a git repository" };
2268
2528
  return { available: true, ...await changesOf(base.repo, base.base) };
2269
2529
  }
2270
- async#forkCheckout(cwd) {
2271
- if (cwd === undefined)
2272
- return;
2273
- const index = await this.#options.harness.snapshot(IndexDoc, context);
2274
- const existing = checkoutAt(index?.checkouts, cwd);
2275
- let source;
2276
- if (existing !== undefined) {
2277
- source = { repo: existing.repo, dir: existing.path, subdir: relative2(existing.path, cwd) };
2278
- } else {
2279
- if (cwd.startsWith(this.#chatsPrefix))
2280
- return;
2281
- const base2 = await gitBase(cwd);
2282
- if (base2 === undefined)
2283
- return;
2284
- source = { repo: base2.repo, dir: base2.repo, subdir: base2.subdir };
2285
- }
2286
- const { base, snapshot } = await snapshotOf(source.dir);
2287
- const suffix = branchSuffix();
2288
- return async (tx, id) => {
2289
- const path = worktreePath(this.#options.dataDir, id);
2290
- const doc = await tx.doc(IndexDoc);
2291
- if (doc.checkouts === undefined)
2292
- doc.checkouts = [];
2293
- doc.checkouts.push({
2294
- conversationId: id,
2295
- path,
2296
- repo: source.repo,
2297
- subdir: source.subdir,
2298
- branch: worktreeBranch(id, suffix),
2299
- base,
2300
- snapshot
2301
- });
2302
- (await tx.doc(AgentDoc3, id)).cwd = join4(path, source.subdir);
2303
- };
2304
- }
2305
2530
  async#agent(id) {
2306
2531
  return await this.#options.harness.snapshot(AgentDoc3, id, context) ?? {};
2307
2532
  }
@@ -2346,7 +2571,14 @@ async function openHost(options) {
2346
2571
  const reports = [];
2347
2572
  let report = (error) => void reports.push(error);
2348
2573
  const registry = createRegistry();
2349
- 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 ?? [];
2350
2582
  for (const { extension } of extensions)
2351
2583
  registry.install(extension);
2352
2584
  mcp = options.mcpConfig === undefined ? undefined : startMcp({ configPath: options.mcpConfig, registry });
@@ -2385,9 +2617,8 @@ async function openHost(options) {
2385
2617
  await ensureDevices(harness, context2);
2386
2618
  for (const path of options.projects ?? [])
2387
2619
  await addProject(harness, path, context2);
2388
- const remoteAccess = options.remote !== undefined || options.relay !== undefined;
2389
- const hostKey = remoteAccess ? await loadHostKey(options.dataDir) : undefined;
2390
- const offers = remoteAccess ? pairingOffers(options.remote?.pairingTtlMs) : undefined;
2620
+ const hostKey = await loadHostKey(options.dataDir);
2621
+ const offers = pairingOffers(options.remote?.pairingTtlMs);
2391
2622
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
2392
2623
  const defaults = initialModel === undefined ? {} : {
2393
2624
  model: { provider: initialModel.provider, modelId: initialModel.modelId },
@@ -2407,24 +2638,22 @@ async function openHost(options) {
2407
2638
  toolPresentations: Object.assign({}, ...extensions.map((extension) => extension.tools ?? {})),
2408
2639
  ...mcp === undefined ? {} : { mcp: mcp.status },
2409
2640
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
2410
- ...hostKey === undefined || offers === undefined ? {} : {
2411
- secure: {
2412
- hostKey,
2413
- offers,
2414
- ...options.remote?.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: options.remote.handshakeTimeoutMs },
2415
- ...options.remote === undefined ? {} : {
2416
- lan: {
2417
- port: options.remote.port,
2418
- ...options.remote.publicUrl === undefined ? {} : { publicUrl: options.remote.publicUrl }
2419
- }
2420
- },
2421
- ...options.relay === undefined || relayKey === undefined ? {} : {
2422
- relay: {
2423
- origin: options.relay.origin,
2424
- signingKey: relayKey,
2425
- ...options.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: options.relay.reconnectDelayMs },
2426
- ...options.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: options.relay.pingIntervalMs }
2427
- }
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 }
2428
2657
  }
2429
2658
  }
2430
2659
  }
@@ -2597,7 +2826,10 @@ async function main(argv) {
2597
2826
  const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
2598
2827
  const extensionsFor = (models, summaries) => (host2) => {
2599
2828
  const others = [
2600
- createContext({ agentsHome: join6(homedir2(), ".agents"), checkout: checkoutInfo }),
2829
+ createContext({
2830
+ agentsHome: join6(homedir2(), ".agents"),
2831
+ checkout: (input, context3) => checkoutInfo(host2.harness(), input, context3)
2832
+ }),
2601
2833
  coding,
2602
2834
  todo,
2603
2835
  question,