pinomad 0.5.0 → 0.7.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 (31) hide show
  1. package/README.md +2 -2
  2. package/dist/{chunk-f0v3sht0.js → chunk-07769pqx.js} +10 -8
  3. package/dist/{chunk-ebfaf3k2.js → chunk-0xh6cy9v.js} +1 -1
  4. package/dist/{chunk-aqj4wg33.js → chunk-12kaxggg.js} +29 -12
  5. package/dist/{chunk-d4wdt5qs.js → chunk-1nyzr0mg.js} +2282 -1027
  6. package/dist/chunk-1t58fh0k.js +16 -0
  7. package/dist/{chunk-4by4x5c4.js → chunk-6ysezfy5.js} +44 -14
  8. package/dist/{chunk-kmbh44dt.js → chunk-7qb9tq54.js} +42 -6
  9. package/dist/{chunk-gqr7ahv4.js → chunk-8yme2wep.js} +2 -2
  10. package/dist/chunk-b1h374jr.js +159 -0
  11. package/dist/{chunk-hsp12cjt.js → chunk-e4ahjbmz.js} +212 -50
  12. package/dist/{chunk-9d59jjmt.js → chunk-fbkd73zr.js} +36 -10
  13. package/dist/chunk-kzbzacz9.js +12 -0
  14. package/dist/{chunk-zttf1hvh.js → chunk-m4c5gvce.js} +4 -3
  15. package/dist/{chunk-cjfmfyjb.js → chunk-ngf1zatm.js} +7 -6
  16. package/dist/{chunk-1d9sppj0.js → chunk-rtwxjb90.js} +1 -1
  17. package/dist/{chunk-m4q9wafw.js → chunk-sj7rnvh4.js} +118 -6
  18. package/dist/chunk-sz3rzk55.js +79 -0
  19. package/dist/{chunk-26d04z02.js → chunk-ywmm63ks.js} +7 -3
  20. package/dist/chunk-znhn8af7.js +33 -0
  21. package/dist/pinomad.js +36 -12
  22. package/package.json +1 -1
  23. package/web/assets/index-C0rwAo0H.css +1 -0
  24. package/web/assets/index-DGee2kNU.js +394 -0
  25. package/web/index.html +2 -2
  26. package/dist/chunk-5ng86fmy.js +0 -14
  27. package/dist/chunk-crv3dqzp.js +0 -63
  28. package/web/assets/index-BkF0-im5.css +0 -1
  29. package/web/assets/index-D4lgm3Y4.js +0 -383
  30. package/web/assets/montserrat-latin-ext-wght-normal-BsZE-iaG.woff2 +0 -0
  31. package/web/assets/montserrat-latin-wght-normal-l_AIctKy.woff2 +0 -0
@@ -2,6 +2,13 @@
2
2
  import {
3
3
  serveWebClient
4
4
  } from "./chunk-r6w5aqkw.js";
5
+ import {
6
+ Published,
7
+ constantValue,
8
+ openHostSettings,
9
+ sameList,
10
+ settingsDefaults
11
+ } from "./chunk-b1h374jr.js";
5
12
  import {
6
13
  DevicesDoc,
7
14
  RevocationsDoc,
@@ -14,7 +21,7 @@ import {
14
21
  readServerBinding,
15
22
  readServerConfig,
16
23
  resolveRelayOrigin
17
- } from "./chunk-ebfaf3k2.js";
24
+ } from "./chunk-0xh6cy9v.js";
18
25
  import {
19
26
  SECURE_PROLOGUE,
20
27
  SecureSession,
@@ -24,30 +31,41 @@ import {
24
31
  respondIK,
25
32
  startRelayLink,
26
33
  toBase64Url
27
- } from "./chunk-kmbh44dt.js";
34
+ } from "./chunk-7qb9tq54.js";
28
35
  import {
29
36
  packagedVersion,
30
37
  webRoot
31
- } from "./chunk-1d9sppj0.js";
38
+ } from "./chunk-rtwxjb90.js";
32
39
  import {
33
- HOST_SETTING_KEYS,
40
+ ATTACHMENT_TTL_MS,
34
41
  INVALID_FRAME_CLOSE_CODE,
42
+ MAX_ATTACHMENTS_PER_MESSAGE,
43
+ MAX_ATTACHMENT_NAME_LENGTH,
44
+ MAX_PENDING_ATTACHMENTS,
45
+ MAX_PENDING_ATTACHMENT_BYTES,
35
46
  PROTOCOL,
36
- THINKING_LEVELS,
37
47
  UNAUTHORIZED_CLOSE_CODE,
38
- isQueueMode,
39
- isThinkingLevel,
48
+ attachmentBeginProblem,
49
+ attachmentBlock,
50
+ attachmentChunkProblem,
51
+ base64DecodedLength,
52
+ isAttachmentId,
40
53
  organize,
54
+ parseAttachmentBlock,
41
55
  parseClientFrame
42
- } from "./chunk-hsp12cjt.js";
56
+ } from "./chunk-e4ahjbmz.js";
57
+ import {
58
+ THINKING_LEVELS,
59
+ isThinkingLevel
60
+ } from "./chunk-znhn8af7.js";
43
61
  import {
44
62
  DEFAULT_DATA_DIR
45
- } from "./chunk-cjfmfyjb.js";
63
+ } from "./chunk-ngf1zatm.js";
46
64
  import"./chunk-df32avw6.js";
47
65
 
48
66
  // apps/host/src/main.ts
49
67
  import { homedir as homedir3 } from "node:os";
50
- import { join as join9, resolve as resolve4 } from "node:path";
68
+ import { join as join12, resolve as resolve7 } from "node:path";
51
69
  import { parseArgs } from "node:util";
52
70
  import { createModels } from "@earendil-works/pi-ai/models";
53
71
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
@@ -181,7 +199,7 @@ function result(questions, resolution) {
181
199
  var askUserQuestion = defineTool({
182
200
  name: "ask_user_question",
183
201
  callers: ["model"],
184
- 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.",
202
+ 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.",
185
203
  parameters: Type2.Object({
186
204
  questions: Type2.Array(QuestionItemSchema, { minItems: 1, maxItems: 4 })
187
205
  }),
@@ -252,393 +270,194 @@ function createCodemode() {
252
270
  }
253
271
 
254
272
  // apps/host/src/extensions/context.ts
255
- import { dirname, join, sep } from "node:path";
273
+ import { dirname as dirname2, sep as sep4 } from "node:path";
256
274
  import { defineExtension as defineExtension2, section } from "@earendil-works/pi-durable";
257
- import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
258
- 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.";
259
- 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.";
260
- function createContext(options) {
261
- const { agentsHome } = options;
262
- const resolveCheckout = (input, context) => options.checkout?.(input, context);
263
- return {
264
- extension: defineExtension2({
265
- name: "context",
266
- sections: [
267
- section("preamble", () => PREAMBLE, { tag: false }),
268
- section("environment", async (input, context) => {
269
- const cwd = input.agent.cwd;
270
- if (cwd === undefined)
271
- return;
272
- const resolved = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
273
- const checkout = resolved?.checkout;
274
- const forkLine = resolved?.fork === true && checkout?.own !== true ? [FORK_SHARED_DIR] : [];
275
- if (checkout === undefined) {
276
- return [`Working directory: ${cwd}`, ...forkLine].join(`
277
- `);
278
- }
279
- return [
280
- `Working directory: ${cwd}`,
281
- 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)}.`,
282
- `The user's project directory is ${checkout.projectPath} and must not be modified.`,
283
- "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed.",
284
- ...forkLine
285
- ].join(`
286
- `);
287
- }),
288
- 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 }),
289
- section("project_context", async (input, context) => {
290
- const cwd = input.agent.cwd;
291
- if (cwd === undefined)
292
- return;
293
- const checkout = (await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context))?.checkout;
294
- let files;
295
- if (checkout === undefined) {
296
- files = loadProjectContextFiles({ cwd, agentDir: agentsHome });
297
- } else {
298
- const seen = new Set;
299
- files = [
300
- ...loadProjectContextFiles({ cwd: dirname(checkout.repo), agentDir: agentsHome }),
301
- ...loadProjectContextFiles({ cwd, agentDir: agentsHome }).filter((file) => file.path.startsWith(checkout.worktreeRoot + sep))
302
- ].filter((file) => {
303
- if (seen.has(file.path))
304
- return false;
305
- seen.add(file.path);
306
- return true;
307
- });
308
- }
309
- return files.length === 0 ? undefined : files.map((file) => `## ${file.path}
275
+ import { formatSkillsForPrompt, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
310
276
 
311
- ${file.content}`).join(`
277
+ // apps/host/src/skills.ts
278
+ import { execFile as execFile2 } from "node:child_process";
279
+ import { existsSync } from "node:fs";
280
+ import { realpath as realpath4, stat as stat4 } from "node:fs/promises";
281
+ import { dirname, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
282
+ import { promisify as promisify2 } from "node:util";
283
+ import { AgentDoc as AgentDoc3 } from "@earendil-works/pi-durable";
284
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
312
285
 
313
- `);
314
- }),
315
- section("skills", (input) => {
316
- const cwd = input.agent.cwd;
317
- if (cwd === undefined)
318
- return;
319
- const { skills } = loadSkills({
320
- cwd,
321
- agentDir: agentsHome,
322
- includeDefaults: false,
323
- skillPaths: [join(cwd, ".agents", "skills"), join(agentsHome, "skills")]
324
- });
325
- const text = formatSkillsForPrompt(skills, "read");
326
- return text === "" ? undefined : text;
327
- }, { tag: false })
328
- ]
329
- })
330
- };
286
+ // apps/host/src/checkout.ts
287
+ import { execFile, spawn } from "node:child_process";
288
+ import { randomBytes } from "node:crypto";
289
+ import { realpath, rm, stat } from "node:fs/promises";
290
+ import { tmpdir } from "node:os";
291
+ import { join, relative, sep } from "node:path";
292
+ import { promisify } from "node:util";
293
+ var run = promisify(execFile);
294
+ var git = (dir, args, env) => run("git", ["-C", dir, ...args], env === undefined ? {} : { env: { ...process.env, ...env } });
295
+ function gitCapped(dir, args, maxBytes, env) {
296
+ return new Promise((resolve, reject) => {
297
+ const child = spawn("git", ["-C", dir, ...args], env === undefined ? {} : { env: { ...process.env, ...env } });
298
+ const chunks = [];
299
+ let size = 0;
300
+ let killed = false;
301
+ let stderr = "";
302
+ child.stdout.on("data", (chunk) => {
303
+ if (killed)
304
+ return;
305
+ chunks.push(chunk);
306
+ size += chunk.length;
307
+ if (size > maxBytes) {
308
+ killed = true;
309
+ child.kill();
310
+ child.stdout.destroy();
311
+ child.stderr.destroy();
312
+ }
313
+ });
314
+ child.stderr.on("data", (chunk) => {
315
+ if (stderr.length < 8192)
316
+ stderr += chunk.toString("utf8");
317
+ });
318
+ child.on("error", reject);
319
+ child.on("close", (code) => {
320
+ const stdout = Buffer.concat(chunks).toString("utf8");
321
+ if (killed)
322
+ resolve({ stdout, truncated: true });
323
+ else if (code === 0)
324
+ resolve({ stdout, truncated: false });
325
+ else
326
+ reject(Object.assign(new Error(`Command failed: git ${args.join(" ")}
327
+ ${stderr}`), { code, stderr }));
328
+ });
329
+ });
330
+ }
331
+ var worktreeRoot = (dataDir) => join(dataDir, "worktrees");
332
+ var worktreePath = (dataDir, id) => join(worktreeRoot(dataDir), String(id));
333
+ var worktreeBranch = (id, suffix) => `pinomad/${id}-${suffix}`;
334
+ var branchSuffix = () => randomBytes(2).toString("hex");
335
+ var checkoutAt = (checkouts, cwd) => checkouts?.find((checkout) => cwd === checkout.path || cwd.startsWith(checkout.path + sep));
336
+ async function gitBase(projectPath) {
337
+ const top = await git(projectPath, ["rev-parse", "--show-toplevel"]).then(({ stdout }) => stdout.trim(), () => {
338
+ return;
339
+ });
340
+ if (top === undefined)
341
+ return;
342
+ const repo = await realpath(top).catch(() => top);
343
+ const base = await git(repo, ["rev-parse", "--verify", "HEAD"]).then(({ stdout }) => stdout.trim(), () => {
344
+ return;
345
+ });
346
+ if (base === undefined)
347
+ return;
348
+ return { repo, subdir: relative(repo, projectPath), base };
349
+ }
350
+ async function branchOf(dir) {
351
+ const ref = await git(dir, ["symbolic-ref", "--quiet", "HEAD"]).then(({ stdout }) => stdout.trim(), () => {
352
+ return;
353
+ });
354
+ const prefix = "refs/heads/";
355
+ if (ref === undefined || !ref.startsWith(prefix))
356
+ return;
357
+ return ref.slice(prefix.length) || undefined;
358
+ }
359
+ var repoLocks = new Map;
360
+ function locked(repo, work) {
361
+ const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
362
+ const settled = next.then(() => {}, () => {});
363
+ repoLocks.set(repo, settled);
364
+ settled.then(() => {
365
+ if (repoLocks.get(repo) === settled)
366
+ repoLocks.delete(repo);
367
+ });
368
+ return next;
369
+ }
370
+ var worktreeExists = async (path) => stat(join(path, ".git")).then(() => true, () => false);
371
+ var worktreeSettled = (record) => locked(record.repo, () => worktreeExists(record.path));
372
+ async function ensureWorktree(record) {
373
+ await locked(record.repo, async () => {
374
+ if (await worktreeExists(record.path))
375
+ return;
376
+ await git(record.repo, ["worktree", "prune"]);
377
+ const branchExists = await git(record.repo, ["show-ref", "--verify", "--quiet", `refs/heads/${record.branch}`]).then(() => true, () => false);
378
+ if (branchExists) {
379
+ await git(record.repo, ["worktree", "add", record.path, record.branch]);
380
+ return;
381
+ }
382
+ await git(record.repo, ["worktree", "add", "-b", record.branch, record.path, record.base]);
383
+ if (record.snapshot !== undefined) {
384
+ await git(record.path, ["read-tree", "-u", "--reset", record.snapshot]);
385
+ await git(record.path, ["reset", "-q"]);
386
+ }
387
+ });
388
+ }
389
+ async function removeWorktree(record) {
390
+ return await locked(record.repo, async () => {
391
+ if (!await worktreeExists(record.path))
392
+ return;
393
+ return await git(record.repo, ["worktree", "remove", record.path]).then(() => {
394
+ return;
395
+ }, (error) => reasonOf(error));
396
+ });
397
+ }
398
+ function reasonOf(error) {
399
+ const stderr = error?.stderr;
400
+ const text = typeof stderr === "string" && stderr.trim() !== "" ? stderr.trim() : error instanceof Error ? error.message : String(error);
401
+ return text.split(`
402
+ `).filter((line) => line.trim() !== "").at(-1) ?? text;
403
+ }
404
+ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
405
+ const indexFile = join(tmpdir(), `pinomad-index-${randomBytes(8).toString("hex")}`);
406
+ const env = { GIT_INDEX_FILE: indexFile };
407
+ try {
408
+ await git(dir, ["read-tree", "HEAD"], env);
409
+ await git(dir, ["add", "-A"], env);
410
+ const { stdout, truncated } = await gitCapped(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], maxBytes, env);
411
+ if (!truncated && Buffer.byteLength(stdout) <= maxBytes)
412
+ return { base, patch: stdout, truncated: false };
413
+ const cut = Buffer.from(stdout, "utf8").subarray(0, maxBytes);
414
+ const patch = cut.subarray(0, cut.lastIndexOf(10) + 1).toString("utf8");
415
+ return { base, patch, truncated: true };
416
+ } finally {
417
+ await rm(indexFile, { force: true });
418
+ }
331
419
  }
332
420
 
333
- // apps/host/src/extensions/subagent.ts
421
+ // apps/host/src/files.ts
422
+ import { spawn as spawn2 } from "node:child_process";
423
+ import { opendir, realpath as realpath3, stat as stat3 } from "node:fs/promises";
424
+ import { join as join3, resolve as resolve2, sep as sep2 } from "node:path";
425
+ import { AgentDoc as AgentDoc2 } from "@earendil-works/pi-durable";
426
+
427
+ // apps/host/src/organization.ts
428
+ import { mkdir, realpath as realpath2, stat as stat2 } from "node:fs/promises";
429
+ import { basename, join as join2, resolve } from "node:path";
334
430
  import { clampThinkingLevel } from "@earendil-works/pi-ai";
335
431
  import {
336
432
  AgentDoc,
337
- AssistantEntry,
338
- configure,
339
- defineExtension as defineExtension3,
340
- defineTool as defineTool2,
341
- section as section2
433
+ defineDoc as defineDoc3,
434
+ configure
342
435
  } from "@earendil-works/pi-durable";
343
- import { Type as Type3 } from "typebox";
344
436
 
345
- // apps/host/src/subagent-doc.ts
437
+ // apps/host/src/message-content.ts
438
+ function messageContent(text, images, attachmentText) {
439
+ if ((images === undefined || images.length === 0) && attachmentText === undefined)
440
+ return text;
441
+ return [
442
+ ...text === "" ? [] : [{ type: "text", text }],
443
+ ...attachmentText === undefined ? [] : [{ type: "text", text: attachmentText }],
444
+ ...(images ?? []).map(({ data, mimeType }) => ({ type: "image", data, mimeType }))
445
+ ];
446
+ }
447
+
448
+ // apps/host/src/role-doc.ts
346
449
  import { defineDoc as defineDoc2 } from "@earendil-works/pi-durable";
347
- var SubagentDoc = defineDoc2({
348
- kind: "pinomad.subagent",
450
+ var RoleDoc = defineDoc2({
451
+ kind: "pinomad.role",
349
452
  version: 1,
350
453
  scope: "conversation",
351
- history: "latest",
352
- fork: "initial",
454
+ history: "rewindable",
455
+ fork: "asOf",
353
456
  initial: () => ({})
354
457
  });
355
458
 
356
- // apps/host/src/extensions/subagent.ts
357
- 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.";
358
- var THINKING_LEVELS2 = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
359
- function createSubagent(options) {
360
- const available = () => options.modelSummaries().map((model) => `${model.provider}/${model.modelId}`).join(", ");
361
- const roles = options.roles ?? (() => []);
362
- const roleNamed = (name) => {
363
- const found = roles().find((each) => each.name === name);
364
- if (found !== undefined)
365
- return found;
366
- const defined = roles();
367
- throw new Error(defined.length === 0 ? "No named agents are defined" : `Unknown agent "${name}". Available agents: ${defined.map((each) => each.name).join(", ")}`);
368
- };
369
- const tool = defineTool2({
370
- name: "subagent",
371
- callers: ["model"],
372
- description: "Delegate a self-contained task to a subagent that runs in its own conversation and returns its final answer. " + "The subagent starts with none of this conversation's context, so put everything it needs in `task`. " + "It works in the same files as you: subagents called in the same turn run concurrently, so give parallel subagents read-only work or non-overlapping files. " + "It cannot ask the user questions. `model` and `thinkingLevel` override what it inherits from you; `role` delegates to a named agent; a model or thinking level it pins cannot be overridden; `description` only labels the call. " + `Available models: ${available()}.`,
373
- parameters: Type3.Object({
374
- task: Type3.String({
375
- minLength: 1,
376
- description: "Complete, self-contained instructions. The subagent sees none of this conversation."
377
- }),
378
- role: Type3.Optional(Type3.String({
379
- description: "Optional named agent to delegate to, from the Named agents list in your instructions. Its prompt applies, plus any model or thinking level it pins."
380
- })),
381
- description: Type3.Optional(Type3.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
382
- model: Type3.Optional(Type3.String({ description: "Optional `provider/modelId` to use instead of yours." })),
383
- thinkingLevel: Type3.Optional(Type3.Union(THINKING_LEVELS2.map((level) => Type3.Literal(level)), { description: "Optional thinking level instead of yours." }))
384
- }),
385
- replay: "safe",
386
- execute: async (args, api, context) => {
387
- const child = await api.commit(async (tx) => {
388
- const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
389
- if (existing !== undefined) {
390
- const doc2 = await tx.doc(SubagentDoc, existing.id);
391
- return {
392
- id: existing.id,
393
- ...doc2?.model === undefined ? {} : { model: doc2.model },
394
- ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
395
- };
396
- }
397
- const role = args.role === undefined ? undefined : roleNamed(args.role);
398
- if (role?.model !== undefined && args.model !== undefined) {
399
- throw new Error(`Agent "${role.name}" pins model ${role.model}; omit \`model\``);
400
- }
401
- if (role?.thinkingLevel !== undefined && args.thinkingLevel !== undefined) {
402
- throw new Error(`Agent "${role.name}" pins thinking level ${role.thinkingLevel}; omit \`thinkingLevel\``);
403
- }
404
- const modelString = role?.model ?? args.model;
405
- const levelString = role?.thinkingLevel ?? args.thinkingLevel;
406
- let model;
407
- let thinkingLevel;
408
- let override;
409
- if (modelString !== undefined) {
410
- const slash = modelString.indexOf("/");
411
- const provider = slash === -1 ? "" : modelString.slice(0, slash);
412
- const modelId = slash === -1 ? "" : modelString.slice(slash + 1);
413
- override = provider === "" || modelId === "" ? undefined : options.models.getModel(provider, modelId);
414
- const listed = options.modelSummaries().some((each) => each.provider === provider && each.modelId === modelId);
415
- if (override === undefined || !listed) {
416
- throw new Error(role === undefined ? `Unknown model "${modelString}". Available models: ${available()}` : `Agent "${role.name}" uses model "${modelString}", which is not available. Available models: ${available()}`);
417
- }
418
- model = { provider, modelId };
419
- }
420
- if (levelString !== undefined || override !== undefined) {
421
- const parent = await tx.doc(AgentDoc, api.conversationId);
422
- const effective = override ?? (parent.model === undefined ? undefined : options.models.getModel(parent.model.provider, parent.model.modelId));
423
- if (levelString !== undefined) {
424
- thinkingLevel = effective === undefined ? levelString : clampThinkingLevel(effective, levelString);
425
- } else if (parent.thinkingLevel !== undefined) {
426
- thinkingLevel = clampThinkingLevel(override, parent.thinkingLevel);
427
- }
428
- }
429
- const change = {
430
- extensions: { remove: [extension, ...options.exclude] },
431
- instructions: role === undefined || role.prompt === "" ? INSTRUCTIONS : `${INSTRUCTIONS}
432
-
433
- ${role.prompt}`,
434
- ...model === undefined ? {} : { model },
435
- ...thinkingLevel === undefined ? {} : { thinkingLevel }
436
- };
437
- const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
438
- await configure(tx, created.id, change);
439
- const childAgent = await tx.doc(AgentDoc, created.id);
440
- const ref = childAgent.model ?? model ?? (await tx.doc(AgentDoc, api.conversationId)).model;
441
- const startedAt = Date.now();
442
- const doc = await tx.doc(SubagentDoc, created.id);
443
- doc.startedAt = startedAt;
444
- if (ref !== undefined)
445
- doc.model = `${ref.provider}/${ref.modelId}`;
446
- if (role !== undefined)
447
- doc.role = role.name;
448
- const label = args.description?.trim();
449
- if (label !== undefined && label !== "")
450
- doc.label = label;
451
- return { id: created.id, ...ref === undefined ? {} : { model: doc.model }, startedAt };
452
- }, context);
453
- await api.details({ conversationId: child.id, ...child.model === undefined ? {} : { model: child.model }, ...child.startedAt === undefined ? {} : { startedAt: child.startedAt } }, context);
454
- const handle = await api.conversation(child.id, context);
455
- if (handle === undefined)
456
- throw new Error(`Subagent conversation ${child.id} does not exist`);
457
- const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` };
458
- const settled = await (await handle.submit(request, context)).wait(context);
459
- if (settled.status !== "done" || settled.type !== "input")
460
- throw new Error(`Subagent failed: ${settled.status}`);
461
- const answer = await api.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
462
- const message = answer?.model?.[0];
463
- const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
464
- const result2 = {
465
- output: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
466
- details: {
467
- conversationId: child.id,
468
- ...child.model === undefined ? {} : { model: child.model },
469
- ...child.startedAt === undefined ? {} : { startedAt: child.startedAt }
470
- }
471
- };
472
- return result2;
473
- }
474
- });
475
- const extension = defineExtension3({
476
- name: "subagent",
477
- tools: [tool],
478
- sections: [
479
- section2("agent_roles", () => {
480
- const defined = roles();
481
- if (defined.length === 0)
482
- return;
483
- return [
484
- "Named agents: pass one as `role` to `subagent` to delegate to it. When the user writes @name for one of these agents, delegate the relevant work to that agent with `subagent`, putting everything it needs in `task`.",
485
- ...defined.map((each) => `- ${each.name}: ${each.description.replace(/\s+/g, " ")}`)
486
- ].join(`
487
- `);
488
- })
489
- ]
490
- });
491
- return { extension };
492
- }
493
-
494
- // apps/host/src/extensions/todo.ts
495
- import { defineDoc as defineDoc3, defineExtension as defineExtension4, defineTool as defineTool3 } from "@earendil-works/pi-durable";
496
- import { Type as Type4 } from "typebox";
497
- var TodoDoc = defineDoc3({
498
- kind: "todo.list",
499
- version: 1,
500
- scope: "conversation",
501
- history: "rewindable",
502
- fork: "asOf",
503
- initial: () => ({ items: [] })
504
- });
505
- var todoWrite = defineTool3({
506
- name: "todo_write",
507
- description: "Replace the todo list shown to the user with the given items.",
508
- parameters: Type4.Object({ items: Type4.Array(TodoItemSchema) }),
509
- replay: "safe",
510
- execute: async (args, api, context) => {
511
- await api.commit(async (tx) => {
512
- const doc = await tx.doc(TodoDoc, api.conversationId);
513
- doc.items = args.items.map((item) => ({ ...item }));
514
- }, context);
515
- return { output: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
516
- }
517
- });
518
- var todo = {
519
- extension: defineExtension4({ name: "todo", tools: [todoWrite] }),
520
- docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
521
- };
522
-
523
- // apps/host/src/host.ts
524
- import { randomBytes as randomBytes3 } from "node:crypto";
525
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
526
- import { basename as basename2, join as join8, sep as sep5 } from "node:path";
527
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
528
- import { createRegistry, Harness } from "@earendil-works/pi-durable";
529
- import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
530
-
531
- // apps/host/src/organization.ts
532
- import { mkdir, realpath as realpath2, stat as stat2 } from "node:fs/promises";
533
- import { basename, join as join3, resolve } from "node:path";
534
- import { clampThinkingLevel as clampThinkingLevel2 } from "@earendil-works/pi-ai";
535
- import {
536
- AgentDoc as AgentDoc2,
537
- defineDoc as defineDoc4
538
- } from "@earendil-works/pi-durable";
539
-
540
- // apps/host/src/checkout.ts
541
- import { execFile } from "node:child_process";
542
- import { randomBytes } from "node:crypto";
543
- import { realpath, rm, stat } from "node:fs/promises";
544
- import { tmpdir } from "node:os";
545
- import { join as join2, relative, sep as sep2 } from "node:path";
546
- import { promisify } from "node:util";
547
- var run = promisify(execFile);
548
- var git = (dir, args, env) => run("git", ["-C", dir, ...args], env === undefined ? {} : { env: { ...process.env, ...env } });
549
- var worktreeRoot = (dataDir) => join2(dataDir, "worktrees");
550
- var worktreePath = (dataDir, id) => join2(worktreeRoot(dataDir), String(id));
551
- var worktreeBranch = (id, suffix) => `pinomad/${id}-${suffix}`;
552
- var branchSuffix = () => randomBytes(2).toString("hex");
553
- var checkoutAt = (checkouts, cwd) => checkouts?.find((checkout) => cwd === checkout.path || cwd.startsWith(checkout.path + sep2));
554
- async function gitBase(projectPath) {
555
- const top = await git(projectPath, ["rev-parse", "--show-toplevel"]).then(({ stdout }) => stdout.trim(), () => {
556
- return;
557
- });
558
- if (top === undefined)
559
- return;
560
- const repo = await realpath(top).catch(() => top);
561
- const base = await git(repo, ["rev-parse", "--verify", "HEAD"]).then(({ stdout }) => stdout.trim(), () => {
562
- return;
563
- });
564
- if (base === undefined)
565
- return;
566
- return { repo, subdir: relative(repo, projectPath), base };
567
- }
568
- var repoLocks = new Map;
569
- function locked(repo, work) {
570
- const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
571
- const settled = next.then(() => {}, () => {});
572
- repoLocks.set(repo, settled);
573
- settled.then(() => {
574
- if (repoLocks.get(repo) === settled)
575
- repoLocks.delete(repo);
576
- });
577
- return next;
578
- }
579
- var worktreeExists = async (path) => stat(join2(path, ".git")).then(() => true, () => false);
580
- async function ensureWorktree(record) {
581
- await locked(record.repo, async () => {
582
- if (await worktreeExists(record.path))
583
- return;
584
- await git(record.repo, ["worktree", "prune"]);
585
- const branchExists = await git(record.repo, ["show-ref", "--verify", "--quiet", `refs/heads/${record.branch}`]).then(() => true, () => false);
586
- if (branchExists) {
587
- await git(record.repo, ["worktree", "add", record.path, record.branch]);
588
- return;
589
- }
590
- await git(record.repo, ["worktree", "add", "-b", record.branch, record.path, record.base]);
591
- if (record.snapshot !== undefined) {
592
- await git(record.path, ["read-tree", "-u", "--reset", record.snapshot]);
593
- await git(record.path, ["reset", "-q"]);
594
- }
595
- });
596
- }
597
- async function removeWorktree(record) {
598
- return await locked(record.repo, async () => {
599
- if (!await worktreeExists(record.path))
600
- return;
601
- return await git(record.repo, ["worktree", "remove", record.path]).then(() => {
602
- return;
603
- }, (error) => reasonOf(error));
604
- });
605
- }
606
- function reasonOf(error) {
607
- const stderr = error?.stderr;
608
- const text = typeof stderr === "string" && stderr.trim() !== "" ? stderr.trim() : error instanceof Error ? error.message : String(error);
609
- return text.split(`
610
- `).filter((line) => line.trim() !== "").at(-1) ?? text;
611
- }
612
- async function changesOf(dir, base, maxBytes = 1024 * 1024) {
613
- const indexFile = join2(tmpdir(), `pinomad-index-${randomBytes(8).toString("hex")}`);
614
- const env = { GIT_INDEX_FILE: indexFile };
615
- try {
616
- await git(dir, ["read-tree", "HEAD"], env);
617
- await git(dir, ["add", "-A"], env);
618
- const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], env);
619
- if (Buffer.byteLength(stdout) <= maxBytes)
620
- return { base, patch: stdout, truncated: false };
621
- const cut = stdout.slice(0, maxBytes);
622
- const patch = cut.slice(0, cut.lastIndexOf(`
623
- `) + 1);
624
- return { base, patch, truncated: true };
625
- } finally {
626
- await rm(indexFile, { force: true });
627
- }
628
- }
629
-
630
- // apps/host/src/message-content.ts
631
- function messageContent(text, images) {
632
- if (images === undefined || images.length === 0)
633
- return text;
634
- return [
635
- ...text === "" ? [] : [{ type: "text", text }],
636
- ...images.map(({ data, mimeType }) => ({ type: "image", data, mimeType }))
637
- ];
638
- }
639
-
640
459
  // apps/host/src/organization.ts
641
- var IndexDoc = defineDoc4({
460
+ var IndexDoc = defineDoc3({
642
461
  kind: "pinomad.index",
643
462
  version: 1,
644
463
  scope: "session",
@@ -705,7 +524,7 @@ async function createConversation(harness, dataDir, input, defaults, models, mod
705
524
  if (prior !== undefined) {
706
525
  const existing = await harness.conversation(prior.id, context);
707
526
  if (existing !== undefined) {
708
- await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
527
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images, input.attachmentText), whenBusy: "followUp", requestId: input.requestId }, context);
709
528
  }
710
529
  return prior.id;
711
530
  }
@@ -726,7 +545,7 @@ async function createConversation(harness, dataDir, input, defaults, models, mod
726
545
  throw new Error(`Model ${modelRef.provider}/${modelRef.modelId} is not available${suffix2}`);
727
546
  }
728
547
  }
729
- const thinkingLevel = model === undefined ? input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel2(model, input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel ?? "off");
548
+ const thinkingLevel = model === undefined ? input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel(model, input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel ?? "off");
730
549
  const home = input.home;
731
550
  const projectPath = home.kind === "project" ? await normalizePath(home.path).catch(() => home.path) : undefined;
732
551
  const repo = home.kind === "project" && input.checkout !== "project" && projectPath !== undefined ? await gitBase(projectPath) : undefined;
@@ -756,6 +575,7 @@ async function createConversation(harness, dataDir, input, defaults, models, mod
756
575
  requestId: input.requestId,
757
576
  ...role === undefined ? {} : { role: role.name }
758
577
  });
578
+ (await tx.doc(RoleDoc, id)).name = role?.name ?? null;
759
579
  if (repo !== undefined && suffix !== undefined) {
760
580
  const path = worktreePath(dataDir, id);
761
581
  if (doc.checkouts === undefined)
@@ -768,26 +588,59 @@ async function createConversation(harness, dataDir, input, defaults, models, mod
768
588
  branch: worktreeBranch(id, suffix),
769
589
  base: repo.base
770
590
  });
771
- (await tx.doc(AgentDoc2, id)).cwd = join3(path, repo.subdir);
591
+ (await tx.doc(AgentDoc, id)).cwd = join2(path, repo.subdir);
772
592
  } else if (home.kind === "chat") {
773
- (await tx.doc(AgentDoc2, id)).cwd = join3(dataDir, "chats", String(id));
593
+ (await tx.doc(AgentDoc, id)).cwd = join2(dataDir, "chats", String(id));
774
594
  }
775
595
  }
776
596
  }, context);
777
597
  if (home.kind === "chat")
778
- await mkdir(join3(dataDir, "chats", String(conversation.id)), { recursive: true });
779
- await conversation.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
598
+ await mkdir(join2(dataDir, "chats", String(conversation.id)), { recursive: true });
599
+ await conversation.submit({ type: "input", content: messageContent(input.text, input.images, input.attachmentText), whenBusy: "followUp", requestId: input.requestId }, context);
780
600
  return conversation.id;
781
601
  } catch (error) {
782
602
  if (!(error instanceof DuplicateRequest))
783
603
  throw error;
784
604
  const existing = await harness.conversation(error.conversationId, context);
785
605
  if (existing !== undefined) {
786
- await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
606
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images, input.attachmentText), whenBusy: "followUp", requestId: input.requestId }, context);
787
607
  }
788
608
  return error.conversationId;
789
609
  }
790
610
  }
611
+ async function setAgent(harness, input, models, modelSummaries, agents, context) {
612
+ await harness.commit(async (tx) => {
613
+ const record = await tx.conversation(input.conversationId);
614
+ if (record === undefined)
615
+ throw new Error(`Conversation ${input.conversationId} does not exist`);
616
+ if (record.owner !== undefined)
617
+ throw new Error("Subagent conversations cannot switch agent");
618
+ const role = input.agent === null ? undefined : requireRole(agents, input.agent);
619
+ const state = await tx.doc(AgentDoc, input.conversationId);
620
+ let change;
621
+ if (role === undefined) {
622
+ change = { instructions: null };
623
+ } else {
624
+ const modelRef = roleModelRef(role) ?? state.model;
625
+ const listed = modelSummaries().map((model2) => `${model2.provider}/${model2.modelId}`).join(", ");
626
+ const suffix = listed === "" ? "" : `. Available models: ${listed}`;
627
+ if (modelRef === undefined) {
628
+ throw new Error(`Agent "${role.name}" has no model: the role pins none and the conversation has none${suffix}`);
629
+ }
630
+ const model = models.getModel(modelRef.provider, modelRef.modelId);
631
+ if (model === undefined || !modelSummaries().some((each) => each.provider === modelRef.provider && each.modelId === modelRef.modelId)) {
632
+ throw new Error(`Model ${modelRef.provider}/${modelRef.modelId} is not available${suffix}`);
633
+ }
634
+ change = {
635
+ model: modelRef,
636
+ thinkingLevel: clampThinkingLevel(model, role.thinkingLevel ?? state.thinkingLevel ?? "off"),
637
+ instructions: role.prompt === "" ? null : role.prompt
638
+ };
639
+ }
640
+ await configure(tx, input.conversationId, change);
641
+ (await tx.doc(RoleDoc, input.conversationId)).name = role?.name ?? null;
642
+ }, context);
643
+ }
791
644
  async function archive(harness, id, archived, context) {
792
645
  await harness.commit(async (tx) => {
793
646
  const doc = await tx.doc(IndexDoc);
@@ -812,7 +665,7 @@ async function forkFlagOf(harness, id, context) {
812
665
  return record.parent !== undefined;
813
666
  }
814
667
  async function checkoutInfo(harness, input, context) {
815
- const agent = await input.read.snapshot(AgentDoc2, input.conversationId, context);
668
+ const agent = await input.read.snapshot(AgentDoc, input.conversationId, context);
816
669
  const cwd = agent?.cwd;
817
670
  if (cwd === undefined)
818
671
  return;
@@ -827,7 +680,7 @@ async function checkoutInfo(harness, input, context) {
827
680
  checkout: {
828
681
  branch: checkout.branch,
829
682
  base: checkout.base,
830
- projectPath: join3(checkout.repo, checkout.subdir),
683
+ projectPath: join2(checkout.repo, checkout.subdir),
831
684
  repo: checkout.repo,
832
685
  worktreeRoot: checkout.path,
833
686
  own: checkout.conversationId === input.conversationId
@@ -875,48 +728,752 @@ async function cleanupArchivedWorktrees(harness, rootId, context) {
875
728
  return kept;
876
729
  }
877
730
 
878
- // apps/host/src/mcp.ts
879
- import { readFile } from "node:fs/promises";
880
- import { homedir } from "node:os";
881
- import { isAbsolute, resolve as resolve2 } from "node:path";
882
- import {
883
- McpAuthRequiredError,
884
- McpClient,
885
- McpHttpError,
886
- StdioTransport,
887
- StreamableHttpTransport,
888
- toLlmContent
889
- } from "@earendil-works/pi-mcp";
890
- import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
891
- import { Type as Type5 } from "typebox";
892
- var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
893
- var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
894
- function expand(value) {
895
- let missing;
896
- const expanded = value.replace(ENV_VAR, (_all, name) => {
897
- const found = process.env[name];
898
- if (found === undefined)
899
- missing = name;
900
- return found ?? "";
901
- });
902
- return missing === undefined ? expanded : { missing };
903
- }
904
- function expandAll(values, field, error) {
905
- if (values === undefined)
906
- return;
907
- if (!isObject(values)) {
908
- error(`"${field}" must be an object of strings`);
909
- return;
910
- }
911
- const expanded = {};
912
- for (const [key, value] of Object.entries(values)) {
913
- if (typeof value !== "string") {
914
- error(`"${field}.${key}" must be a string`);
915
- return;
731
+ // apps/host/src/files.ts
732
+ var CACHE_TTL_MS = 3000;
733
+ var MAX_CACHED_ROOTS = 64;
734
+ var MAX_CONCURRENT_SCANS = 4;
735
+ var SCAN_BUDGET_MS = 5000;
736
+ var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
737
+ var MAX_WALK_ENTRIES = 20000;
738
+ var MAX_RESULTS = 50;
739
+ var UNUSABLE_PATH = /[\s`\x00-\x1f\x7f-\x9f]/;
740
+ function createCandidateLister(enumerate, options = {}) {
741
+ const now = options.now ?? Date.now;
742
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
743
+ const maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_SCANS;
744
+ const maxCached = options.maxCached ?? MAX_CACHED_ROOTS;
745
+ const cache = new Map;
746
+ const inflight = new Map;
747
+ let running = 0;
748
+ const waiting = [];
749
+ return (root, mode) => {
750
+ const key = `${mode}${root}`;
751
+ const hit = cache.get(key);
752
+ if (hit !== undefined) {
753
+ if (hit.expiresAt > now())
754
+ return Promise.resolve(hit.value);
755
+ cache.delete(key);
916
756
  }
917
- const result2 = expand(value);
918
- if (typeof result2 !== "string") {
919
- error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
757
+ const pending = inflight.get(key);
758
+ if (pending !== undefined)
759
+ return pending;
760
+ const scan = (async () => {
761
+ if (running >= maxConcurrent)
762
+ await new Promise((release) => waiting.push(release));
763
+ running += 1;
764
+ try {
765
+ return await enumerate(root, mode);
766
+ } finally {
767
+ running -= 1;
768
+ waiting.shift()?.();
769
+ }
770
+ })();
771
+ inflight.set(key, scan);
772
+ scan.then((value) => {
773
+ const at = now();
774
+ for (const [cached, entry] of cache) {
775
+ if (entry.expiresAt <= at)
776
+ cache.delete(cached);
777
+ }
778
+ while (cache.size >= maxCached)
779
+ cache.delete(cache.keys().next().value);
780
+ cache.set(key, { value, expiresAt: at + ttlMs });
781
+ }, () => {});
782
+ return scan.finally(() => inflight.delete(key));
783
+ };
784
+ }
785
+ function gitLines(cwd, args, limits) {
786
+ return new Promise((resolvePromise, rejectPromise) => {
787
+ const child = spawn2("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "pipe"] });
788
+ const chunks = [];
789
+ let bytes = 0;
790
+ let truncated = false;
791
+ let settled = false;
792
+ const kill = (markTruncated) => {
793
+ truncated ||= markTruncated;
794
+ child.kill("SIGKILL");
795
+ };
796
+ const timer = setTimeout(() => kill(true), Math.max(0, limits.deadline - limits.now()));
797
+ const finish = (error) => {
798
+ if (settled)
799
+ return;
800
+ settled = true;
801
+ clearTimeout(timer);
802
+ if (error !== undefined) {
803
+ rejectPromise(error);
804
+ return;
805
+ }
806
+ const output = Buffer.concat(chunks).toString("utf8");
807
+ const parts = output.split("\x00");
808
+ if (truncated)
809
+ parts.pop();
810
+ resolvePromise({ lines: parts.filter((part) => part !== ""), truncated });
811
+ };
812
+ child.stdout.on("data", (chunk) => {
813
+ bytes += chunk.length;
814
+ if (bytes > limits.maxBytes) {
815
+ kill(true);
816
+ return;
817
+ }
818
+ chunks.push(chunk);
819
+ if (limits.now() > limits.deadline)
820
+ kill(true);
821
+ });
822
+ child.on("error", (error) => finish(error));
823
+ child.stderr.resume();
824
+ child.on("close", (code) => {
825
+ if (code === 0 || truncated) {
826
+ finish(undefined);
827
+ } else {
828
+ finish(new Error(`git ${args.join(" ")} exited with code ${code}`));
829
+ }
830
+ });
831
+ });
832
+ }
833
+ async function inWorkTree(root) {
834
+ return await new Promise((resolvePromise) => {
835
+ const child = spawn2("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] });
836
+ let out = "";
837
+ child.stdout.on("data", (chunk) => out += chunk.toString("utf8"));
838
+ child.on("error", () => resolvePromise(false));
839
+ child.on("close", (code) => resolvePromise(code === 0 && out.trim() === "true"));
840
+ });
841
+ }
842
+ async function hasHead(root) {
843
+ return await new Promise((resolvePromise) => {
844
+ const child = spawn2("git", ["-C", root, "rev-parse", "--verify", "-q", "HEAD"], { stdio: ["ignore", "ignore", "ignore"] });
845
+ child.on("error", () => resolvePromise(false));
846
+ child.on("close", (code) => resolvePromise(code === 0));
847
+ });
848
+ }
849
+ var STAGED_RECORD = /^([0-7]{6}) [0-9a-f]{40,64} [0-7]\t/;
850
+ async function gitWorkingFiles(root, limits) {
851
+ const listing = await gitLines(root, ["ls-files", "-z", "-s", "--cached", "--others", "--exclude-standard"], limits);
852
+ const deleted = await gitLines(root, ["ls-files", "-z", "--deleted"], limits);
853
+ const gone = new Set(deleted.lines);
854
+ const files = new Set;
855
+ for (const line of listing.lines) {
856
+ const staged = STAGED_RECORD.exec(line);
857
+ const path = staged === null ? line : line.slice(staged[0].length);
858
+ if (staged !== null && staged[1] === "160000")
859
+ continue;
860
+ if (path === "" || path.endsWith("/") || gone.has(path))
861
+ continue;
862
+ files.add(path);
863
+ }
864
+ return { files: [...files], truncated: listing.truncated || deleted.truncated };
865
+ }
866
+ async function gitHeadFiles(root, limits) {
867
+ const listing = await gitLines(root, ["ls-tree", "-r", "-z", "HEAD"], limits);
868
+ const files = [];
869
+ for (const line of listing.lines) {
870
+ const tab = line.indexOf("\t");
871
+ if (tab === -1)
872
+ continue;
873
+ const kind = line.slice(0, tab).split(" ")[1];
874
+ if (kind === "blob")
875
+ files.push(line.slice(tab + 1));
876
+ }
877
+ return { files, truncated: listing.truncated };
878
+ }
879
+ async function walkFiles(root, limits) {
880
+ const files = [];
881
+ let entries = 0;
882
+ let truncated = false;
883
+ const dirs = [{ dir: root, prefix: "" }];
884
+ while (dirs.length > 0) {
885
+ if (limits.now() > limits.deadline) {
886
+ truncated = true;
887
+ break;
888
+ }
889
+ const { dir, prefix } = dirs.pop();
890
+ let handle;
891
+ try {
892
+ handle = await opendir(dir);
893
+ } catch (error) {
894
+ if (prefix === "")
895
+ throw error;
896
+ truncated = true;
897
+ continue;
898
+ }
899
+ for await (const entry of handle) {
900
+ if (entries++ >= limits.maxEntries || limits.now() > limits.deadline) {
901
+ truncated = true;
902
+ break;
903
+ }
904
+ if (entry.isDirectory()) {
905
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
906
+ continue;
907
+ dirs.push({ dir: join3(dir, entry.name), prefix: `${prefix}${entry.name}/` });
908
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
909
+ files.push(`${prefix}${entry.name}`);
910
+ }
911
+ }
912
+ if (truncated)
913
+ break;
914
+ }
915
+ return { files, truncated };
916
+ }
917
+ async function enumerateRoot(root, mode, limits = {}) {
918
+ const now = limits.now ?? Date.now;
919
+ const bounded = {
920
+ now,
921
+ deadline: now() + (limits.budgetMs ?? SCAN_BUDGET_MS),
922
+ maxBytes: limits.maxBytes ?? MAX_OUTPUT_BYTES,
923
+ maxEntries: limits.maxEntries ?? MAX_WALK_ENTRIES
924
+ };
925
+ if (!await inWorkTree(root)) {
926
+ return walkFiles(root, bounded);
927
+ }
928
+ if (mode === "head" && !await hasHead(root)) {
929
+ return walkFiles(root, bounded);
930
+ }
931
+ return mode === "head" ? gitHeadFiles(root, bounded) : gitWorkingFiles(root, bounded);
932
+ }
933
+ function matchFiles(candidates, query, max = MAX_RESULTS) {
934
+ const usable = candidates.filter((path) => !UNUSABLE_PATH.test(path));
935
+ if (query === "") {
936
+ const sorted = [...usable].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
937
+ return { files: sorted.slice(0, max), truncated: sorted.length > max };
938
+ }
939
+ const needle = query.toLowerCase();
940
+ const tiers = [[], [], []];
941
+ for (const path of usable) {
942
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
943
+ const lower = path.toLowerCase();
944
+ if (base.startsWith(needle))
945
+ tiers[0].push(path);
946
+ else if (base.includes(needle))
947
+ tiers[1].push(path);
948
+ else if (lower.includes(needle))
949
+ tiers[2].push(path);
950
+ }
951
+ const shorter = (a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
952
+ const matched = [...tiers[0].sort(shorter), ...tiers[1].sort(shorter), ...tiers[2].sort(shorter)];
953
+ return { files: matched.slice(0, max), truncated: matched.length > max };
954
+ }
955
+ async function listFiles(harness, dataDir, enumerate, scope, query, context) {
956
+ let root;
957
+ let mode = "live";
958
+ if (scope.kind === "conversation") {
959
+ const agent = await harness.snapshot(AgentDoc2, scope.conversationId, context);
960
+ if (agent === undefined)
961
+ throw new Error(`Conversation ${scope.conversationId} does not exist`);
962
+ const cwd = agent.cwd;
963
+ if (cwd === undefined)
964
+ throw new Error(`Conversation ${scope.conversationId} has no checkout`);
965
+ if (cwd.startsWith(join3(dataDir, "chats") + sep2))
966
+ return { files: [], truncated: false };
967
+ const record = checkoutAt((await harness.snapshot(IndexDoc, context))?.checkouts, cwd);
968
+ const ready = record === undefined || await worktreeSettled(record);
969
+ const info = ready ? await stat3(cwd).catch(() => {
970
+ return;
971
+ }) : undefined;
972
+ if (info === undefined || !info.isDirectory()) {
973
+ throw new Error(`Conversation ${scope.conversationId}'s checkout is unavailable: ${cwd}`);
974
+ }
975
+ root = cwd;
976
+ } else {
977
+ const normalized = await realpath3(resolve2(scope.path)).catch(() => resolve2(scope.path));
978
+ const index = await harness.snapshot(IndexDoc, context);
979
+ if (!index?.projects.some((project) => project.path === normalized)) {
980
+ throw new Error(`Unknown project: ${scope.path}`);
981
+ }
982
+ const info = await stat3(normalized).catch(() => {
983
+ return;
984
+ });
985
+ if (info === undefined || !info.isDirectory()) {
986
+ throw new Error(`Not a directory: ${normalized}`);
987
+ }
988
+ root = normalized;
989
+ mode = scope.checkout === "worktree" ? "head" : "live";
990
+ }
991
+ const candidates = await enumerate(root, mode);
992
+ const matched = matchFiles(candidates.files, query);
993
+ return { files: matched.files, truncated: matched.truncated || candidates.truncated };
994
+ }
995
+
996
+ // apps/host/src/skills.ts
997
+ var exec = promisify2(execFile2);
998
+ function invocableSkillName(name) {
999
+ return name.length <= 64 && /^[a-z0-9-]+$/.test(name) && !name.startsWith("-") && !name.endsWith("-") && !name.includes("--");
1000
+ }
1001
+ var reportedDiagnostics = new Set;
1002
+ function reportDiagnostics(diagnostics) {
1003
+ for (const diagnostic of diagnostics) {
1004
+ const key = `${diagnostic.path ?? ""}\x00${diagnostic.message}`;
1005
+ if (reportedDiagnostics.has(key))
1006
+ continue;
1007
+ reportedDiagnostics.add(key);
1008
+ console.error(JSON.stringify({
1009
+ event: "skill",
1010
+ level: diagnostic.type === "error" ? "error" : "warning",
1011
+ ...diagnostic.path === undefined ? {} : { path: diagnostic.path },
1012
+ message: diagnostic.message
1013
+ }));
1014
+ }
1015
+ }
1016
+ function entrySkills(dir, source, agentsHome) {
1017
+ if (!existsSync(dir))
1018
+ return [];
1019
+ const { skills, diagnostics } = loadSkills({ cwd: dir, agentDir: agentsHome, includeDefaults: false, skillPaths: [dir] });
1020
+ reportDiagnostics(diagnostics);
1021
+ return skills.map((skill) => ({ skill, source }));
1022
+ }
1023
+ function projectSkillLayers(root) {
1024
+ const start = resolve3(root);
1025
+ const dirs = [start];
1026
+ let dir = start;
1027
+ for (;; ) {
1028
+ if (existsSync(join4(dir, ".git")))
1029
+ return { dirs, gitRoot: dir };
1030
+ const parent = dirname(dir);
1031
+ if (parent === dir)
1032
+ return { dirs: [start] };
1033
+ dir = parent;
1034
+ dirs.push(dir);
1035
+ }
1036
+ }
1037
+ function projectSkills(dirs, agentsHome) {
1038
+ return dirs.flatMap((dir) => entrySkills(join4(dir, ".agents", "skills"), "project", agentsHome));
1039
+ }
1040
+ function mergeSkills(entries) {
1041
+ const seen = new Set;
1042
+ const merged = [];
1043
+ for (const entry of entries) {
1044
+ for (const scoped of entry) {
1045
+ if (seen.has(scoped.skill.name))
1046
+ continue;
1047
+ seen.add(scoped.skill.name);
1048
+ merged.push(scoped);
1049
+ }
1050
+ }
1051
+ return merged;
1052
+ }
1053
+ function scopedSkills(root, agentsHome) {
1054
+ const project = root === undefined ? [] : projectSkills(projectSkillLayers(root).dirs, agentsHome);
1055
+ return mergeSkills([project, entrySkills(join4(agentsHome, "skills"), "user", agentsHome)]);
1056
+ }
1057
+ async function committedSkillFiles(gitRoot, layers) {
1058
+ if (!await inWorkTree(gitRoot) || !await hasHead(gitRoot))
1059
+ return null;
1060
+ const pathspecs = layers.map((dir) => {
1061
+ const rel = relative2(gitRoot, dir).split(sep3).join("/");
1062
+ return rel === "" ? ".agents/skills" : `${rel}/.agents/skills`;
1063
+ });
1064
+ const { stdout } = await exec("git", ["-C", gitRoot, "ls-tree", "-r", "-z", "--full-name", "--name-only", "HEAD", "--", ...pathspecs], { maxBuffer: 4 * 1024 * 1024 });
1065
+ return new Set(stdout.split("\x00").filter((path) => path !== ""));
1066
+ }
1067
+ async function enumerateSkills(root, mode, agentsHome) {
1068
+ let project = [];
1069
+ if (root !== undefined) {
1070
+ const layers = projectSkillLayers(root);
1071
+ project = projectSkills(layers.dirs, agentsHome);
1072
+ const gitRoot = layers.gitRoot;
1073
+ if (mode === "head" && gitRoot !== undefined) {
1074
+ const committed = await committedSkillFiles(gitRoot, layers.dirs);
1075
+ if (committed !== null) {
1076
+ project = project.filter((scoped) => committed.has(relative2(gitRoot, scoped.skill.filePath).split(sep3).join("/")));
1077
+ }
1078
+ }
1079
+ }
1080
+ const merged = mergeSkills([project, entrySkills(join4(agentsHome, "skills"), "user", agentsHome)]);
1081
+ return merged.filter((scoped) => invocableSkillName(scoped.skill.name)).map(({ skill, source }) => ({
1082
+ name: skill.name,
1083
+ description: skill.description,
1084
+ source,
1085
+ filePath: skill.filePath,
1086
+ baseDir: skill.baseDir
1087
+ }));
1088
+ }
1089
+ var CACHE_TTL_MS2 = 3000;
1090
+ var MAX_CACHED_SCOPES = 64;
1091
+ function createSkillLister(enumerate, options = {}) {
1092
+ const now = options.now ?? Date.now;
1093
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS2;
1094
+ const maxCached = options.maxCached ?? MAX_CACHED_SCOPES;
1095
+ const cache = new Map;
1096
+ const inflight = new Map;
1097
+ return (root, mode) => {
1098
+ const key = `${mode}${root ?? ""}`;
1099
+ const hit = cache.get(key);
1100
+ if (hit !== undefined) {
1101
+ if (hit.expiresAt > now())
1102
+ return Promise.resolve(hit.value);
1103
+ cache.delete(key);
1104
+ }
1105
+ const pending = inflight.get(key);
1106
+ if (pending !== undefined)
1107
+ return pending;
1108
+ const scan = enumerate(root, mode);
1109
+ inflight.set(key, scan);
1110
+ scan.then((value) => {
1111
+ const at = now();
1112
+ for (const [cached, entry] of cache) {
1113
+ if (entry.expiresAt <= at)
1114
+ cache.delete(cached);
1115
+ }
1116
+ while (cache.size >= maxCached)
1117
+ cache.delete(cache.keys().next().value);
1118
+ cache.set(key, { value, expiresAt: at + ttlMs });
1119
+ }, () => {});
1120
+ return scan.finally(() => inflight.delete(key));
1121
+ };
1122
+ }
1123
+ async function scopeSkills(harness, lister, scope, context) {
1124
+ if (scope.kind === "chat")
1125
+ return lister(undefined, "live");
1126
+ let root;
1127
+ let mode = "live";
1128
+ if (scope.kind === "conversation") {
1129
+ const agent = await harness.snapshot(AgentDoc3, scope.conversationId, context);
1130
+ if (agent === undefined)
1131
+ throw new Error(`Conversation ${scope.conversationId} does not exist`);
1132
+ const cwd = agent.cwd;
1133
+ if (cwd === undefined)
1134
+ throw new Error(`Conversation ${scope.conversationId} has no checkout`);
1135
+ const record = checkoutAt((await harness.snapshot(IndexDoc, context))?.checkouts, cwd);
1136
+ const ready = record === undefined || await worktreeSettled(record);
1137
+ const info = ready ? await stat4(cwd).catch(() => {
1138
+ return;
1139
+ }) : undefined;
1140
+ if (info === undefined || !info.isDirectory()) {
1141
+ throw new Error(`Conversation ${scope.conversationId}'s checkout is unavailable: ${cwd}`);
1142
+ }
1143
+ root = cwd;
1144
+ } else {
1145
+ const normalized = await realpath4(resolve3(scope.path)).catch(() => resolve3(scope.path));
1146
+ const index = await harness.snapshot(IndexDoc, context);
1147
+ if (!index?.projects.some((project) => project.path === normalized)) {
1148
+ throw new Error(`Unknown project: ${scope.path}`);
1149
+ }
1150
+ const info = await stat4(normalized).catch(() => {
1151
+ return;
1152
+ });
1153
+ if (info === undefined || !info.isDirectory()) {
1154
+ throw new Error(`Not a directory: ${normalized}`);
1155
+ }
1156
+ root = normalized;
1157
+ mode = scope.checkout === "worktree" ? "head" : "live";
1158
+ }
1159
+ return lister(root, mode);
1160
+ }
1161
+ async function listSkills(harness, lister, scope, context) {
1162
+ const skills = await scopeSkills(harness, lister, scope, context);
1163
+ return { skills: skills.map(({ name, description, source }) => ({ name, description, source })) };
1164
+ }
1165
+
1166
+ // apps/host/src/extensions/context.ts
1167
+ 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.";
1168
+ 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.";
1169
+ function createContext(options) {
1170
+ const { agentsHome } = options;
1171
+ const resolveCheckout = (input, context) => options.checkout?.(input, context);
1172
+ return {
1173
+ extension: defineExtension2({
1174
+ name: "context",
1175
+ sections: [
1176
+ section("preamble", () => PREAMBLE, { tag: false }),
1177
+ section("environment", async (input, context) => {
1178
+ const cwd = input.agent.cwd;
1179
+ if (cwd === undefined)
1180
+ return;
1181
+ const resolved = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
1182
+ const checkout = resolved?.checkout;
1183
+ const forkLine = resolved?.fork === true && checkout?.own !== true ? [FORK_SHARED_DIR] : [];
1184
+ if (checkout === undefined) {
1185
+ return [`Working directory: ${cwd}`, ...forkLine].join(`
1186
+ `);
1187
+ }
1188
+ return [
1189
+ `Working directory: ${cwd}`,
1190
+ 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)}.`,
1191
+ `The user's project directory is ${checkout.projectPath} and must not be modified.`,
1192
+ "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed.",
1193
+ ...forkLine
1194
+ ].join(`
1195
+ `);
1196
+ }),
1197
+ 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 }),
1198
+ section("project_context", async (input, context) => {
1199
+ const cwd = input.agent.cwd;
1200
+ if (cwd === undefined)
1201
+ return;
1202
+ const checkout = (await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context))?.checkout;
1203
+ let files;
1204
+ if (checkout === undefined) {
1205
+ files = loadProjectContextFiles({ cwd, agentDir: agentsHome });
1206
+ } else {
1207
+ const seen = new Set;
1208
+ files = [
1209
+ ...loadProjectContextFiles({ cwd: dirname2(checkout.repo), agentDir: agentsHome }),
1210
+ ...loadProjectContextFiles({ cwd, agentDir: agentsHome }).filter((file) => file.path.startsWith(checkout.worktreeRoot + sep4))
1211
+ ].filter((file) => {
1212
+ if (seen.has(file.path))
1213
+ return false;
1214
+ seen.add(file.path);
1215
+ return true;
1216
+ });
1217
+ }
1218
+ return files.length === 0 ? undefined : files.map((file) => `## ${file.path}
1219
+
1220
+ ${file.content}`).join(`
1221
+
1222
+ `);
1223
+ }),
1224
+ section("skills", (input) => {
1225
+ const cwd = input.agent.cwd;
1226
+ if (cwd === undefined)
1227
+ return;
1228
+ const skills = scopedSkills(cwd, agentsHome).map((scoped) => scoped.skill);
1229
+ const text = formatSkillsForPrompt(skills, "read");
1230
+ return text === "" ? undefined : text;
1231
+ }, { tag: false })
1232
+ ]
1233
+ })
1234
+ };
1235
+ }
1236
+
1237
+ // apps/host/src/extensions/subagent.ts
1238
+ import { clampThinkingLevel as clampThinkingLevel2 } from "@earendil-works/pi-ai";
1239
+ import {
1240
+ AgentDoc as AgentDoc4,
1241
+ AssistantEntry,
1242
+ configure as configure2,
1243
+ defineExtension as defineExtension3,
1244
+ defineTool as defineTool2,
1245
+ section as section2
1246
+ } from "@earendil-works/pi-durable";
1247
+ import { Type as Type3 } from "typebox";
1248
+
1249
+ // apps/host/src/subagent-doc.ts
1250
+ import { defineDoc as defineDoc4 } from "@earendil-works/pi-durable";
1251
+ var SubagentDoc = defineDoc4({
1252
+ kind: "pinomad.subagent",
1253
+ version: 1,
1254
+ scope: "conversation",
1255
+ history: "latest",
1256
+ fork: "initial",
1257
+ initial: () => ({})
1258
+ });
1259
+
1260
+ // apps/host/src/extensions/subagent.ts
1261
+ 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.";
1262
+ var THINKING_LEVELS2 = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
1263
+ function createSubagent(options) {
1264
+ const available = () => options.modelSummaries().map((model) => `${model.provider}/${model.modelId}`).join(", ");
1265
+ const roles = options.roles ?? (() => []);
1266
+ const roleNamed = (name) => {
1267
+ const found = roles().find((each) => each.name === name);
1268
+ if (found !== undefined)
1269
+ return found;
1270
+ const defined = roles();
1271
+ throw new Error(defined.length === 0 ? "No named agents are defined" : `Unknown agent "${name}". Available agents: ${defined.map((each) => each.name).join(", ")}`);
1272
+ };
1273
+ const tool = defineTool2({
1274
+ name: "subagent",
1275
+ callers: ["model"],
1276
+ description: "Delegate a self-contained task to a subagent that runs in its own conversation and returns its final answer. " + "The subagent starts with none of this conversation's context, so put everything it needs in `task`. " + "It works in the same files as you: subagents called in the same turn run concurrently, so give parallel subagents read-only work or non-overlapping files. " + "It cannot ask the user questions. `model` and `thinkingLevel` override what it inherits from you; `role` delegates to a named agent; a model or thinking level it pins cannot be overridden; `description` only labels the call. " + `Available models: ${available()}.`,
1277
+ parameters: Type3.Object({
1278
+ task: Type3.String({
1279
+ minLength: 1,
1280
+ description: "Complete, self-contained instructions. The subagent sees none of this conversation."
1281
+ }),
1282
+ role: Type3.Optional(Type3.String({
1283
+ description: "Optional named agent to delegate to, from the Named agents list in your instructions. Its prompt applies, plus any model or thinking level it pins."
1284
+ })),
1285
+ description: Type3.Optional(Type3.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
1286
+ model: Type3.Optional(Type3.String({ description: "Optional `provider/modelId` to use instead of yours." })),
1287
+ thinkingLevel: Type3.Optional(Type3.Union(THINKING_LEVELS2.map((level) => Type3.Literal(level)), { description: "Optional thinking level instead of yours." }))
1288
+ }),
1289
+ replay: "safe",
1290
+ execute: async (args, api, context) => {
1291
+ const child = await api.commit(async (tx) => {
1292
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
1293
+ if (existing !== undefined) {
1294
+ const doc2 = await tx.doc(SubagentDoc, existing.id);
1295
+ return {
1296
+ id: existing.id,
1297
+ ...doc2?.model === undefined ? {} : { model: doc2.model },
1298
+ ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
1299
+ };
1300
+ }
1301
+ const role = args.role === undefined ? undefined : roleNamed(args.role);
1302
+ if (role?.model !== undefined && args.model !== undefined) {
1303
+ throw new Error(`Agent "${role.name}" pins model ${role.model}; omit \`model\``);
1304
+ }
1305
+ if (role?.thinkingLevel !== undefined && args.thinkingLevel !== undefined) {
1306
+ throw new Error(`Agent "${role.name}" pins thinking level ${role.thinkingLevel}; omit \`thinkingLevel\``);
1307
+ }
1308
+ const modelString = role?.model ?? args.model;
1309
+ const levelString = role?.thinkingLevel ?? args.thinkingLevel;
1310
+ let model;
1311
+ let thinkingLevel;
1312
+ let override;
1313
+ if (modelString !== undefined) {
1314
+ const slash = modelString.indexOf("/");
1315
+ const provider = slash === -1 ? "" : modelString.slice(0, slash);
1316
+ const modelId = slash === -1 ? "" : modelString.slice(slash + 1);
1317
+ override = provider === "" || modelId === "" ? undefined : options.models.getModel(provider, modelId);
1318
+ const listed = options.modelSummaries().some((each) => each.provider === provider && each.modelId === modelId);
1319
+ if (override === undefined || !listed) {
1320
+ throw new Error(role === undefined ? `Unknown model "${modelString}". Available models: ${available()}` : `Agent "${role.name}" uses model "${modelString}", which is not available. Available models: ${available()}`);
1321
+ }
1322
+ model = { provider, modelId };
1323
+ }
1324
+ if (levelString !== undefined || override !== undefined) {
1325
+ const parent = await tx.doc(AgentDoc4, api.conversationId);
1326
+ const effective = override ?? (parent.model === undefined ? undefined : options.models.getModel(parent.model.provider, parent.model.modelId));
1327
+ if (levelString !== undefined) {
1328
+ thinkingLevel = effective === undefined ? levelString : clampThinkingLevel2(effective, levelString);
1329
+ } else if (parent.thinkingLevel !== undefined) {
1330
+ thinkingLevel = clampThinkingLevel2(override, parent.thinkingLevel);
1331
+ }
1332
+ }
1333
+ const change = {
1334
+ extensions: { remove: [extension, ...options.exclude] },
1335
+ instructions: role === undefined || role.prompt === "" ? INSTRUCTIONS : `${INSTRUCTIONS}
1336
+
1337
+ ${role.prompt}`,
1338
+ ...model === undefined ? {} : { model },
1339
+ ...thinkingLevel === undefined ? {} : { thinkingLevel }
1340
+ };
1341
+ const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
1342
+ await configure2(tx, created.id, change);
1343
+ const childAgent = await tx.doc(AgentDoc4, created.id);
1344
+ const ref = childAgent.model ?? model ?? (await tx.doc(AgentDoc4, api.conversationId)).model;
1345
+ const startedAt = Date.now();
1346
+ const doc = await tx.doc(SubagentDoc, created.id);
1347
+ doc.startedAt = startedAt;
1348
+ if (ref !== undefined)
1349
+ doc.model = `${ref.provider}/${ref.modelId}`;
1350
+ if (role !== undefined)
1351
+ doc.role = role.name;
1352
+ const label = args.description?.trim();
1353
+ if (label !== undefined && label !== "")
1354
+ doc.label = label;
1355
+ return { id: created.id, ...ref === undefined ? {} : { model: doc.model }, startedAt };
1356
+ }, context);
1357
+ await api.details({ conversationId: child.id, ...child.model === undefined ? {} : { model: child.model }, ...child.startedAt === undefined ? {} : { startedAt: child.startedAt } }, context);
1358
+ const handle = await api.conversation(child.id, context);
1359
+ if (handle === undefined)
1360
+ throw new Error(`Subagent conversation ${child.id} does not exist`);
1361
+ const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` };
1362
+ const settled = await (await handle.submit(request, context)).wait(context);
1363
+ if (settled.status !== "done" || settled.type !== "input")
1364
+ throw new Error(`Subagent failed: ${settled.status}`);
1365
+ const answer = await api.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
1366
+ const message = answer?.model?.[0];
1367
+ const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
1368
+ const result2 = {
1369
+ output: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
1370
+ details: {
1371
+ conversationId: child.id,
1372
+ ...child.model === undefined ? {} : { model: child.model },
1373
+ ...child.startedAt === undefined ? {} : { startedAt: child.startedAt }
1374
+ }
1375
+ };
1376
+ return result2;
1377
+ }
1378
+ });
1379
+ const extension = defineExtension3({
1380
+ name: "subagent",
1381
+ tools: [tool],
1382
+ sections: [
1383
+ section2("agent_roles", () => {
1384
+ const defined = roles();
1385
+ if (defined.length === 0)
1386
+ return;
1387
+ return [
1388
+ "Named agents: pass one as `role` to `subagent` to delegate to it. When the user writes @name for one of these agents, delegate the relevant work to that agent with `subagent`, putting everything it needs in `task`.",
1389
+ ...defined.map((each) => `- ${each.name}: ${each.description.replace(/\s+/g, " ")}`)
1390
+ ].join(`
1391
+ `);
1392
+ })
1393
+ ]
1394
+ });
1395
+ return { extension };
1396
+ }
1397
+
1398
+ // apps/host/src/extensions/todo.ts
1399
+ import { defineDoc as defineDoc5, defineExtension as defineExtension4, defineTool as defineTool3 } from "@earendil-works/pi-durable";
1400
+ import { Type as Type4 } from "typebox";
1401
+ var TodoDoc = defineDoc5({
1402
+ kind: "todo.list",
1403
+ version: 1,
1404
+ scope: "conversation",
1405
+ history: "rewindable",
1406
+ fork: "asOf",
1407
+ initial: () => ({ items: [] })
1408
+ });
1409
+ var todoWrite = defineTool3({
1410
+ name: "todo_write",
1411
+ description: "Replace the todo list shown to the user with the given items.",
1412
+ parameters: Type4.Object({ items: Type4.Array(TodoItemSchema) }),
1413
+ replay: "safe",
1414
+ execute: async (args, api, context) => {
1415
+ await api.commit(async (tx) => {
1416
+ const doc = await tx.doc(TodoDoc, api.conversationId);
1417
+ doc.items = args.items.map((item) => ({ ...item }));
1418
+ }, context);
1419
+ return { output: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
1420
+ }
1421
+ });
1422
+ var todo = {
1423
+ extension: defineExtension4({ name: "todo", tools: [todoWrite] }),
1424
+ docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
1425
+ };
1426
+
1427
+ // apps/host/src/host.ts
1428
+ import { randomBytes as randomBytes4 } from "node:crypto";
1429
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
1430
+ import { basename as basename3, join as join10, sep as sep6 } from "node:path";
1431
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
1432
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
1433
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
1434
+
1435
+ // apps/host/src/mcp.ts
1436
+ import { readFile } from "node:fs/promises";
1437
+ import { homedir } from "node:os";
1438
+ import { isAbsolute, resolve as resolve4 } from "node:path";
1439
+ import {
1440
+ McpAuthRequiredError,
1441
+ McpClient,
1442
+ McpHttpError,
1443
+ StdioTransport,
1444
+ StreamableHttpTransport,
1445
+ toLlmContent
1446
+ } from "@earendil-works/pi-mcp";
1447
+ import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
1448
+ import { Type as Type5 } from "typebox";
1449
+ var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1450
+ var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1451
+ function expand(value) {
1452
+ let missing;
1453
+ const expanded = value.replace(ENV_VAR, (_all, name) => {
1454
+ const found = process.env[name];
1455
+ if (found === undefined)
1456
+ missing = name;
1457
+ return found ?? "";
1458
+ });
1459
+ return missing === undefined ? expanded : { missing };
1460
+ }
1461
+ function expandAll(values, field, error) {
1462
+ if (values === undefined)
1463
+ return;
1464
+ if (!isObject(values)) {
1465
+ error(`"${field}" must be an object of strings`);
1466
+ return;
1467
+ }
1468
+ const expanded = {};
1469
+ for (const [key, value] of Object.entries(values)) {
1470
+ if (typeof value !== "string") {
1471
+ error(`"${field}.${key}" must be a string`);
1472
+ return;
1473
+ }
1474
+ const result2 = expand(value);
1475
+ if (typeof result2 !== "string") {
1476
+ error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
920
1477
  return;
921
1478
  }
922
1479
  expanded[key] = result2;
@@ -953,7 +1510,7 @@ function parseServer(name, raw, error) {
953
1510
  const env = expandAll(raw.env, "env", (message) => error(`${name}: ${message}`));
954
1511
  if (raw.env !== undefined && env === undefined)
955
1512
  return;
956
- const cwd = raw.cwd === undefined ? homedir() : isAbsolute(raw.cwd) ? raw.cwd : resolve2(homedir(), raw.cwd);
1513
+ const cwd = raw.cwd === undefined ? homedir() : isAbsolute(raw.cwd) ? raw.cwd : resolve4(homedir(), raw.cwd);
957
1514
  return {
958
1515
  name,
959
1516
  spec: { kind: "stdio", command: raw.command, args: raw.args, env, cwd },
@@ -1164,9 +1721,9 @@ ${stderr}`;
1164
1721
 
1165
1722
  // apps/host/src/agent-roles.ts
1166
1723
  import { randomBytes as randomBytes2 } from "node:crypto";
1167
- import { existsSync, watch } from "node:fs";
1724
+ import { existsSync as existsSync2, watch } from "node:fs";
1168
1725
  import { mkdir as mkdir2, readdir, readFile as readFile2, rename, rm as rm2, writeFile } from "node:fs/promises";
1169
- import { join as join4 } from "node:path";
1726
+ import { join as join6 } from "node:path";
1170
1727
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1171
1728
 
1172
1729
  // packages/protocol/src/agents.ts
@@ -1196,39 +1753,6 @@ function agentRoleProblem(role) {
1196
1753
  return;
1197
1754
  }
1198
1755
 
1199
- // apps/host/src/host-value.ts
1200
- function constantValue(value) {
1201
- return {
1202
- value,
1203
- subscribe: () => () => {}
1204
- };
1205
- }
1206
-
1207
- class Published {
1208
- #listeners = new Set;
1209
- #equal;
1210
- #value;
1211
- constructor(value, equal) {
1212
- this.#value = value;
1213
- this.#equal = equal;
1214
- }
1215
- get value() {
1216
- return this.#value;
1217
- }
1218
- subscribe(listener) {
1219
- this.#listeners.add(listener);
1220
- return () => this.#listeners.delete(listener);
1221
- }
1222
- set(next) {
1223
- if (this.#equal(this.#value, next))
1224
- return;
1225
- this.#value = next;
1226
- for (const listener of this.#listeners)
1227
- listener(next);
1228
- }
1229
- }
1230
- var sameList = (a, b, same) => a.length === b.length && a.every((entry, index) => same(entry, b[index]));
1231
-
1232
1756
  // apps/host/src/agent-roles.ts
1233
1757
  var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
1234
1758
  async function load(dir) {
@@ -1243,7 +1767,7 @@ async function load(dir) {
1243
1767
  const problem = (reason2) => void problems.push({ file, reason: reason2 });
1244
1768
  let parsed;
1245
1769
  try {
1246
- parsed = parseFrontmatter(await readFile2(join4(dir, file), "utf8"));
1770
+ parsed = parseFrontmatter(await readFile2(join6(dir, file), "utf8"));
1247
1771
  } catch (error) {
1248
1772
  problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
1249
1773
  continue;
@@ -1301,11 +1825,14 @@ async function openAgentRoles(options) {
1301
1825
  const dir = options.dir;
1302
1826
  await mkdir2(dir, { recursive: true });
1303
1827
  const debounceMs = options.debounceMs ?? 150;
1828
+ const rescanMs = options.rescanMs ?? 1000;
1304
1829
  const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
1305
1830
  let closed = false;
1306
1831
  let reported = false;
1307
1832
  let timer;
1308
- const reload = async () => {
1833
+ let queued = false;
1834
+ let chain = Promise.resolve();
1835
+ const loadOnce = async () => {
1309
1836
  try {
1310
1837
  published.set(await load(dir));
1311
1838
  reported = false;
@@ -1315,7 +1842,18 @@ async function openAgentRoles(options) {
1315
1842
  reported = true;
1316
1843
  }
1317
1844
  };
1318
- await reload();
1845
+ const reload = () => {
1846
+ if (closed)
1847
+ return Promise.resolve();
1848
+ queued = true;
1849
+ chain = chain.then(async () => {
1850
+ if (!queued || closed)
1851
+ return;
1852
+ queued = false;
1853
+ await loadOnce();
1854
+ });
1855
+ return chain;
1856
+ };
1319
1857
  const watcher = watch(dir, () => {
1320
1858
  if (closed)
1321
1859
  return;
@@ -1326,7 +1864,10 @@ async function openAgentRoles(options) {
1326
1864
  if (!reported)
1327
1865
  console.error(`agent-roles watch failed: ${error.message}`);
1328
1866
  reported = true;
1867
+ reload();
1329
1868
  });
1869
+ await reload();
1870
+ const rescan = setInterval(() => void reload(), rescanMs);
1330
1871
  let pending = Promise.resolve();
1331
1872
  const enqueue = (operation) => {
1332
1873
  const run2 = pending.then(operation, operation);
@@ -1338,13 +1879,13 @@ async function openAgentRoles(options) {
1338
1879
  if (problem !== undefined)
1339
1880
  throw new Error(problem);
1340
1881
  await enqueue(async () => {
1341
- const target = join4(dir, `${role.name}.md`);
1342
- const exists = existsSync(target);
1882
+ const target = join6(dir, `${role.name}.md`);
1883
+ const exists = existsSync2(target);
1343
1884
  if (mustExist && !exists)
1344
1885
  throw new Error(`No such agent: "${role.name}"`);
1345
1886
  if (!mustExist && exists)
1346
1887
  throw new Error(`Agent "${role.name}" already exists`);
1347
- const temp = join4(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
1888
+ const temp = join6(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
1348
1889
  try {
1349
1890
  await writeFile(temp, serialize(role));
1350
1891
  await rename(temp, target);
@@ -1365,485 +1906,618 @@ async function openAgentRoles(options) {
1365
1906
  async remove(name) {
1366
1907
  const problem = agentNameProblem(name);
1367
1908
  if (problem !== undefined)
1368
- throw new Error(problem);
1369
- await enqueue(async () => {
1370
- const target = join4(dir, `${name}.md`);
1371
- if (!existsSync(target))
1372
- throw new Error(`No such agent: "${name}"`);
1373
- await rm2(target);
1374
- await reload();
1375
- });
1376
- },
1377
- close() {
1378
- closed = true;
1379
- clearTimeout(timer);
1380
- watcher.close();
1381
- }
1382
- };
1383
- }
1384
-
1385
- // apps/host/src/host-settings.ts
1386
- import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1387
- import { dirname as dirname2 } from "node:path";
1388
- function settingsDefaults(settings) {
1389
- return {
1390
- ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
1391
- ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
1392
- };
1393
- }
1394
- var isModelRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.provider === "string" && value.provider !== "" && typeof value.modelId === "string" && value.modelId !== "";
1395
- var fileValueError = (key, value, models) => {
1396
- switch (key) {
1397
- case "defaultModel":
1398
- if (!isModelRef(value))
1399
- return "malformed";
1400
- return models.some((each) => each.provider === value.provider && each.modelId === value.modelId) ? undefined : "unavailable";
1401
- case "defaultThinkingLevel":
1402
- return isThinkingLevel(value) ? undefined : "malformed";
1403
- case "steeringMode":
1404
- case "followUpMode":
1405
- return isQueueMode(value) ? undefined : "malformed";
1406
- case "acceptAccountDevices":
1407
- return typeof value === "boolean" ? undefined : "malformed";
1408
- }
1409
- };
1410
- var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
1411
- 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);
1412
- var parse = (path, text) => {
1413
- let parsed;
1414
- try {
1415
- parsed = JSON.parse(text);
1416
- } catch (error) {
1417
- throw new Error(`Invalid settings file ${path}: ${error instanceof Error ? error.message : String(error)}`);
1418
- }
1419
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1420
- throw new Error(`Invalid settings file ${path}: expected a JSON object`);
1421
- }
1422
- return parsed;
1423
- };
1424
- async function openHostSettings(options) {
1425
- let file = await readFile3(options.path, "utf8").then((text) => parse(options.path, text), (error) => {
1426
- if (error.code === "ENOENT")
1427
- return {};
1428
- throw new Error(`Cannot read settings file ${options.path}: ${error instanceof Error ? error.message : String(error)}`);
1429
- });
1430
- const warned = new Set;
1431
- const compute = () => {
1432
- const fileValues = {};
1433
- const custom = [];
1434
- for (const key of HOST_SETTING_KEYS) {
1435
- if (!Object.hasOwn(file, key))
1436
- continue;
1437
- const value = file[key];
1438
- const invalid = fileValueError(key, value, options.models.value);
1439
- if (invalid === "malformed" && !warned.has(key)) {
1440
- warned.add(key);
1441
- console.error(`${options.path}: ignoring invalid value for settings key "${key}": ${JSON.stringify(value)}`);
1442
- }
1443
- if (invalid !== undefined)
1444
- continue;
1445
- fileValues[key] = value;
1446
- custom.push(key);
1447
- }
1448
- const { fallback } = options;
1449
- return {
1450
- defaultModel: fileValues.defaultModel ?? fallback.defaultModel(),
1451
- defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
1452
- steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
1453
- followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
1454
- acceptAccountDevices: fileValues.acceptAccountDevices ?? true,
1455
- custom
1456
- };
1457
- };
1458
- const published = new Published(compute(), sameSettings);
1459
- options.models.subscribe(() => published.set(compute()));
1460
- let tail = Promise.resolve();
1461
- const write = async (patch) => {
1462
- const model = patch.defaultModel;
1463
- if (model !== undefined && model !== null && !options.models.value.some((each) => each.provider === model.provider && each.modelId === model.modelId)) {
1464
- throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
1465
- }
1466
- const next = { ...file };
1467
- for (const key of HOST_SETTING_KEYS) {
1468
- const value = patch[key];
1469
- if (value === undefined)
1470
- continue;
1471
- if (value === null)
1472
- delete next[key];
1473
- else
1474
- next[key] = value;
1475
- }
1476
- const tmp = `${options.path}.tmp`;
1477
- await mkdir3(dirname2(options.path), { recursive: true });
1478
- await writeFile2(tmp, `${JSON.stringify(next, null, 2)}
1479
- `, { mode: 384 });
1480
- await rename2(tmp, options.path);
1481
- file = next;
1482
- published.set(compute());
1483
- };
1484
- return {
1485
- get value() {
1486
- return published.value;
1487
- },
1488
- subscribe: (listener) => published.subscribe(listener),
1489
- update: (patch) => tail = tail.then(() => write(patch), () => write(patch))
1490
- };
1491
- }
1492
-
1493
- // apps/host/src/host.ts
1494
- import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1495
- import lockfile from "proper-lockfile";
1496
-
1497
- // apps/host/src/gateway.ts
1498
- import { createServer } from "node:http";
1499
- import { homedir as homedir2, networkInterfaces } from "node:os";
1500
- import { join as join7, sep as sep4 } from "node:path";
1501
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1502
- import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1503
- import {
1504
- AgentDoc as AgentDoc4,
1505
- LiveDoc
1506
- } from "@earendil-works/pi-durable";
1507
- import { WebSocket, WebSocketServer } from "ws";
1508
- import { Value as Value2 } from "typebox/value";
1509
-
1510
- // apps/host/src/folders.ts
1511
- import { readdir as readdir2, realpath as realpath3, stat as stat3 } from "node:fs/promises";
1512
- import { isAbsolute as isAbsolute2, join as join5 } from "node:path";
1513
- var denied = (error) => {
1514
- const code = error?.code;
1515
- return code === "EACCES" || code === "EPERM";
1516
- };
1517
- async function listFolders(path, home) {
1518
- if (path !== undefined && !isAbsolute2(path))
1519
- throw new Error(`Not an absolute path: ${path}`);
1520
- const given = path ?? home;
1521
- const dir = await realpath3(given).catch((error) => {
1522
- if (denied(error))
1523
- throw new Error(`Permission denied: ${given}`);
1524
- throw new Error(`No such directory: ${given}`);
1525
- });
1526
- if (!(await stat3(dir)).isDirectory())
1527
- throw new Error(`Not a directory: ${given}`);
1528
- let entries;
1529
- try {
1530
- entries = await readdir2(dir, { withFileTypes: true });
1531
- } catch (error) {
1532
- if (denied(error))
1533
- throw new Error(`Permission denied: ${given}`);
1534
- throw error;
1535
- }
1536
- const folders = [];
1537
- for (const entry of entries) {
1538
- if (entry.name.startsWith("."))
1539
- continue;
1540
- let isDirectory = entry.isDirectory();
1541
- if (!isDirectory && entry.isSymbolicLink()) {
1542
- isDirectory = await stat3(join5(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1543
- }
1544
- if (isDirectory)
1545
- folders.push({ name: entry.name, path: join5(dir, entry.name) });
1546
- }
1547
- folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1548
- return { path: dir, home: await realpath3(home).catch(() => home), folders };
1549
- }
1550
-
1551
- // apps/host/src/files.ts
1552
- import { spawn } from "node:child_process";
1553
- import { opendir, realpath as realpath4, stat as stat4 } from "node:fs/promises";
1554
- import { join as join6, resolve as resolve3, sep as sep3 } from "node:path";
1555
- import { AgentDoc as AgentDoc3 } from "@earendil-works/pi-durable";
1556
- var CACHE_TTL_MS = 3000;
1557
- var MAX_CACHED_ROOTS = 64;
1558
- var MAX_CONCURRENT_SCANS = 4;
1559
- var SCAN_BUDGET_MS = 5000;
1560
- var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
1561
- var MAX_WALK_ENTRIES = 20000;
1562
- var MAX_RESULTS = 50;
1563
- var UNUSABLE_PATH = /[\s`\x00-\x1f\x7f-\x9f]/;
1564
- function createCandidateLister(enumerate, options = {}) {
1565
- const now = options.now ?? Date.now;
1566
- const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
1567
- const maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_SCANS;
1568
- const maxCached = options.maxCached ?? MAX_CACHED_ROOTS;
1569
- const cache = new Map;
1570
- const inflight = new Map;
1571
- let running = 0;
1572
- const waiting = [];
1573
- return (root, mode) => {
1574
- const key = `${mode}${root}`;
1575
- const hit = cache.get(key);
1576
- if (hit !== undefined) {
1577
- if (hit.expiresAt > now())
1578
- return Promise.resolve(hit.value);
1579
- cache.delete(key);
1580
- }
1581
- const pending = inflight.get(key);
1582
- if (pending !== undefined)
1583
- return pending;
1584
- const scan = (async () => {
1585
- if (running >= maxConcurrent)
1586
- await new Promise((release) => waiting.push(release));
1587
- running += 1;
1588
- try {
1589
- return await enumerate(root, mode);
1590
- } finally {
1591
- running -= 1;
1592
- waiting.shift()?.();
1593
- }
1594
- })();
1595
- inflight.set(key, scan);
1596
- scan.then((value) => {
1597
- const at = now();
1598
- for (const [cached, entry] of cache) {
1599
- if (entry.expiresAt <= at)
1600
- cache.delete(cached);
1601
- }
1602
- while (cache.size >= maxCached)
1603
- cache.delete(cache.keys().next().value);
1604
- cache.set(key, { value, expiresAt: at + ttlMs });
1605
- }, () => {});
1606
- return scan.finally(() => inflight.delete(key));
1607
- };
1608
- }
1609
- function gitLines(cwd, args, limits) {
1610
- return new Promise((resolvePromise, rejectPromise) => {
1611
- const child = spawn("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "pipe"] });
1612
- const chunks = [];
1613
- let bytes = 0;
1614
- let truncated = false;
1615
- let settled = false;
1616
- const kill = (markTruncated) => {
1617
- truncated ||= markTruncated;
1618
- child.kill("SIGKILL");
1619
- };
1620
- const timer = setTimeout(() => kill(true), Math.max(0, limits.deadline - limits.now()));
1621
- const finish = (error) => {
1622
- if (settled)
1623
- return;
1624
- settled = true;
1625
- clearTimeout(timer);
1626
- if (error !== undefined) {
1627
- rejectPromise(error);
1628
- return;
1629
- }
1630
- const output = Buffer.concat(chunks).toString("utf8");
1631
- const parts = output.split("\x00");
1632
- if (truncated)
1633
- parts.pop();
1634
- resolvePromise({ lines: parts.filter((part) => part !== ""), truncated });
1635
- };
1636
- child.stdout.on("data", (chunk) => {
1637
- bytes += chunk.length;
1638
- if (bytes > limits.maxBytes) {
1639
- kill(true);
1640
- return;
1641
- }
1642
- chunks.push(chunk);
1643
- if (limits.now() > limits.deadline)
1644
- kill(true);
1645
- });
1646
- child.on("error", (error) => finish(error));
1647
- child.stderr.resume();
1648
- child.on("close", (code) => {
1649
- if (code === 0 || truncated) {
1650
- finish(undefined);
1651
- } else {
1652
- finish(new Error(`git ${args.join(" ")} exited with code ${code}`));
1653
- }
1654
- });
1655
- });
1909
+ throw new Error(problem);
1910
+ await enqueue(async () => {
1911
+ const target = join6(dir, `${name}.md`);
1912
+ if (!existsSync2(target))
1913
+ throw new Error(`No such agent: "${name}"`);
1914
+ await rm2(target);
1915
+ await reload();
1916
+ });
1917
+ },
1918
+ close() {
1919
+ closed = true;
1920
+ clearTimeout(timer);
1921
+ clearInterval(rescan);
1922
+ watcher.close();
1923
+ }
1924
+ };
1656
1925
  }
1657
- async function inWorkTree(root) {
1658
- return await new Promise((resolvePromise) => {
1659
- const child = spawn("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] });
1660
- let out = "";
1661
- child.stdout.on("data", (chunk) => out += chunk.toString("utf8"));
1662
- child.on("error", () => resolvePromise(false));
1663
- child.on("close", (code) => resolvePromise(code === 0 && out.trim() === "true"));
1926
+
1927
+ // apps/host/src/host.ts
1928
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1929
+ import lockfile from "proper-lockfile";
1930
+
1931
+ // apps/host/src/gateway.ts
1932
+ import { createServer } from "node:http";
1933
+ import { homedir as homedir2, networkInterfaces } from "node:os";
1934
+ import { join as join8, sep as sep5 } from "node:path";
1935
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1936
+ import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1937
+ import {
1938
+ AgentDoc as AgentDoc5,
1939
+ configure as configure3,
1940
+ InboxDoc,
1941
+ LiveDoc
1942
+ } from "@earendil-works/pi-durable";
1943
+ import { WebSocket, WebSocketServer } from "ws";
1944
+ import { Value as Value2 } from "typebox/value";
1945
+
1946
+ // apps/host/src/folders.ts
1947
+ import { readdir as readdir2, realpath as realpath5, stat as stat5 } from "node:fs/promises";
1948
+ import { isAbsolute as isAbsolute2, join as join7 } from "node:path";
1949
+ var denied = (error) => {
1950
+ const code = error?.code;
1951
+ return code === "EACCES" || code === "EPERM";
1952
+ };
1953
+ async function listFolders(path, home) {
1954
+ if (path !== undefined && !isAbsolute2(path))
1955
+ throw new Error(`Not an absolute path: ${path}`);
1956
+ const given = path ?? home;
1957
+ const dir = await realpath5(given).catch((error) => {
1958
+ if (denied(error))
1959
+ throw new Error(`Permission denied: ${given}`);
1960
+ throw new Error(`No such directory: ${given}`);
1664
1961
  });
1962
+ if (!(await stat5(dir)).isDirectory())
1963
+ throw new Error(`Not a directory: ${given}`);
1964
+ let entries;
1965
+ try {
1966
+ entries = await readdir2(dir, { withFileTypes: true });
1967
+ } catch (error) {
1968
+ if (denied(error))
1969
+ throw new Error(`Permission denied: ${given}`);
1970
+ throw error;
1971
+ }
1972
+ const folders = [];
1973
+ for (const entry of entries) {
1974
+ if (entry.name.startsWith("."))
1975
+ continue;
1976
+ let isDirectory = entry.isDirectory();
1977
+ if (!isDirectory && entry.isSymbolicLink()) {
1978
+ isDirectory = await stat5(join7(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1979
+ }
1980
+ if (isDirectory)
1981
+ folders.push({ name: entry.name, path: join7(dir, entry.name) });
1982
+ }
1983
+ folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1984
+ return { path: dir, home: await realpath5(home).catch(() => home), folders };
1665
1985
  }
1666
- async function hasHead(root) {
1667
- return await new Promise((resolvePromise) => {
1668
- const child = spawn("git", ["-C", root, "rev-parse", "--verify", "-q", "HEAD"], { stdio: ["ignore", "ignore", "ignore"] });
1669
- child.on("error", () => resolvePromise(false));
1670
- child.on("close", (code) => resolvePromise(code === 0));
1671
- });
1986
+
1987
+ // apps/host/src/skill-expansion.ts
1988
+ import { readFile as readFile3 } from "node:fs/promises";
1989
+ import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
1990
+
1991
+ // packages/client/src/skill-blocks.ts
1992
+ var OPEN = '<skill name="';
1993
+ var LOCATION = '" location="';
1994
+ var REFERENCES = `">
1995
+ References are relative to `;
1996
+ var CLOSE = `
1997
+ </skill>`;
1998
+ var SEPARATOR = `
1999
+
2000
+ `;
2001
+ function withSkillBlocks(blocks, text) {
2002
+ if (blocks.length === 0)
2003
+ return text;
2004
+ const written = blocks.map((block) => `${OPEN}${block.name}${LOCATION}${block.location}${REFERENCES}${block.baseDir}.
2005
+
2006
+ ${block.body}${CLOSE}`);
2007
+ return [...written, text].join(SEPARATOR);
1672
2008
  }
1673
- var STAGED_RECORD = /^([0-7]{6}) [0-9a-f]{40,64} [0-7]\t/;
1674
- async function gitWorkingFiles(root, limits) {
1675
- const listing = await gitLines(root, ["ls-files", "-z", "-s", "--cached", "--others", "--exclude-standard"], limits);
1676
- const deleted = await gitLines(root, ["ls-files", "-z", "--deleted"], limits);
1677
- const gone = new Set(deleted.lines);
1678
- const files = new Set;
1679
- for (const line of listing.lines) {
1680
- const staged = STAGED_RECORD.exec(line);
1681
- const path = staged === null ? line : line.slice(staged[0].length);
1682
- if (staged !== null && staged[1] === "160000")
1683
- continue;
1684
- if (path === "" || path.endsWith("/") || gone.has(path))
1685
- continue;
1686
- files.add(path);
2009
+ var isNameUnit = (unit) => unit >= 48 && unit <= 57 || unit >= 97 && unit <= 122 || unit === 45;
2010
+ function blockEnd(text, at) {
2011
+ if (!text.startsWith(OPEN, at))
2012
+ return -1;
2013
+ let i = at + OPEN.length;
2014
+ const name = i;
2015
+ while (i < text.length && isNameUnit(text.charCodeAt(i)))
2016
+ i += 1;
2017
+ if (i === name || !text.startsWith(LOCATION, i))
2018
+ return -1;
2019
+ i += LOCATION.length;
2020
+ const location = i;
2021
+ while (i < text.length && text[i] !== '"' && text[i] !== `
2022
+ `)
2023
+ i += 1;
2024
+ if (i === location || !text.startsWith(REFERENCES, i))
2025
+ return -1;
2026
+ i += REFERENCES.length;
2027
+ const lineEnd = text.indexOf(`
2028
+ `, i);
2029
+ if (lineEnd - i < 2 || text[lineEnd - 1] !== "." || text[lineEnd + 1] !== `
2030
+ `)
2031
+ return -1;
2032
+ const close = text.indexOf(CLOSE, lineEnd + 1);
2033
+ return close === -1 ? -1 : close + CLOSE.length;
2034
+ }
2035
+ function stripSkillPrefix(text) {
2036
+ const skills = [];
2037
+ let at = 0;
2038
+ for (;; ) {
2039
+ const end = blockEnd(text, at);
2040
+ if (end === -1 || !text.startsWith(SEPARATOR, end) || end + SEPARATOR.length === text.length) {
2041
+ return { text: text.slice(at), skills };
2042
+ }
2043
+ skills.push(text.slice(at + OPEN.length, text.indexOf('"', at + OPEN.length)));
2044
+ at = end + SEPARATOR.length;
1687
2045
  }
1688
- return { files: [...files], truncated: listing.truncated || deleted.truncated };
1689
2046
  }
1690
- async function gitHeadFiles(root, limits) {
1691
- const listing = await gitLines(root, ["ls-tree", "-r", "-z", "HEAD"], limits);
1692
- const files = [];
1693
- for (const line of listing.lines) {
1694
- const tab = line.indexOf("\t");
1695
- if (tab === -1)
2047
+ function stripUserBlocks(content) {
2048
+ const attachments = [];
2049
+ let joined = "";
2050
+ if (typeof content === "string") {
2051
+ joined = content;
2052
+ } else {
2053
+ for (const block of content) {
2054
+ if (block.type !== "text" || typeof block.text !== "string")
2055
+ continue;
2056
+ const files = parseAttachmentBlock(block.text);
2057
+ if (files !== undefined)
2058
+ attachments.push(...files);
2059
+ else
2060
+ joined += block.text;
2061
+ }
2062
+ }
2063
+ const typed = stripSkillPrefix(joined);
2064
+ return { text: typed.text, skills: typed.skills, attachments };
2065
+ }
2066
+
2067
+ // packages/client/src/skill-mentions.ts
2068
+ var WHITESPACE = /\p{White_Space}/u;
2069
+ var WORD_CHAR = /[\p{Alphabetic}\p{N}_$]/u;
2070
+ var isNameUnit2 = (unit) => unit >= 48 && unit <= 57 || unit >= 97 && unit <= 122 || unit === 45;
2071
+ function skillMentionRanges(text, names) {
2072
+ const known = new Set(names);
2073
+ const ranges = [];
2074
+ for (let i = 0;i < text.length; i += 1) {
2075
+ if (text.charCodeAt(i) !== 36)
1696
2076
  continue;
1697
- const kind = line.slice(0, tab).split(" ")[1];
1698
- if (kind === "blob")
1699
- files.push(line.slice(tab + 1));
2077
+ if (i > 0 && !WHITESPACE.test(text[i - 1]))
2078
+ continue;
2079
+ let end = i + 1;
2080
+ while (end < text.length && isNameUnit2(text.charCodeAt(end)))
2081
+ end += 1;
2082
+ if (!known.has(text.slice(i + 1, end)))
2083
+ continue;
2084
+ if (end < text.length && WORD_CHAR.test(String.fromCodePoint(text.codePointAt(end))))
2085
+ continue;
2086
+ ranges.push({ start: i, end });
1700
2087
  }
1701
- return { files, truncated: listing.truncated };
2088
+ return ranges;
1702
2089
  }
1703
- async function walkFiles(root, limits) {
1704
- const files = [];
1705
- let entries = 0;
1706
- let truncated = false;
1707
- const dirs = [{ dir: root, prefix: "" }];
1708
- while (dirs.length > 0) {
1709
- if (limits.now() > limits.deadline) {
1710
- truncated = true;
1711
- break;
2090
+
2091
+ // apps/host/src/skill-expansion.ts
2092
+ function mentionedSkillNames(text, names) {
2093
+ return [...new Set(skillMentionRanges(text, names).map((range) => text.slice(range.start + 1, range.end)))];
2094
+ }
2095
+ async function expandSkills(text, names, skills) {
2096
+ const byName = new Map(skills.map((skill) => [skill.name, skill]));
2097
+ const blocks = [];
2098
+ const missing = [];
2099
+ const failures = [];
2100
+ for (const name of new Set(names)) {
2101
+ const skill = byName.get(name);
2102
+ if (skill === undefined) {
2103
+ missing.push(name);
2104
+ continue;
1712
2105
  }
1713
- const { dir, prefix } = dirs.pop();
1714
- let handle;
1715
2106
  try {
1716
- handle = await opendir(dir);
2107
+ const body = stripFrontmatter(await readFile3(skill.filePath, "utf8")).trim();
2108
+ blocks.push({ name, location: skill.filePath, baseDir: skill.baseDir, body });
1717
2109
  } catch (error) {
1718
- if (prefix === "")
1719
- throw error;
1720
- truncated = true;
1721
- continue;
2110
+ failures.push({ name, filePath: skill.filePath, message: error instanceof Error ? error.message : String(error) });
1722
2111
  }
1723
- for await (const entry of handle) {
1724
- if (entries++ >= limits.maxEntries || limits.now() > limits.deadline) {
1725
- truncated = true;
1726
- break;
1727
- }
1728
- if (entry.isDirectory()) {
1729
- if (entry.name.startsWith(".") || entry.name === "node_modules")
1730
- continue;
1731
- dirs.push({ dir: join6(dir, entry.name), prefix: `${prefix}${entry.name}/` });
1732
- } else if (entry.isFile() || entry.isSymbolicLink()) {
1733
- files.push(`${prefix}${entry.name}`);
1734
- }
2112
+ }
2113
+ return { text: withSkillBlocks(blocks, text), missing, failures };
2114
+ }
2115
+
2116
+ // apps/host/src/commands.ts
2117
+ var BUILTIN_COMMANDS = [
2118
+ {
2119
+ info: {
2120
+ name: "compact",
2121
+ description: "Summarize older context to free up space",
2122
+ argumentHint: "[instructions]",
2123
+ source: "builtin"
2124
+ },
2125
+ run: async (target, args) => {
2126
+ await target.compact(args?.trim() || undefined);
1735
2127
  }
1736
- if (truncated)
1737
- break;
1738
2128
  }
1739
- return { files, truncated };
2129
+ ];
2130
+ function listCommands() {
2131
+ return { commands: BUILTIN_COMMANDS.map((command) => command.info) };
1740
2132
  }
1741
- async function enumerateRoot(root, mode, limits = {}) {
1742
- const now = limits.now ?? Date.now;
1743
- const bounded = {
1744
- now,
1745
- deadline: now() + (limits.budgetMs ?? SCAN_BUDGET_MS),
1746
- maxBytes: limits.maxBytes ?? MAX_OUTPUT_BYTES,
1747
- maxEntries: limits.maxEntries ?? MAX_WALK_ENTRIES
1748
- };
1749
- if (!await inWorkTree(root)) {
1750
- return walkFiles(root, bounded);
2133
+ async function runCommand(target, name, args) {
2134
+ const command = BUILTIN_COMMANDS.find((candidate) => candidate.info.name === name);
2135
+ if (command === undefined)
2136
+ throw new Error(`Unknown command: /${name}`);
2137
+ await command.run(target, args);
2138
+ }
2139
+
2140
+ // packages/protocol/src/images.ts
2141
+ var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
2142
+ var REF_ENCODER = new TextEncoder;
2143
+ var MAX_IMAGES_PER_MESSAGE = 4;
2144
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
2145
+ var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
2146
+ function imageProblem(images) {
2147
+ if (images.length > MAX_IMAGES_PER_MESSAGE)
2148
+ return `At most ${MAX_IMAGES_PER_MESSAGE} images per message`;
2149
+ for (let i = 0;i < images.length; i++) {
2150
+ const image = images[i];
2151
+ if (!IMAGE_MIME_TYPES.includes(image.mimeType)) {
2152
+ return `Unsupported image type: ${image.mimeType} (use JPEG, PNG, GIF or WebP)`;
2153
+ }
2154
+ if (image.data === "" || image.data.length % 4 !== 0 || !BASE64.test(image.data)) {
2155
+ return `Image ${i + 1} is not valid base64`;
2156
+ }
2157
+ const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
2158
+ if (image.data.length * 3 / 4 - padding > MAX_IMAGE_BYTES)
2159
+ return `Image ${i + 1} is larger than 5 MB`;
1751
2160
  }
1752
- if (mode === "head" && !await hasHead(root)) {
1753
- return walkFiles(root, bounded);
2161
+ return;
2162
+ }
2163
+
2164
+ // apps/host/src/image-refs.ts
2165
+ import { createHash } from "node:crypto";
2166
+ import { applyImmutable } from "@earendil-works/chord/delta";
2167
+ var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2168
+ var isImageBlock = (value) => value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string";
2169
+ var hashed = new WeakMap;
2170
+ function refOf(block) {
2171
+ let ref = hashed.get(block);
2172
+ if (ref === undefined) {
2173
+ ref = createHash("sha256").update(block.mimeType).update(`
2174
+ `).update(block.data).digest("hex");
2175
+ hashed.set(block, ref);
1754
2176
  }
1755
- return mode === "head" ? gitHeadFiles(root, bounded) : gitWorkingFiles(root, bounded);
2177
+ return ref;
1756
2178
  }
1757
- function matchFiles(candidates, query, max = MAX_RESULTS) {
1758
- const usable = candidates.filter((path) => !UNUSABLE_PATH.test(path));
1759
- if (query === "") {
1760
- const sorted = [...usable].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1761
- return { files: sorted.slice(0, max), truncated: sorted.length > max };
2179
+ function slotAt(path) {
2180
+ if (path[0] === "entries") {
2181
+ if (path.length === 1)
2182
+ return "entries";
2183
+ if (path.length === 2 && typeof path[1] === "number")
2184
+ return "entry";
2185
+ if (path.length === 3 && typeof path[1] === "number" && path[2] === "model")
2186
+ return "messages";
2187
+ if (path.length === 4 && typeof path[1] === "number" && path[2] === "model" && typeof path[3] === "number")
2188
+ return "message";
2189
+ if (path.length === 5 && typeof path[1] === "number" && path[2] === "model" && typeof path[3] === "number" && path[4] === "content")
2190
+ return "content";
2191
+ if (path.length === 6 && typeof path[1] === "number" && path[2] === "model" && typeof path[3] === "number" && path[4] === "content" && typeof path[5] === "number")
2192
+ return "block";
2193
+ return "none";
1762
2194
  }
1763
- const needle = query.toLowerCase();
1764
- const tiers = [[], [], []];
1765
- for (const path of usable) {
1766
- const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
1767
- const lower = path.toLowerCase();
1768
- if (base.startsWith(needle))
1769
- tiers[0].push(path);
1770
- else if (base.includes(needle))
1771
- tiers[1].push(path);
1772
- else if (lower.includes(needle))
1773
- tiers[2].push(path);
2195
+ if (path[0] !== "docs")
2196
+ return "none";
2197
+ if (path.length === 1)
2198
+ return "docs";
2199
+ if (path[1] === "pi.inbox") {
2200
+ if (path.length === 2)
2201
+ return "inbox";
2202
+ if (path.length === 3 && path[2] === "items")
2203
+ return "items";
2204
+ if (path.length === 4 && path[2] === "items" && typeof path[3] === "number")
2205
+ return "item";
2206
+ if (path.length === 5 && path[2] === "items" && typeof path[3] === "number" && path[4] === "content")
2207
+ return "content";
2208
+ if (path.length === 6 && path[2] === "items" && typeof path[3] === "number" && path[4] === "content" && typeof path[5] === "number")
2209
+ return "block";
2210
+ return "none";
1774
2211
  }
1775
- const shorter = (a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
1776
- const matched = [...tiers[0].sort(shorter), ...tiers[1].sort(shorter), ...tiers[2].sort(shorter)];
1777
- return { files: matched.slice(0, max), truncated: matched.length > max };
2212
+ if (path[1] === "pi.live") {
2213
+ if (path.length === 2)
2214
+ return "live";
2215
+ if (path.length === 3 && path[2] === "generation")
2216
+ return "generation";
2217
+ if (path.length === 4 && path[2] === "generation" && path[3] === "message")
2218
+ return "message";
2219
+ if (path.length === 5 && path[2] === "generation" && path[3] === "message" && path[4] === "content")
2220
+ return "content";
2221
+ if (path.length === 6 && path[2] === "generation" && path[3] === "message" && path[4] === "content" && typeof path[5] === "number")
2222
+ return "block";
2223
+ return "none";
2224
+ }
2225
+ return "none";
1778
2226
  }
1779
- async function listFiles(harness, dataDir, enumerate, scope, query, context) {
1780
- let root;
1781
- let mode = "live";
1782
- if (scope.kind === "conversation") {
1783
- const agent = await harness.snapshot(AgentDoc3, scope.conversationId, context);
1784
- if (agent === undefined)
1785
- throw new Error(`Conversation ${scope.conversationId} does not exist`);
1786
- const cwd = agent.cwd;
1787
- if (cwd === undefined)
1788
- throw new Error(`Conversation ${scope.conversationId} has no checkout`);
1789
- if (cwd.startsWith(join6(dataDir, "chats") + sep3))
1790
- return { files: [], truncated: false };
1791
- const info = await stat4(cwd).catch(() => {
1792
- return;
1793
- });
1794
- if (info === undefined || !info.isDirectory()) {
1795
- throw new Error(`Conversation ${scope.conversationId}'s checkout is unavailable: ${cwd}`);
2227
+ function withRef(block) {
2228
+ const ref = refOf(block);
2229
+ const { data: _data, ...rest } = block;
2230
+ return { ...rest, ref };
2231
+ }
2232
+ var HASH_YIELD_BYTES = 8 * 1024 * 1024;
2233
+ var yieldToEventLoop = () => new Promise((resolve5) => setImmediate(resolve5));
2234
+ async function hashBlocks(blocks) {
2235
+ let bytes = 0;
2236
+ for (const block of blocks) {
2237
+ if (hashed.has(block))
2238
+ continue;
2239
+ if (bytes >= HASH_YIELD_BYTES) {
2240
+ bytes = 0;
2241
+ await yieldToEventLoop();
1796
2242
  }
1797
- root = cwd;
1798
- } else {
1799
- const normalized = await realpath4(resolve3(scope.path)).catch(() => resolve3(scope.path));
1800
- const index = await harness.snapshot(IndexDoc, context);
1801
- if (!index?.projects.some((project) => project.path === normalized)) {
1802
- throw new Error(`Unknown project: ${scope.path}`);
2243
+ refOf(block);
2244
+ bytes += block.data.length;
2245
+ }
2246
+ }
2247
+ function rewriteEntries(value) {
2248
+ if (!Array.isArray(value))
2249
+ return value;
2250
+ let out;
2251
+ for (let i = 0;i < value.length; i++) {
2252
+ const next = rewriteEntry(value[i]);
2253
+ if (next !== value[i])
2254
+ (out ??= value.slice())[i] = next;
2255
+ }
2256
+ return out ?? value;
2257
+ }
2258
+ function rewriteEntry(value) {
2259
+ if (!isObject2(value) || value.model === undefined)
2260
+ return value;
2261
+ const model = rewriteMessages(value.model);
2262
+ return model === value.model ? value : { ...value, model };
2263
+ }
2264
+ function rewriteMessages(value) {
2265
+ if (!Array.isArray(value))
2266
+ return value;
2267
+ let out;
2268
+ for (let i = 0;i < value.length; i++) {
2269
+ const next = rewriteMessage(value[i]);
2270
+ if (next !== value[i])
2271
+ (out ??= value.slice())[i] = next;
2272
+ }
2273
+ return out ?? value;
2274
+ }
2275
+ function rewriteMessage(value) {
2276
+ if (!isObject2(value) || value.content === undefined)
2277
+ return value;
2278
+ const content = rewriteContent(value.content);
2279
+ return content === value.content ? value : { ...value, content };
2280
+ }
2281
+ function rewriteContent(value) {
2282
+ if (!Array.isArray(value))
2283
+ return value;
2284
+ let out;
2285
+ for (let i = 0;i < value.length; i++) {
2286
+ const block = value[i];
2287
+ const next = isObject2(block) && isImageBlock(block) ? withRef(block) : block;
2288
+ if (next !== block)
2289
+ (out ??= value.slice())[i] = next;
2290
+ }
2291
+ return out ?? value;
2292
+ }
2293
+ function rewriteItems(value) {
2294
+ if (!Array.isArray(value))
2295
+ return value;
2296
+ let out;
2297
+ for (let i = 0;i < value.length; i++) {
2298
+ const next = rewriteItem(value[i]);
2299
+ if (next !== value[i])
2300
+ (out ??= value.slice())[i] = next;
2301
+ }
2302
+ return out ?? value;
2303
+ }
2304
+ function rewriteItem(value) {
2305
+ if (!isObject2(value) || value.content === undefined)
2306
+ return value;
2307
+ const content = rewriteContent(value.content);
2308
+ return content === value.content ? value : { ...value, content };
2309
+ }
2310
+ function rewriteInbox(value) {
2311
+ if (!isObject2(value) || value.items === undefined)
2312
+ return value;
2313
+ const items = rewriteItems(value.items);
2314
+ return items === value.items ? value : { ...value, items };
2315
+ }
2316
+ function rewriteGeneration(value) {
2317
+ if (!isObject2(value) || value.message === undefined)
2318
+ return value;
2319
+ const message = rewriteMessage(value.message);
2320
+ return message === value.message ? value : { ...value, message };
2321
+ }
2322
+ function rewriteLive(value) {
2323
+ if (!isObject2(value) || value.generation === undefined)
2324
+ return value;
2325
+ const generation = rewriteGeneration(value.generation);
2326
+ return generation === value.generation ? value : { ...value, generation };
2327
+ }
2328
+ function rewriteDocs(value) {
2329
+ if (!isObject2(value))
2330
+ return value;
2331
+ let out;
2332
+ for (const key of ["pi.inbox", "pi.live"]) {
2333
+ const doc = value[key];
2334
+ if (doc === undefined)
2335
+ continue;
2336
+ const next = key === "pi.inbox" ? rewriteInbox(doc) : rewriteLive(doc);
2337
+ if (next !== doc)
2338
+ (out ??= { ...value })[key] = next;
2339
+ }
2340
+ return out ?? value;
2341
+ }
2342
+ function rewriteView(value) {
2343
+ if (!isObject2(value))
2344
+ return value;
2345
+ let out;
2346
+ if (value.entries !== undefined) {
2347
+ const entries = rewriteEntries(value.entries);
2348
+ if (entries !== value.entries)
2349
+ (out ??= { ...value }).entries = entries;
2350
+ }
2351
+ if (value.docs !== undefined) {
2352
+ const docs = rewriteDocs(value.docs);
2353
+ if (docs !== value.docs)
2354
+ (out ??= { ...value }).docs = docs;
2355
+ }
2356
+ return out ?? value;
2357
+ }
2358
+ function rewriteSlot(slot, value) {
2359
+ switch (slot) {
2360
+ case "entries":
2361
+ return rewriteEntries(value);
2362
+ case "entry":
2363
+ return rewriteEntry(value);
2364
+ case "messages":
2365
+ return rewriteMessages(value);
2366
+ case "message":
2367
+ return rewriteMessage(value);
2368
+ case "content":
2369
+ return rewriteContent(value);
2370
+ case "block":
2371
+ return isObject2(value) && isImageBlock(value) ? withRef(value) : value;
2372
+ case "docs":
2373
+ return rewriteDocs(value);
2374
+ case "inbox":
2375
+ return rewriteInbox(value);
2376
+ case "items":
2377
+ return rewriteItems(value);
2378
+ case "item":
2379
+ return rewriteItem(value);
2380
+ case "live":
2381
+ return rewriteLive(value);
2382
+ case "generation":
2383
+ return rewriteGeneration(value);
2384
+ default:
2385
+ return value;
2386
+ }
2387
+ }
2388
+ function rewriteOp(op) {
2389
+ switch (op[0]) {
2390
+ case "r": {
2391
+ const value = rewriteView(op[1]);
2392
+ return value === op[1] ? op : ["r", value];
1803
2393
  }
1804
- const info = await stat4(normalized).catch(() => {
2394
+ case "s": {
2395
+ const slot = slotAt(op[1]);
2396
+ if (slot === "none")
2397
+ return op;
2398
+ const value = rewriteSlot(slot, op[2]);
2399
+ return value === op[2] ? op : ["s", op[1], value];
2400
+ }
2401
+ case "p": {
2402
+ const path = op[1];
2403
+ const start = op[2];
2404
+ let items;
2405
+ for (let i = 0;i < op[4].length; i++) {
2406
+ const item = op[4][i];
2407
+ const slot = slotAt([...path, start + i]);
2408
+ const next = slot === "none" ? item : rewriteSlot(slot, item);
2409
+ if (next !== item)
2410
+ (items ??= op[4].slice())[i] = next;
2411
+ }
2412
+ return items === undefined ? op : ["p", op[1], op[2], op[3], items];
2413
+ }
2414
+ default:
2415
+ return op;
2416
+ }
2417
+ }
2418
+ function resolve5(root, path, length) {
2419
+ let node = root;
2420
+ for (let i = 0;i < length; i++) {
2421
+ if (node === null || typeof node !== "object")
1805
2422
  return;
1806
- });
1807
- if (info === undefined || !info.isDirectory()) {
1808
- throw new Error(`Not a directory: ${normalized}`);
2423
+ node = node[path[i]];
2424
+ }
2425
+ return node;
2426
+ }
2427
+ function reachesIntoImage(op, root) {
2428
+ if (op[0] === "r" || op[0] === "m")
2429
+ return false;
2430
+ const path = op[1];
2431
+ const depth = op[0] === "p" ? path.length : path.length - 1;
2432
+ for (let length = 1;length <= depth; length++) {
2433
+ if (slotAt(path.slice(0, length)) !== "block")
2434
+ continue;
2435
+ const node = resolve5(root, path, length);
2436
+ if (isObject2(node) && isImageBlock(node))
2437
+ return true;
2438
+ }
2439
+ return false;
2440
+ }
2441
+ function* contentArrays(view) {
2442
+ if (!isObject2(view))
2443
+ return;
2444
+ if (Array.isArray(view.entries)) {
2445
+ for (const entry of view.entries) {
2446
+ if (!isObject2(entry) || !Array.isArray(entry.model))
2447
+ continue;
2448
+ for (const message of entry.model) {
2449
+ if (isObject2(message) && Array.isArray(message.content))
2450
+ yield message.content;
2451
+ }
1809
2452
  }
1810
- root = normalized;
1811
- mode = scope.checkout === "worktree" ? "head" : "live";
1812
2453
  }
1813
- const candidates = await enumerate(root, mode);
1814
- const matched = matchFiles(candidates.files, query);
1815
- return { files: matched.files, truncated: matched.truncated || candidates.truncated };
2454
+ if (!isObject2(view.docs))
2455
+ return;
2456
+ const inbox = view.docs["pi.inbox"];
2457
+ if (isObject2(inbox) && Array.isArray(inbox.items)) {
2458
+ for (const item of inbox.items) {
2459
+ if (isObject2(item) && Array.isArray(item.content))
2460
+ yield item.content;
2461
+ }
2462
+ }
2463
+ const live = view.docs["pi.live"];
2464
+ if (isObject2(live) && isObject2(live.generation) && isObject2(live.generation.message) && Array.isArray(live.generation.message.content)) {
2465
+ yield live.generation.message.content;
2466
+ }
1816
2467
  }
1817
-
1818
- // packages/protocol/src/images.ts
1819
- var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1820
- var MAX_IMAGES_PER_MESSAGE = 4;
1821
- var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1822
- var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
1823
- function imageProblem(images) {
1824
- if (images.length > MAX_IMAGES_PER_MESSAGE)
1825
- return `At most ${MAX_IMAGES_PER_MESSAGE} images per message`;
1826
- for (let i = 0;i < images.length; i++) {
1827
- const image = images[i];
1828
- if (!IMAGE_MIME_TYPES.includes(image.mimeType)) {
1829
- return `Unsupported image type: ${image.mimeType} (use JPEG, PNG, GIF or WebP)`;
2468
+ function imageBlocks(view) {
2469
+ const blocks = [];
2470
+ for (const content of contentArrays(view)) {
2471
+ for (const block of content) {
2472
+ if (isObject2(block) && isImageBlock(block))
2473
+ blocks.push(block);
1830
2474
  }
1831
- if (image.data === "" || image.data.length % 4 !== 0 || !BASE64.test(image.data)) {
1832
- return `Image ${i + 1} is not valid base64`;
2475
+ }
2476
+ return blocks;
2477
+ }
2478
+ function findImage(view, ref) {
2479
+ for (const block of imageBlocks(view)) {
2480
+ if (refOf(block) === ref) {
2481
+ return { data: block.data, mimeType: block.mimeType };
1833
2482
  }
1834
- const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
1835
- if (image.data.length * 3 / 4 - padding > MAX_IMAGE_BYTES)
1836
- return `Image ${i + 1} is larger than 5 MB`;
1837
2483
  }
1838
2484
  return;
1839
2485
  }
1840
2486
 
2487
+ class ImageRefs {
2488
+ async value(view) {
2489
+ const blocks = imageBlocks(view);
2490
+ if (blocks.length > 0)
2491
+ await hashBlocks(blocks);
2492
+ return rewriteView(view);
2493
+ }
2494
+ image(view, ref) {
2495
+ return findImage(view, ref);
2496
+ }
2497
+ async ops(ops, before) {
2498
+ let state = before;
2499
+ let touched = false;
2500
+ for (const op of ops) {
2501
+ if (!touched && reachesIntoImage(op, state))
2502
+ touched = true;
2503
+ state = applyImmutable(state, [op]);
2504
+ }
2505
+ if (touched) {
2506
+ await hashBlocks(imageBlocks(state));
2507
+ return [["r", rewriteView(state)]];
2508
+ }
2509
+ if (ops.some((op) => op[0] === "r"))
2510
+ await hashBlocks(imageBlocks(state));
2511
+ return ops.map(rewriteOp);
2512
+ }
2513
+ }
2514
+
1841
2515
  // apps/host/src/auto-title.ts
1842
2516
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1843
2517
 
1844
2518
  // apps/host/src/title-doc.ts
1845
- import { defineDoc as defineDoc5 } from "@earendil-works/pi-durable";
1846
- var TitleDoc = defineDoc5({
2519
+ import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
2520
+ var TitleDoc = defineDoc6({
1847
2521
  kind: "pinomad.title",
1848
2522
  version: 1,
1849
2523
  scope: "conversation",
@@ -1912,7 +2586,8 @@ function attachAutoTitle(runs, deps) {
1912
2586
  const model = ref === null ? undefined : deps.models.getModel(ref.provider, ref.modelId);
1913
2587
  if (model === undefined)
1914
2588
  return;
1915
- const firstText = (textOf(first.model?.[0] !== undefined ? first.model[0].content : "") || "(images only)").slice(0, TITLE_INPUT_LIMIT);
2589
+ const firstMessage = first.model?.[0];
2590
+ const firstText = (stripUserBlocks(firstMessage?.content ?? "").text || "(images or files only)").slice(0, TITLE_INPUT_LIMIT);
1916
2591
  const answer = textOf(last.content).slice(0, TITLE_INPUT_LIMIT);
1917
2592
  const request = {
1918
2593
  systemPrompt: TITLE_PROMPT,
@@ -1959,9 +2634,9 @@ ${answer}`}`,
1959
2634
  }
1960
2635
 
1961
2636
  // apps/host/src/push.ts
1962
- import { createHash } from "node:crypto";
2637
+ import { createHash as createHash2 } from "node:crypto";
1963
2638
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1964
- import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
2639
+ import { defineDoc as defineDoc7 } from "@earendil-works/pi-durable";
1965
2640
 
1966
2641
  // packages/client/src/family.ts
1967
2642
  var SUBAGENT_TASK_PREFIX = /^\s*in the working directory\s+\S+,\s*/i;
@@ -2019,7 +2694,7 @@ function diffRunNotices(previous, organized) {
2019
2694
 
2020
2695
  // apps/host/src/push.ts
2021
2696
  var context2 = BACKGROUND_CONTEXT2;
2022
- var PushDoc = defineDoc6({
2697
+ var PushDoc = defineDoc7({
2023
2698
  kind: "pinomad.push",
2024
2699
  version: 2,
2025
2700
  scope: "session",
@@ -2102,7 +2777,7 @@ var realTimers = {
2102
2777
  clearTimeout: (handle) => clearTimeout(handle)
2103
2778
  };
2104
2779
  var tokenHint = (token) => `…${token.slice(-6)}`;
2105
- var collapseId = (hostKey, id) => createHash("sha256").update(`${hostKey}:${id}`).digest("hex").slice(0, 32);
2780
+ var collapseId = (hostKey, id) => createHash2("sha256").update(`${hostKey}:${id}`).digest("hex").slice(0, 32);
2106
2781
  async function attachPushSender(options) {
2107
2782
  const { harness, conversations } = options;
2108
2783
  const transport = options.transport ?? UNAVAILABLE_TRANSPORT;
@@ -2152,7 +2827,7 @@ async function attachPushSender(options) {
2152
2827
  return named;
2153
2828
  if (notice.body !== "Finished" || disposed)
2154
2829
  return FALLBACK_TITLE;
2155
- return await new Promise((resolve4) => {
2830
+ return await new Promise((resolve6) => {
2156
2831
  const waiters = titleWaiters.get(notice.id) ?? new Set;
2157
2832
  titleWaiters.set(notice.id, waiters);
2158
2833
  const settle = (title) => {
@@ -2160,7 +2835,7 @@ async function attachPushSender(options) {
2160
2835
  if (waiters.size === 0)
2161
2836
  titleWaiters.delete(notice.id);
2162
2837
  timers.clearTimeout(timer);
2163
- resolve4(title);
2838
+ resolve6(title);
2164
2839
  };
2165
2840
  const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
2166
2841
  waiters.add(settle);
@@ -2360,6 +3035,28 @@ function toolTarget(args) {
2360
3035
  // apps/host/src/gateway.ts
2361
3036
  var context3 = BACKGROUND_CONTEXT3;
2362
3037
  var forkTails = new WeakMap;
3038
+ function assertQueuedInput(record, conversationId) {
3039
+ if (record.conversationId !== conversationId) {
3040
+ throw new Error(`Submission ${record.id} belongs to another conversation`);
3041
+ }
3042
+ if (record.type !== "input")
3043
+ throw new Error(`Submission ${record.id} is a write, not a message`);
3044
+ }
3045
+ async function dismissPendingQuestions(tx, docs, conversationId) {
3046
+ let dismissed = false;
3047
+ for (const doc of docs) {
3048
+ const value = await tx.doc(doc.token, conversationId);
3049
+ if (!Value2.Check(QuestionSchema, value))
3050
+ continue;
3051
+ for (const request of value.requests) {
3052
+ if (request.resolution === undefined) {
3053
+ request.resolution = { outcome: "dismissed", at: Date.now() };
3054
+ dismissed = true;
3055
+ }
3056
+ }
3057
+ }
3058
+ return dismissed;
3059
+ }
2363
3060
  function serializedFork(harness, work) {
2364
3061
  const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
2365
3062
  const settled = next.then(() => {}, () => {});
@@ -2374,6 +3071,7 @@ var LOOPBACK_SECURE_PATH = "/secure";
2374
3071
  async function startGateway(options) {
2375
3072
  const conversations = await ConversationList.open(options.harness, options.docs);
2376
3073
  const fileLister = createCandidateLister(enumerateRoot);
3074
+ const skillLister = createSkillLister((root, mode) => enumerateSkills(root, mode, options.agentsHome));
2377
3075
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
2378
3076
  harness: options.harness,
2379
3077
  models: options.models,
@@ -2386,12 +3084,12 @@ async function startGateway(options) {
2386
3084
  response.end();
2387
3085
  });
2388
3086
  });
2389
- const server = new WebSocketServer({ server: http });
2390
- await new Promise((resolve4, reject) => {
2391
- http.once("listening", resolve4);
3087
+ await new Promise((resolve6, reject) => {
3088
+ http.once("listening", resolve6);
2392
3089
  http.once("error", reject);
2393
3090
  http.listen(options.port, "127.0.0.1");
2394
3091
  });
3092
+ const server = new WebSocketServer({ server: http });
2395
3093
  const { port } = http.address();
2396
3094
  const browserOrigins = [...options.browserOrigins ?? [], `http://127.0.0.1:${port}`];
2397
3095
  const clients = new Set;
@@ -2405,7 +3103,7 @@ async function startGateway(options) {
2405
3103
  if (joined?.source === "account") {
2406
3104
  broadcast("info", `New device "${joined.name}" joined through your account`);
2407
3105
  }
2408
- const client = new GatewayClient(connection, options, conversations, fileLister, {
3106
+ const client = new GatewayClient(connection, options, conversations, fileLister, skillLister, {
2409
3107
  devicePublicKey,
2410
3108
  closed: (self) => {
2411
3109
  clients.delete(self);
@@ -2465,7 +3163,11 @@ async function startGateway(options) {
2465
3163
  }
2466
3164
  register(tokenConnection(socket));
2467
3165
  });
2468
- remote = await startRemote(options, admission, register);
3166
+ remote = await startRemote(options, admission, register).catch(async (error) => {
3167
+ await new Promise((resolve6) => server.close(() => resolve6()));
3168
+ await new Promise((resolve6) => http.close(() => resolve6()));
3169
+ throw error;
3170
+ });
2469
3171
  const secure = options.secure;
2470
3172
  const detachSettingsWatch = options.settings.subscribe((settings) => {
2471
3173
  if (!settings.acceptAccountDevices)
@@ -2479,6 +3181,12 @@ async function startGateway(options) {
2479
3181
  signingKey: secure.relay.signingKey,
2480
3182
  noisePublicKey: secure.hostKey.publicKey,
2481
3183
  accept: (socket) => secureHandshake(socket, secure, admission, register),
3184
+ hostName: {
3185
+ get value() {
3186
+ return options.settings.value.hostName;
3187
+ },
3188
+ subscribe: (listener) => options.settings.subscribe((settings) => listener(settings.hostName))
3189
+ },
2482
3190
  accountOffer: async () => {
2483
3191
  if (!options.settings.value.acceptAccountDevices)
2484
3192
  return { ok: false, reason: "disabled" };
@@ -2531,8 +3239,8 @@ async function startGateway(options) {
2531
3239
  for (const socket of server.clients)
2532
3240
  socket.terminate();
2533
3241
  await remote?.close();
2534
- await new Promise((resolve4) => server.close(() => resolve4()));
2535
- await new Promise((resolve4) => http.close(() => resolve4()));
3242
+ await new Promise((resolve6) => server.close(() => resolve6()));
3243
+ await new Promise((resolve6) => http.close(() => resolve6()));
2536
3244
  }
2537
3245
  };
2538
3246
  }
@@ -2541,7 +3249,7 @@ function tokenConnection(socket) {
2541
3249
  get open() {
2542
3250
  return socket.readyState === WebSocket.OPEN;
2543
3251
  },
2544
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => socket.send(text, () => resolve4())) : Promise.resolve(),
3252
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve6) => socket.send(text, () => resolve6())) : Promise.resolve(),
2545
3253
  close: (code, reason) => socket.close(code, reason),
2546
3254
  handle(handlers) {
2547
3255
  socket.on("message", (data) => handlers.message(String(data)));
@@ -2554,9 +3262,9 @@ function secureConnection(socket, session) {
2554
3262
  get open() {
2555
3263
  return socket.readyState === WebSocket.OPEN;
2556
3264
  },
2557
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => {
3265
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve6) => {
2558
3266
  const chunks = session.seal(text);
2559
- chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve4() : undefined));
3267
+ chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve6() : undefined));
2560
3268
  }) : Promise.resolve(),
2561
3269
  close: (code, reason) => socket.close(code, reason),
2562
3270
  handle(handlers) {
@@ -2659,13 +3367,13 @@ async function startRemote(options, admission, register) {
2659
3367
  response.end();
2660
3368
  });
2661
3369
  });
2662
- const secure = new WebSocketServer({ server: http });
2663
- secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
2664
3370
  await new Promise((resolveListen, reject) => {
2665
3371
  http.once("listening", resolveListen);
2666
3372
  http.once("error", reject);
2667
3373
  http.listen(remote.port, "0.0.0.0");
2668
3374
  });
3375
+ const secure = new WebSocketServer({ server: http });
3376
+ secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
2669
3377
  const { port } = http.address();
2670
3378
  const url = `ws://127.0.0.1:${port}`;
2671
3379
  const advertiseUrl = remote.publicUrl?.replace(/\/+$/, "") ?? `http://${pickLanAddress(networkInterfaces()) ?? "127.0.0.1"}:${port}`;
@@ -2675,8 +3383,8 @@ async function startRemote(options, admission, register) {
2675
3383
  async close() {
2676
3384
  for (const socket of secure.clients)
2677
3385
  socket.terminate();
2678
- await new Promise((resolve4) => secure.close(() => resolve4()));
2679
- await new Promise((resolve4) => http.close(() => resolve4()));
3386
+ await new Promise((resolve6) => secure.close(() => resolve6()));
3387
+ await new Promise((resolve6) => http.close(() => resolve6()));
2680
3388
  }
2681
3389
  };
2682
3390
  }
@@ -2742,6 +3450,7 @@ class ConversationList {
2742
3450
  if (typeof subagent?.role === "string" && subagent.role !== "")
2743
3451
  facts.role = subagent.role;
2744
3452
  }
3453
+ facts.agent = roleNameOf(await harness.snapshot(RoleDoc, record.id, context3));
2745
3454
  const named = (await harness.snapshot(TitleDoc, record.id, context3))?.title;
2746
3455
  if (typeof named === "string" && named !== "")
2747
3456
  facts.named = named;
@@ -2798,6 +3507,9 @@ class ConversationList {
2798
3507
  next.delete(change.record.kind);
2799
3508
  facts.pending = next;
2800
3509
  touched.add(id);
3510
+ } else if (change.record.kind === RoleDoc.definition.kind) {
3511
+ facts.agent = roleNameOf(change.value);
3512
+ touched.add(id);
2801
3513
  } else if (change.record.kind === SubagentDoc.definition.kind) {
2802
3514
  const value = change.value;
2803
3515
  facts.label = typeof value?.label === "string" && value.label !== "" ? value.label : undefined;
@@ -2830,7 +3542,10 @@ class ConversationList {
2830
3542
  }
2831
3543
  #summaryOf(record) {
2832
3544
  const facts = this.#fact(record.id);
2833
- return summaryOf(record, facts, record.owner === undefined ? topLevelRole(record, this.#records, this.#roles) : facts.role);
3545
+ if (record.owner !== undefined)
3546
+ return summaryOf(record, facts, facts.role, facts.role ?? null);
3547
+ const role = topLevelRole(record, this.#records, this.#roles);
3548
+ return summaryOf(record, facts, role, (facts.agent === undefined ? role : facts.agent) ?? null);
2834
3549
  }
2835
3550
  #refreshSummaries(touched) {
2836
3551
  let next;
@@ -2881,6 +3596,11 @@ class ConversationList {
2881
3596
  this.#listeners.clear();
2882
3597
  }
2883
3598
  }
3599
+ function roleNameOf(value) {
3600
+ if (value === null || value === undefined)
3601
+ return;
3602
+ return typeof value.name === "string" ? value.name : value.name === null ? null : undefined;
3603
+ }
2884
3604
  function rolesOf(index) {
2885
3605
  const roles = new Map;
2886
3606
  for (const entry of index?.conversations ?? []) {
@@ -2900,7 +3620,7 @@ function topLevelRole(record, records, roles) {
2900
3620
  }
2901
3621
  return;
2902
3622
  }
2903
- function summaryOf(record, facts, role) {
3623
+ function summaryOf(record, facts, role, currentAgent) {
2904
3624
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2905
3625
  const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2906
3626
  const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
@@ -2918,18 +3638,18 @@ function summaryOf(record, facts, role) {
2918
3638
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2919
3639
  ...facts.label === undefined ? {} : { label: facts.label },
2920
3640
  ...role === undefined ? {} : { role },
3641
+ currentAgent,
2921
3642
  ...activity === undefined ? {} : { activity }
2922
3643
  };
2923
3644
  }
2924
3645
  function sameSummary(a, b) {
2925
- 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.role === b.role && a.activity?.tool === b.activity?.tool && a.activity?.target === b.activity?.target;
3646
+ 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.role === b.role && a.currentAgent === b.currentAgent && a.activity?.tool === b.activity?.tool && a.activity?.target === b.activity?.target;
2926
3647
  }
2927
3648
  function titleOf(entry) {
2928
3649
  const message = entry?.model?.[0];
2929
3650
  if (message?.role !== "user")
2930
3651
  return {};
2931
- const text = typeof message.content === "string" ? message.content : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
2932
- const title = text.replace(/\s+/g, " ").trim();
3652
+ const title = stripUserBlocks(message.content).text.replace(/\s+/g, " ").trim();
2933
3653
  return title === "" ? {} : { title };
2934
3654
  }
2935
3655
  async function historyFacts(harness, id) {
@@ -2974,13 +3694,15 @@ class GatewayClient {
2974
3694
  #hooks;
2975
3695
  #options;
2976
3696
  #files;
3697
+ #skills;
2977
3698
  #hostValues;
2978
3699
  #subscriptions = new Map;
2979
3700
  #disposed = false;
2980
- constructor(connection, options, conversations, files, hooks) {
3701
+ constructor(connection, options, conversations, files, skills, hooks) {
2981
3702
  this.#connection = connection;
2982
3703
  this.#options = options;
2983
3704
  this.#files = files;
3705
+ this.#skills = skills;
2984
3706
  this.#hostValues = {
2985
3707
  conversations,
2986
3708
  mcp: options.mcp ?? constantValue(null),
@@ -3045,15 +3767,15 @@ class GatewayClient {
3045
3767
  }
3046
3768
  async#receive(frame) {
3047
3769
  if (frame.type === "subscribe")
3048
- await this.#subscribe(frame.stream);
3770
+ await this.#subscribe(frame.stream, frame.images === "ref");
3049
3771
  else if (frame.type === "unsubscribe")
3050
3772
  await this.#unsubscribe(frame.stream);
3051
3773
  else
3052
3774
  await this.#answer(frame.id, frame.method, frame.args);
3053
3775
  }
3054
- async#subscribe(stream) {
3776
+ async#subscribe(stream, imageRefs) {
3055
3777
  const previous = this.#subscriptions.get(stream);
3056
- const state = {};
3778
+ const state = imageRefs && stream.startsWith("conversation:") ? { images: new ImageRefs } : {};
3057
3779
  this.#subscriptions.set(stream, state);
3058
3780
  const current = () => !this.#disposed && this.#subscriptions.get(stream) === state;
3059
3781
  const send = (frame) => current() ? this.send(frame) : Promise.resolve();
@@ -3062,7 +3784,7 @@ class GatewayClient {
3062
3784
  await previous?.subscription?.stop();
3063
3785
  if (!current())
3064
3786
  return;
3065
- subscription = await this.#open(stream, send);
3787
+ subscription = await this.#open(stream, send, state);
3066
3788
  } catch (error) {
3067
3789
  if (current()) {
3068
3790
  await send({ type: "ended", stream, reason: error instanceof Error ? error.message : String(error) });
@@ -3077,7 +3799,7 @@ class GatewayClient {
3077
3799
  }
3078
3800
  state.subscription = subscription;
3079
3801
  }
3080
- async#open(stream, send) {
3802
+ async#open(stream, send, state) {
3081
3803
  const source = this.#hostValue(stream);
3082
3804
  if (source !== undefined) {
3083
3805
  send({ type: "snapshot", stream, value: source.value });
@@ -3102,7 +3824,7 @@ class GatewayClient {
3102
3824
  return this.#openDoc(stream, stream.slice("doc:".length, at), Number(stream.slice(at + 1)), send);
3103
3825
  }
3104
3826
  const conversation = await this.#conversation(Number(stream.slice("conversation:".length)));
3105
- return this.#forward(stream, await conversation.watch(context3), send);
3827
+ return this.#forward(stream, await conversation.watch(context3), send, state);
3106
3828
  }
3107
3829
  #hostValue(stream) {
3108
3830
  return Object.hasOwn(this.#hostValues, stream) ? this.#hostValues[stream] : undefined;
@@ -3115,8 +3837,8 @@ class GatewayClient {
3115
3837
  await this.#conversation(id);
3116
3838
  const { harness } = this.#options;
3117
3839
  let signalCreation;
3118
- const created = new Promise((resolve4) => {
3119
- signalCreation = resolve4;
3840
+ const created = new Promise((resolve6) => {
3841
+ signalCreation = resolve6;
3120
3842
  });
3121
3843
  let inner;
3122
3844
  let stopped = false;
@@ -3164,11 +3886,60 @@ class GatewayClient {
3164
3886
  }
3165
3887
  };
3166
3888
  }
3167
- async#forward(stream, watch2, send) {
3168
- await send({ type: "snapshot", stream, value: watch2.value });
3169
- watch2.start((_value, ops) => send({ type: "ops", stream, ops }));
3889
+ async#forward(stream, watch2, send, state) {
3890
+ if (state?.images === undefined) {
3891
+ await send({ type: "snapshot", stream, value: watch2.value });
3892
+ watch2.start((_value, ops) => send({ type: "ops", stream, ops }));
3893
+ return watch2;
3894
+ }
3895
+ const images = state.images;
3896
+ let before = watch2.value;
3897
+ state.view = before;
3898
+ await send({ type: "snapshot", stream, value: await images.value(before) });
3899
+ watch2.start(async (value, ops) => {
3900
+ const sent = await images.ops(ops, before);
3901
+ before = value;
3902
+ state.view = before;
3903
+ return send({ type: "ops", stream, ops: sent });
3904
+ });
3170
3905
  return watch2;
3171
3906
  }
3907
+ #shownImage(ref) {
3908
+ for (const state of this.#subscriptions.values()) {
3909
+ if (state.images === undefined || state.view === undefined)
3910
+ continue;
3911
+ const image = state.images.image(state.view, ref);
3912
+ if (image !== undefined)
3913
+ return image;
3914
+ }
3915
+ return;
3916
+ }
3917
+ #attachmentStore() {
3918
+ const store = this.#options.attachments;
3919
+ if (store === undefined)
3920
+ throw new Error("Attachments are not available on this host");
3921
+ return store;
3922
+ }
3923
+ async#attachmentText(ids) {
3924
+ if (ids === undefined || ids.length === 0)
3925
+ return;
3926
+ const files = await this.#attachmentStore().use(ids);
3927
+ return files === undefined ? undefined : attachmentBlock(files);
3928
+ }
3929
+ #attached(images) {
3930
+ const resolved = images?.map((image, index) => {
3931
+ if ("data" in image)
3932
+ return image;
3933
+ const shown = this.#shownImage(image.ref);
3934
+ if (shown === undefined)
3935
+ throw new Error(`Image ${index + 1} is no longer available`);
3936
+ return shown;
3937
+ });
3938
+ const problem = imageProblem(resolved ?? []);
3939
+ if (problem !== undefined)
3940
+ throw new Error(problem);
3941
+ return resolved;
3942
+ }
3172
3943
  async#unsubscribe(stream) {
3173
3944
  const subscription = this.#subscriptions.get(stream);
3174
3945
  this.#subscriptions.delete(stream);
@@ -3180,6 +3951,11 @@ class GatewayClient {
3180
3951
  throw new Error(`Conversation ${id} does not exist`);
3181
3952
  return conversation;
3182
3953
  }
3954
+ async#compact(conversation, instructions) {
3955
+ const taskId = await conversation.compact(instructions, context3);
3956
+ this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
3957
+ return taskId;
3958
+ }
3183
3959
  async#answer(id, method, args) {
3184
3960
  try {
3185
3961
  const value = await this.#call(method, args);
@@ -3195,16 +3971,37 @@ class GatewayClient {
3195
3971
  case "removeProject":
3196
3972
  await removeProject(this.#options.harness, args.path, context3);
3197
3973
  return null;
3974
+ case "submit": {
3975
+ const input = args;
3976
+ const images = this.#attached(input.images);
3977
+ const conversation2 = await this.#conversation(input.conversationId);
3978
+ const attachments = await this.#attachmentText(input.attachments);
3979
+ const text = await this.#expandSkills(input.text, { kind: "conversation", conversationId: conversation2.id }, input.skills);
3980
+ const dismissed = await this.#dismissPendingQuestions(conversation2.id, context3);
3981
+ const submission = await conversation2.submit({
3982
+ type: "input",
3983
+ content: messageContent(text, images, attachments),
3984
+ whenBusy: dismissed ? "steer" : input.whenBusy,
3985
+ requestId: input.requestId
3986
+ }, context3);
3987
+ submission.wait(context3).then((settled) => {
3988
+ if (settled.status === "unanswered" && settled.reason !== "aborted") {
3989
+ this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
3990
+ }
3991
+ }, () => {});
3992
+ return { submissionId: submission.id };
3993
+ }
3198
3994
  case "createConversation": {
3199
3995
  const create = args;
3200
- const problem = imageProblem(create.images ?? []);
3201
- if (problem !== undefined)
3202
- throw new Error(problem);
3996
+ const images = this.#attached(create.images);
3997
+ const attachments = await this.#attachmentText(create.attachments);
3998
+ const text = await this.#expandSkills(create.text, create.home.kind === "chat" ? { kind: "chat" } : { kind: "project", path: create.home.path, checkout: create.checkout ?? "worktree" }, create.skills);
3203
3999
  const conversationId = await createConversation(this.#options.harness, this.#options.dataDir, {
3204
4000
  home: create.home,
3205
- text: create.text,
4001
+ text,
3206
4002
  requestId: create.requestId,
3207
- ...create.images === undefined ? {} : { images: create.images },
4003
+ ...images === undefined ? {} : { images },
4004
+ ...attachments === undefined ? {} : { attachmentText: attachments },
3208
4005
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
3209
4006
  ...create.model === undefined ? {} : { model: create.model },
3210
4007
  ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel },
@@ -3283,22 +4080,64 @@ class GatewayClient {
3283
4080
  const input = args;
3284
4081
  return listFiles(this.#options.harness, this.#options.dataDir, this.#files, input.scope, input.query, context3);
3285
4082
  }
4083
+ case "listSkills": {
4084
+ const input = args;
4085
+ return listSkills(this.#options.harness, this.#skills, input.scope, context3);
4086
+ }
4087
+ case "image": {
4088
+ const image = this.#shownImage(args.ref);
4089
+ if (image === undefined)
4090
+ throw new Error("Unknown image");
4091
+ return image;
4092
+ }
4093
+ case "beginAttachment": {
4094
+ const input = args;
4095
+ const store = this.#attachmentStore();
4096
+ return { attachmentId: await store.begin(input.name, input.size) };
4097
+ }
4098
+ case "appendAttachment": {
4099
+ const input = args;
4100
+ const store = this.#attachmentStore();
4101
+ return await store.append(input.attachmentId, input.offset, input.data);
4102
+ }
3286
4103
  }
3287
4104
  const conversation = await this.#conversation(args.conversationId);
3288
4105
  switch (method) {
3289
- case "submit": {
3290
- const { text, whenBusy, requestId, images } = args;
3291
- const problem = imageProblem(images ?? []);
3292
- if (problem !== undefined)
3293
- throw new Error(problem);
3294
- const dismissed = await this.#dismissPendingQuestions(conversation.id, context3);
3295
- const submission = await conversation.submit({ type: "input", content: messageContent(text, images), whenBusy: dismissed ? "steer" : whenBusy, requestId }, context3);
3296
- submission.wait(context3).then((settled) => {
3297
- if (settled.status === "unanswered" && settled.reason !== "aborted") {
3298
- this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
4106
+ case "withdrawQueued": {
4107
+ const { conversationId, submissionId } = args;
4108
+ const id = submissionId;
4109
+ const submission = await this.#options.harness.submission(id, context3);
4110
+ if (submission === undefined)
4111
+ throw new Error(`Submission ${submissionId} does not exist`);
4112
+ assertQueuedInput(await submission.status(context3), conversationId);
4113
+ await this.#options.harness.abortSubmission(id, context3, conversationId);
4114
+ const after = await this.#options.harness.submission(id, context3);
4115
+ if (after === undefined)
4116
+ throw new Error(`Submission ${submissionId} does not exist`);
4117
+ return { outcome: (await after.status(context3)).entry === undefined ? "withdrawn" : "sent" };
4118
+ }
4119
+ case "steerQueued": {
4120
+ const { conversationId, submissionId } = args;
4121
+ const id = submissionId;
4122
+ return await this.#options.harness.commit(async (tx) => {
4123
+ const record = await tx.submission(id);
4124
+ if (record === undefined)
4125
+ throw new Error(`Submission ${submissionId} does not exist`);
4126
+ assertQueuedInput(record, conversationId);
4127
+ if (record.entry !== undefined)
4128
+ return { outcome: "sent" };
4129
+ if (record.status !== "queued")
4130
+ return { outcome: "unavailable" };
4131
+ const inbox = await tx.doc(InboxDoc, conversationId);
4132
+ const item = inbox.items.find((candidate) => candidate.id === id);
4133
+ if (item === undefined) {
4134
+ throw new Error(`Submission ${submissionId} is not queued in the inbox`);
3299
4135
  }
3300
- }, () => {});
3301
- return { submissionId: submission.id };
4136
+ await dismissPendingQuestions(tx, this.#questionDocs(), conversationId);
4137
+ if (item.mode === "followUp")
4138
+ item.mode = "steer";
4139
+ return { outcome: "steered" };
4140
+ }, context3);
3302
4141
  }
3303
4142
  case "renameConversation": {
3304
4143
  const { title } = args;
@@ -3325,29 +4164,44 @@ class GatewayClient {
3325
4164
  return null;
3326
4165
  case "compact": {
3327
4166
  const { instructions } = args;
3328
- const taskId = await conversation.compact(instructions, context3);
3329
- this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
3330
- return { taskId };
4167
+ return { taskId: await this.#compact(conversation, instructions) };
4168
+ }
4169
+ case "listCommands":
4170
+ return listCommands();
4171
+ case "runCommand": {
4172
+ const { name, args: input } = args;
4173
+ await runCommand({ compact: (instructions) => this.#compact(conversation, instructions) }, name, input);
4174
+ return null;
3331
4175
  }
3332
4176
  case "setModel": {
3333
4177
  const { model: ref } = args;
3334
4178
  const model = this.#options.models.getModel(ref.provider, ref.modelId);
3335
4179
  if (model === undefined)
3336
4180
  throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
3337
- const thinking = (await this.#agent(conversation.id)).thinkingLevel ?? "off";
3338
- await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context3);
4181
+ await this.#options.harness.commit(async (tx) => {
4182
+ const state = await tx.doc(AgentDoc5, conversation.id);
4183
+ const thinking = state.thinkingLevel ?? "off";
4184
+ await configure3(tx, conversation.id, { model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) });
4185
+ }, context3);
3339
4186
  return null;
3340
4187
  }
3341
4188
  case "setThinkingLevel": {
3342
4189
  const { level } = args;
3343
- const agent = await this.#agent(conversation.id);
3344
- const model = agent.model === undefined ? undefined : this.#options.models.getModel(agent.model.provider, agent.model.modelId);
3345
- if (model === undefined)
3346
- throw new Error("No model selected");
3347
- if (!getSupportedThinkingLevels(model).includes(level)) {
3348
- throw new Error(`Thinking level ${level} is not supported by ${agent.model.provider}/${agent.model.modelId}`);
3349
- }
3350
- await conversation.configure({ thinkingLevel: level }, context3);
4190
+ await this.#options.harness.commit(async (tx) => {
4191
+ const state = await tx.doc(AgentDoc5, conversation.id);
4192
+ const model = state.model === undefined ? undefined : this.#options.models.getModel(state.model.provider, state.model.modelId);
4193
+ if (model === undefined)
4194
+ throw new Error("No model selected");
4195
+ if (!getSupportedThinkingLevels(model).includes(level)) {
4196
+ throw new Error(`Thinking level ${level} is not supported by ${state.model.provider}/${state.model.modelId}`);
4197
+ }
4198
+ await configure3(tx, conversation.id, { thinkingLevel: level });
4199
+ }, context3);
4200
+ return null;
4201
+ }
4202
+ case "setAgent": {
4203
+ const { conversationId, agent } = args;
4204
+ await setAgent(this.#options.harness, { conversationId, agent }, this.#options.models, this.#options.modelSummaries, this.#options.agents?.value ?? { roles: [], problems: [] }, context3);
3351
4205
  return null;
3352
4206
  }
3353
4207
  case "fork": {
@@ -3376,7 +4230,10 @@ class GatewayClient {
3376
4230
  const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
3377
4231
  const fork = await conversation.fork(at, {
3378
4232
  ownership: { kind: "ownerless" },
3379
- ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
4233
+ ...remove.length === 0 ? {} : { agent: { tools: { remove } } },
4234
+ init: async (tx, id) => {
4235
+ await tx.doc(RoleDoc, id);
4236
+ }
3380
4237
  }, context3);
3381
4238
  return { conversationId: fork.id };
3382
4239
  });
@@ -3420,30 +4277,47 @@ class GatewayClient {
3420
4277
  }
3421
4278
  }
3422
4279
  }
4280
+ async#expandSkills(text, scope, bound) {
4281
+ if (bound === undefined ? !text.includes("$") : bound.length === 0)
4282
+ return text;
4283
+ let skills;
4284
+ try {
4285
+ skills = await scopeSkills(this.#options.harness, this.#skills, scope, context3);
4286
+ } catch (error) {
4287
+ this.send({
4288
+ type: "notice",
4289
+ level: "warning",
4290
+ message: `Skills were not expanded: ${error instanceof Error ? error.message : String(error)}`
4291
+ });
4292
+ return text;
4293
+ }
4294
+ const names = bound ?? mentionedSkillNames(text, skills.map((skill) => skill.name));
4295
+ const expanded = await expandSkills(text, names, skills);
4296
+ for (const name of expanded.missing) {
4297
+ this.send({ type: "notice", level: "warning", message: `Skill "${name}" is not available here; sent without it` });
4298
+ }
4299
+ for (const failure of expanded.failures) {
4300
+ this.send({
4301
+ type: "notice",
4302
+ level: "error",
4303
+ message: `Could not read skill "${failure.name}" (${failure.filePath}): ${failure.message}`
4304
+ });
4305
+ }
4306
+ return expanded.text;
4307
+ }
3423
4308
  async#dismissPendingQuestions(conversationId, context4) {
3424
- const questionDocs = (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
4309
+ const questionDocs = this.#questionDocs();
3425
4310
  const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
3426
4311
  const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
3427
4312
  if (!pending)
3428
4313
  return false;
3429
- return await this.#options.harness.commit(async (tx) => {
3430
- let dismissed = false;
3431
- for (const doc of questionDocs) {
3432
- const value = await tx.doc(doc.token, conversationId);
3433
- if (!Value2.Check(QuestionSchema, value))
3434
- continue;
3435
- for (const request of value.requests) {
3436
- if (request.resolution === undefined) {
3437
- request.resolution = { outcome: "dismissed", at: Date.now() };
3438
- dismissed = true;
3439
- }
3440
- }
3441
- }
3442
- return dismissed;
3443
- }, context4);
4314
+ return await this.#options.harness.commit((tx) => dismissPendingQuestions(tx, questionDocs, conversationId), context4);
4315
+ }
4316
+ #questionDocs() {
4317
+ return (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
3444
4318
  }
3445
4319
  get #chatsPrefix() {
3446
- return join7(this.#options.dataDir, "chats") + sep4;
4320
+ return join8(this.#options.dataDir, "chats") + sep5;
3447
4321
  }
3448
4322
  async#changes(conversation, context4) {
3449
4323
  const cwd = (await conversation.agent(context4)).cwd;
@@ -3456,15 +4330,13 @@ class GatewayClient {
3456
4330
  if (record !== undefined) {
3457
4331
  if (!await worktreeExists(record.path))
3458
4332
  return { available: false, reason: "No changes yet" };
3459
- return { available: true, ...await changesOf(record.path, record.base) };
4333
+ const [changes, branch2] = await Promise.all([changesOf(record.path, record.base), branchOf(record.path)]);
4334
+ return { available: true, ...changes, branch: branch2 };
3460
4335
  }
3461
- const base = await gitBase(cwd);
4336
+ const [base, branch] = await Promise.all([gitBase(cwd), branchOf(cwd)]);
3462
4337
  if (base === undefined)
3463
- return { available: false, reason: "Not in a git repository" };
3464
- return { available: true, ...await changesOf(base.repo, base.base) };
3465
- }
3466
- async#agent(id) {
3467
- return await this.#options.harness.snapshot(AgentDoc4, id, context3) ?? {};
4338
+ return { available: false, reason: "Not in a git repository", branch };
4339
+ return { available: true, ...await changesOf(base.repo, base.base), branch };
3468
4340
  }
3469
4341
  async dispose() {
3470
4342
  if (this.#disposed)
@@ -3477,14 +4349,323 @@ class GatewayClient {
3477
4349
  }
3478
4350
  }
3479
4351
 
4352
+ // apps/host/src/attachments.ts
4353
+ import { randomBytes as randomBytes3 } from "node:crypto";
4354
+ import {
4355
+ chmod,
4356
+ lstat,
4357
+ mkdir as mkdir3,
4358
+ open,
4359
+ readFile as readFile4,
4360
+ readdir as readdir3,
4361
+ rename as rename2,
4362
+ rm as rm3,
4363
+ stat as stat6,
4364
+ writeFile as writeFile2
4365
+ } from "node:fs/promises";
4366
+ import { basename as basename2, join as join9, resolve as resolve6 } from "node:path";
4367
+ var META = "meta.json";
4368
+ var DATA = "file";
4369
+ var CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
4370
+ var FILE_NAME_BYTES = 200;
4371
+ function metaOf(value, id) {
4372
+ if (value === null || typeof value !== "object")
4373
+ return;
4374
+ const meta = value;
4375
+ if (meta.v !== 1)
4376
+ return;
4377
+ if (typeof meta.name !== "string" || typeof meta.fileName !== "string")
4378
+ return;
4379
+ if (typeof meta.size !== "number" || !Number.isSafeInteger(meta.size) || meta.size < 0)
4380
+ return;
4381
+ if (meta.state !== "uploading" && meta.state !== "complete" && meta.state !== "used")
4382
+ return;
4383
+ if (typeof meta.createdAt !== "number" || typeof meta.expiresAt !== "number")
4384
+ return;
4385
+ if (meta.fileName === "" || basename2(meta.fileName) !== meta.fileName || meta.fileName === "." || meta.fileName === "..") {
4386
+ return;
4387
+ }
4388
+ return { ...meta, id };
4389
+ }
4390
+ function displayName2(name) {
4391
+ const chars = Array.from(name);
4392
+ return chars.length <= MAX_ATTACHMENT_NAME_LENGTH ? name : chars.slice(0, MAX_ATTACHMENT_NAME_LENGTH).join("");
4393
+ }
4394
+ function truncateUtf8(text, limit) {
4395
+ let out = "";
4396
+ let bytes = 0;
4397
+ for (const char of text) {
4398
+ const size = Buffer.byteLength(char, "utf8");
4399
+ if (bytes + size > limit)
4400
+ break;
4401
+ out += char;
4402
+ bytes += size;
4403
+ }
4404
+ return out;
4405
+ }
4406
+ function attachmentFileName(name) {
4407
+ let clean = (name.split(/[\\/]/).pop() ?? "").replace(/[\u0000-\u001f\u007f]/g, "_").trim();
4408
+ clean = clean.replace(/^\.+/, "");
4409
+ if (clean === "")
4410
+ return "file";
4411
+ const dot = clean.lastIndexOf(".");
4412
+ const rawExtension = dot > 0 ? clean.slice(dot) : "";
4413
+ const extension = Buffer.byteLength(rawExtension, "utf8") <= 32 ? rawExtension : "";
4414
+ const stem = extension === "" ? clean : clean.slice(0, -extension.length);
4415
+ const extensionBytes = Buffer.byteLength(extension, "utf8");
4416
+ const keptStem = truncateUtf8(stem, Math.max(0, FILE_NAME_BYTES - extensionBytes));
4417
+ const trimmedStem = keptStem.replace(/[\s.]+$/u, "");
4418
+ const candidate = trimmedStem + (trimmedStem === "" ? "" : extension);
4419
+ return candidate === "" ? "file" : candidate;
4420
+ }
4421
+
4422
+ class AttachmentStore {
4423
+ #root;
4424
+ #now;
4425
+ #ttlMs;
4426
+ #pendingLimit;
4427
+ #pendingBytesLimit;
4428
+ #cleanupIntervalMs;
4429
+ #lock = Promise.resolve();
4430
+ #pending = new Map;
4431
+ #timer;
4432
+ constructor(options) {
4433
+ this.#root = resolve6(options.dir);
4434
+ this.#now = options.now ?? Date.now;
4435
+ this.#ttlMs = options.ttlMs ?? ATTACHMENT_TTL_MS;
4436
+ this.#pendingLimit = options.pendingLimit ?? MAX_PENDING_ATTACHMENTS;
4437
+ this.#pendingBytesLimit = options.pendingBytesLimit ?? MAX_PENDING_ATTACHMENT_BYTES;
4438
+ this.#cleanupIntervalMs = options.cleanupIntervalMs ?? CLEANUP_INTERVAL_MS;
4439
+ }
4440
+ get root() {
4441
+ return this.#root;
4442
+ }
4443
+ async open() {
4444
+ await mkdir3(this.#root, { recursive: true });
4445
+ await this.#rebuildPending();
4446
+ await this.sweep();
4447
+ this.#timer = setInterval(() => void this.sweep().catch(() => {}), this.#cleanupIntervalMs);
4448
+ this.#timer.unref();
4449
+ }
4450
+ close() {
4451
+ if (this.#timer !== undefined)
4452
+ clearInterval(this.#timer);
4453
+ this.#timer = undefined;
4454
+ }
4455
+ #run(work) {
4456
+ const next = this.#lock.then(work, work);
4457
+ this.#lock = next.then(() => {}, () => {});
4458
+ return next;
4459
+ }
4460
+ #directory(id) {
4461
+ return join9(this.#root, id);
4462
+ }
4463
+ #dataPath(meta) {
4464
+ return join9(this.#directory(meta.id), DATA, meta.fileName);
4465
+ }
4466
+ async#readMeta(id) {
4467
+ if (!isAttachmentId(id))
4468
+ return;
4469
+ try {
4470
+ return metaOf(JSON.parse(await readFile4(join9(this.#directory(id), META), "utf8")), id);
4471
+ } catch {
4472
+ return;
4473
+ }
4474
+ }
4475
+ async#writeMeta(meta) {
4476
+ const { id, ...value } = meta;
4477
+ const directory = this.#directory(id);
4478
+ const temporary = join9(directory, `.meta.${randomBytes3(6).toString("hex")}.tmp`);
4479
+ await writeFile2(temporary, `${JSON.stringify(value)}
4480
+ `, { mode: 384 });
4481
+ await rename2(temporary, join9(directory, META));
4482
+ }
4483
+ async#rebuildPending() {
4484
+ this.#pending.clear();
4485
+ let entries;
4486
+ try {
4487
+ entries = await readdir3(this.#root, { withFileTypes: true });
4488
+ } catch {
4489
+ return;
4490
+ }
4491
+ for (const entry of entries) {
4492
+ if (!entry.isDirectory())
4493
+ continue;
4494
+ const meta = await this.#readMeta(entry.name);
4495
+ if (meta !== undefined && meta.state !== "used")
4496
+ this.#pending.set(meta.id, meta.size);
4497
+ }
4498
+ }
4499
+ begin(name, size) {
4500
+ return this.#run(async () => {
4501
+ const problem = attachmentBeginProblem(name, size);
4502
+ if (problem !== undefined)
4503
+ throw new Error(problem);
4504
+ let pendingBytes = 0;
4505
+ for (const bytes of this.#pending.values())
4506
+ pendingBytes += bytes;
4507
+ if (this.#pending.size + 1 > this.#pendingLimit) {
4508
+ throw new Error(`Too many pending attachments (${this.#pendingLimit} at most); try again after unused uploads expire`);
4509
+ }
4510
+ if (pendingBytes + size > this.#pendingBytesLimit) {
4511
+ throw new Error(`Pending attachments exceed the ${this.#pendingBytesLimit / (1024 * 1024)} MB budget; try again after unused uploads expire`);
4512
+ }
4513
+ const id = randomBytes3(16).toString("base64url");
4514
+ const directory = this.#directory(id);
4515
+ const meta = {
4516
+ id,
4517
+ v: 1,
4518
+ name: displayName2(name),
4519
+ fileName: attachmentFileName(name),
4520
+ size,
4521
+ state: size === 0 ? "complete" : "uploading",
4522
+ createdAt: this.#now(),
4523
+ expiresAt: this.#now() + this.#ttlMs
4524
+ };
4525
+ try {
4526
+ await mkdir3(join9(directory, DATA), { recursive: true });
4527
+ await writeFile2(this.#dataPath(meta), new Uint8Array(0), { mode: 384 });
4528
+ if (size === 0)
4529
+ await chmod(this.#dataPath(meta), 292);
4530
+ await this.#writeMeta(meta);
4531
+ } catch (error) {
4532
+ await rm3(directory, { recursive: true, force: true }).catch(() => {});
4533
+ throw error;
4534
+ }
4535
+ this.#pending.set(id, size);
4536
+ return id;
4537
+ });
4538
+ }
4539
+ append(id, offset, data) {
4540
+ return this.#run(async () => {
4541
+ const meta = await this.#readMeta(id);
4542
+ if (meta === undefined)
4543
+ throw new Error("Attachment not found or expired; attach it again");
4544
+ if (meta.state === "complete" || meta.state === "used") {
4545
+ return { received: meta.size, complete: true };
4546
+ }
4547
+ const problem = attachmentChunkProblem(data);
4548
+ if (problem !== undefined)
4549
+ throw new Error(problem);
4550
+ const decoded = base64DecodedLength(data);
4551
+ const path = this.#dataPath(meta);
4552
+ const received = (await stat6(path)).size;
4553
+ if (received === meta.size) {
4554
+ await chmod(path, 292);
4555
+ await this.#writeMeta({ ...meta, state: "complete" });
4556
+ return { received, complete: true };
4557
+ }
4558
+ if (offset !== received)
4559
+ return { received, complete: false };
4560
+ if (received + decoded > meta.size)
4561
+ throw new Error("Attachment chunk is larger than the remaining size");
4562
+ const bytes = Buffer.from(data, "base64");
4563
+ const handle = await open(path, "r+");
4564
+ try {
4565
+ const { bytesWritten } = await handle.write(bytes, 0, bytes.length, received);
4566
+ if (bytesWritten !== bytes.length)
4567
+ throw new Error("Could not write the whole attachment chunk");
4568
+ } finally {
4569
+ await handle.close();
4570
+ }
4571
+ const after = received + bytes.length;
4572
+ if (after === meta.size) {
4573
+ await chmod(path, 292);
4574
+ await this.#writeMeta({ ...meta, state: "complete" });
4575
+ }
4576
+ return { received: after, complete: after === meta.size };
4577
+ });
4578
+ }
4579
+ use(ids) {
4580
+ return this.#run(async () => {
4581
+ if (ids === undefined || ids.length === 0)
4582
+ return;
4583
+ if (ids.length > MAX_ATTACHMENTS_PER_MESSAGE) {
4584
+ throw new Error(`At most ${MAX_ATTACHMENTS_PER_MESSAGE} attachments per message`);
4585
+ }
4586
+ const checked = [];
4587
+ for (let index = 0;index < ids.length; index++) {
4588
+ const id = ids[index];
4589
+ const meta = await this.#readMeta(id);
4590
+ if (meta === undefined)
4591
+ throw new Error(`Attachment ${index + 1} has expired; attach it again`);
4592
+ if (meta.state === "uploading")
4593
+ throw new Error(`Attachment ${index + 1} is still uploading`);
4594
+ const path = this.#dataPath(meta);
4595
+ let info;
4596
+ try {
4597
+ info = await lstat(path);
4598
+ } catch {
4599
+ throw new Error(`Attachment ${index + 1} is unavailable; attach it again`);
4600
+ }
4601
+ if (!info.isFile() || info.size !== meta.size) {
4602
+ throw new Error(`Attachment ${index + 1} is unavailable; attach it again`);
4603
+ }
4604
+ checked.push({ meta, path });
4605
+ }
4606
+ for (const { meta } of checked) {
4607
+ if (meta.state === "used") {
4608
+ this.#pending.delete(meta.id);
4609
+ continue;
4610
+ }
4611
+ await this.#writeMeta({ ...meta, state: "used" });
4612
+ this.#pending.delete(meta.id);
4613
+ }
4614
+ return checked.map(({ meta, path }) => ({ id: meta.id, name: meta.name, path, size: meta.size }));
4615
+ });
4616
+ }
4617
+ sweep() {
4618
+ return this.#run(async () => {
4619
+ let removed = 0;
4620
+ let entries;
4621
+ try {
4622
+ entries = await readdir3(this.#root, { withFileTypes: true });
4623
+ } catch {
4624
+ return 0;
4625
+ }
4626
+ const now = this.#now();
4627
+ for (const entry of entries) {
4628
+ if (!entry.isDirectory())
4629
+ continue;
4630
+ const directory = this.#directory(entry.name);
4631
+ const meta = await this.#readMeta(entry.name);
4632
+ if (meta !== undefined) {
4633
+ if (meta.state === "used") {
4634
+ this.#pending.delete(meta.id);
4635
+ continue;
4636
+ }
4637
+ if (meta.expiresAt > now) {
4638
+ this.#pending.set(meta.id, meta.size);
4639
+ continue;
4640
+ }
4641
+ } else {
4642
+ this.#pending.delete(entry.name);
4643
+ try {
4644
+ if (now - (await stat6(directory)).mtimeMs <= this.#ttlMs)
4645
+ continue;
4646
+ } catch {
4647
+ continue;
4648
+ }
4649
+ }
4650
+ try {
4651
+ await rm3(directory, { recursive: true, force: true });
4652
+ this.#pending.delete(entry.name);
4653
+ removed += 1;
4654
+ } catch {}
4655
+ }
4656
+ return removed;
4657
+ });
4658
+ }
4659
+ }
4660
+
3480
4661
  // apps/host/src/host.ts
3481
4662
  var context4 = BACKGROUND_CONTEXT4;
3482
4663
  async function hostToken(dataDir) {
3483
- const path = join8(dataDir, "token");
4664
+ const path = join10(dataDir, "token");
3484
4665
  try {
3485
- return (await readFile4(path, "utf8")).trim();
4666
+ return (await readFile5(path, "utf8")).trim();
3486
4667
  } catch {
3487
- const token = randomBytes3(24).toString("base64url");
4668
+ const token = randomBytes4(24).toString("base64url");
3488
4669
  await writeFile3(path, `${token}
3489
4670
  `, { mode: 384 });
3490
4671
  return token;
@@ -3503,13 +4684,16 @@ async function openHost(options) {
3503
4684
  let harness;
3504
4685
  let mcp;
3505
4686
  let agentRoles;
4687
+ let attachments;
3506
4688
  let extensions = [];
3507
4689
  try {
3508
4690
  const token = await hostToken(options.dataDir);
4691
+ attachments = new AttachmentStore({ dir: join10(options.dataDir, "attachments") });
4692
+ await attachments.open();
3509
4693
  const reports = [];
3510
4694
  let report = (error) => void reports.push(error);
3511
4695
  const registry = createRegistry();
3512
- agentRoles = await openAgentRoles({ dir: join8(options.dataDir, "agents") });
4696
+ agentRoles = await openAgentRoles({ dir: join10(options.dataDir, "agents") });
3513
4697
  extensions = typeof options.extensions === "function" ? options.extensions({
3514
4698
  harness: () => {
3515
4699
  if (harness === undefined)
@@ -3532,13 +4716,13 @@ async function openHost(options) {
3532
4716
  }
3533
4717
  });
3534
4718
  const hostSettings = await openHostSettings({
3535
- path: join8(options.dataDir, "settings.json"),
4719
+ path: join10(options.dataDir, "settings.json"),
3536
4720
  fallback: options.settingsFallback,
3537
4721
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
3538
4722
  });
3539
4723
  const environments = new Map;
3540
- const chatsDir = join8(options.dataDir, "chats");
3541
- harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join8(options.dataDir, "session.sqlite")), {
4724
+ const chatsDir = join10(options.dataDir, "chats");
4725
+ harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join10(options.dataDir, "session.sqlite")), {
3542
4726
  models: options.models,
3543
4727
  registry,
3544
4728
  env: async (target) => {
@@ -3546,7 +4730,7 @@ async function openHost(options) {
3546
4730
  if (cwd === undefined)
3547
4731
  return;
3548
4732
  const worktrees = worktreeRoot(options.dataDir);
3549
- if (cwd === worktrees || cwd.startsWith(worktrees + sep5)) {
4733
+ if (cwd === worktrees || cwd.startsWith(worktrees + sep6)) {
3550
4734
  const index = await target.read.snapshot(IndexDoc, context4);
3551
4735
  const record = checkoutAt(index?.checkouts, cwd);
3552
4736
  if (record !== undefined) {
@@ -3579,8 +4763,10 @@ async function openHost(options) {
3579
4763
  harness,
3580
4764
  models: options.models,
3581
4765
  modelSummaries: options.modelSummaries,
3582
- session: { id: basename2(options.dataDir), directory: options.dataDir },
4766
+ session: { id: basename3(options.dataDir), directory: options.dataDir },
3583
4767
  dataDir: options.dataDir,
4768
+ attachments,
4769
+ agentsHome: options.agentsHome,
3584
4770
  settings: hostSettings,
3585
4771
  token,
3586
4772
  port: options.port,
@@ -3645,6 +4831,7 @@ async function openHost(options) {
3645
4831
  await gateway.close();
3646
4832
  await opened.close(context4);
3647
4833
  await mcp?.close();
4834
+ attachments?.close();
3648
4835
  agentRoles?.close();
3649
4836
  for (const each of extensions)
3650
4837
  await each.close?.();
@@ -3659,6 +4846,7 @@ async function openHost(options) {
3659
4846
  };
3660
4847
  } catch (error) {
3661
4848
  options.modelAuth?.close();
4849
+ attachments?.close();
3662
4850
  agentRoles?.close();
3663
4851
  for (const each of extensions)
3664
4852
  await each.close?.().catch(() => {});
@@ -3669,9 +4857,71 @@ async function openHost(options) {
3669
4857
  }
3670
4858
  }
3671
4859
 
4860
+ // apps/host/src/service/log-rotation.ts
4861
+ import { copyFile, rename as rename3, rm as rm4, stat as stat7, truncate } from "node:fs/promises";
4862
+ import { join as join11 } from "node:path";
4863
+ var HOST_LOG_MAX_BYTES = 10 * 1024 * 1024;
4864
+ var HOST_LOG_HISTORY = 3;
4865
+ var HOST_LOG_CHECK_INTERVAL_MS = 10 * 60 * 1000;
4866
+ var hostLogPath = (dataDir) => join11(dataDir, "logs", "host.log");
4867
+ var rotationTempPath = (log) => `${log}.rotating`;
4868
+ var isMissing = (error) => error.code === "ENOENT";
4869
+ async function rotateHostLog(dataDir, copy = copyFile) {
4870
+ const log = hostLogPath(dataDir);
4871
+ const temp = rotationTempPath(log);
4872
+ await rm4(temp, { force: true });
4873
+ let size;
4874
+ try {
4875
+ size = (await stat7(log)).size;
4876
+ } catch (error) {
4877
+ if (isMissing(error))
4878
+ return false;
4879
+ throw error;
4880
+ }
4881
+ if (size <= HOST_LOG_MAX_BYTES)
4882
+ return false;
4883
+ try {
4884
+ await copy(log, temp);
4885
+ } catch (error) {
4886
+ await rm4(temp, { force: true }).catch(() => {});
4887
+ throw error;
4888
+ }
4889
+ await rm4(`${log}.${HOST_LOG_HISTORY}`, { force: true });
4890
+ for (let index = HOST_LOG_HISTORY - 1;index >= 1; index -= 1) {
4891
+ try {
4892
+ await rename3(`${log}.${index}`, `${log}.${index + 1}`);
4893
+ } catch (error) {
4894
+ if (!isMissing(error))
4895
+ throw error;
4896
+ }
4897
+ }
4898
+ await rename3(temp, `${log}.1`);
4899
+ await truncate(log, 0);
4900
+ return true;
4901
+ }
4902
+ function startHostLogRotation(dataDir) {
4903
+ let running = false;
4904
+ const check = () => {
4905
+ if (running)
4906
+ return;
4907
+ running = true;
4908
+ rotateHostLog(dataDir).catch((error) => {
4909
+ const message = error instanceof Error ? error.message : String(error);
4910
+ process.stderr.write(`${JSON.stringify({ event: "log-rotation-failed", error: message })}
4911
+ `);
4912
+ }).finally(() => {
4913
+ running = false;
4914
+ });
4915
+ };
4916
+ check();
4917
+ const timer = setInterval(check, HOST_LOG_CHECK_INTERVAL_MS);
4918
+ timer.unref();
4919
+ return () => clearInterval(timer);
4920
+ }
4921
+
3672
4922
  // apps/host/src/model-auth.ts
3673
- import { existsSync as existsSync2, watch as watch2 } from "node:fs";
3674
- import { basename as basename3, dirname as dirname3 } from "node:path";
4923
+ import { existsSync as existsSync3, watch as watch2 } from "node:fs";
4924
+ import { basename as basename4, dirname as dirname3 } from "node:path";
3675
4925
 
3676
4926
  // apps/host/src/pi-setup.ts
3677
4927
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -3786,7 +5036,7 @@ function modelSummaries(modelRuntime) {
3786
5036
  }
3787
5037
 
3788
5038
  // apps/host/src/model-auth.ts
3789
- 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);
5039
+ 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);
3790
5040
  var sameLogin = (a, b) => a.providerId === b.providerId && a.name === b.name && a.kind === b.kind && a.subscription === b.subscription;
3791
5041
  async function storedLogins(modelRuntime) {
3792
5042
  const credentials = await modelRuntime.listCredentials();
@@ -3801,9 +5051,9 @@ async function storedLogins(modelRuntime) {
3801
5051
  }
3802
5052
  async function watchModelAuth(modelRuntime, options) {
3803
5053
  const debounceMs = options.debounceMs ?? 150;
3804
- const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel2));
5054
+ const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel));
3805
5055
  const auth = new Published(await storedLogins(modelRuntime), (a, b) => sameList(a.logins, b.logins, sameLogin));
3806
- const fileName = basename3(options.authPath);
5056
+ const fileName = basename4(options.authPath);
3807
5057
  let watcher;
3808
5058
  let timer;
3809
5059
  let reported = false;
@@ -3821,7 +5071,7 @@ async function watchModelAuth(modelRuntime, options) {
3821
5071
  }
3822
5072
  };
3823
5073
  const directory = dirname3(options.authPath);
3824
- if (existsSync2(directory)) {
5074
+ if (existsSync3(directory)) {
3825
5075
  watcher = watch2(directory, (_event, changed) => {
3826
5076
  if (closed || changed !== null && changed !== fileName)
3827
5077
  return;
@@ -3871,7 +5121,7 @@ async function main(argv) {
3871
5121
  relay: { type: "string" }
3872
5122
  }
3873
5123
  });
3874
- const dataDir = resolve4(values["data-dir"] ?? DEFAULT_DATA_DIR);
5124
+ const dataDir = resolve7(values["data-dir"] ?? DEFAULT_DATA_DIR);
3875
5125
  let relayOrigin;
3876
5126
  try {
3877
5127
  relayOrigin = resolveRelayOrigin(values.relay, await readServerConfig(dataDir));
@@ -3879,9 +5129,10 @@ async function main(argv) {
3879
5129
  console.error(error instanceof Error ? error.message : String(error));
3880
5130
  process.exit(1);
3881
5131
  }
5132
+ const agentsHome = join12(homedir3(), ".agents");
3882
5133
  const extensionsFor = (models, summaries, options2) => (host2) => [
3883
5134
  createContext({
3884
- agentsHome: join9(homedir3(), ".agents"),
5135
+ agentsHome,
3885
5136
  checkout: (input, context5) => checkoutInfo(host2.harness(), input, context5)
3886
5137
  }),
3887
5138
  createCoding(options2),
@@ -3897,9 +5148,10 @@ async function main(argv) {
3897
5148
  ];
3898
5149
  const common = {
3899
5150
  dataDir,
5151
+ agentsHome,
3900
5152
  projects: values.project ?? [],
3901
5153
  port: Number(values.port),
3902
- mcpConfig: join9(homedir3(), ".agents", "mcp.json"),
5154
+ mcpConfig: join12(agentsHome, "mcp.json"),
3903
5155
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
3904
5156
  webRoot,
3905
5157
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -3943,7 +5195,7 @@ async function main(argv) {
3943
5195
  }
3944
5196
  async function piOptions() {
3945
5197
  const modelRuntime = await ModelRuntime.create();
3946
- const modelAuth = await watchModelAuth(modelRuntime, { authPath: join9(getAgentDir(), "auth.json") });
5198
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join12(getAgentDir(), "auth.json") });
3947
5199
  const settingsManager = SettingsManager.create(process.cwd());
3948
5200
  configureHarnessHttp(settingsManager);
3949
5201
  const summaries = () => modelAuth.models.value;
@@ -3969,13 +5221,14 @@ async function main(argv) {
3969
5221
  }
3970
5222
  const options = values.faux !== undefined ? fauxOptions(() => () => fauxAssistantMessage(values.faux)) : await piOptions();
3971
5223
  const host = await openHost(options);
5224
+ const stopLogRotation = startHostLogRotation(dataDir);
3972
5225
  const { defaultModel: effectiveModel } = host.settings.value;
3973
5226
  const model = effectiveModel === null ? null : `${effectiveModel.provider}/${effectiveModel.modelId}`;
3974
5227
  const web = packagedVersion === undefined ? "http://127.0.0.1:5199/" : new URL(host.url.replace(/^ws/, "http")).href;
3975
5228
  console.log(JSON.stringify({
3976
5229
  event: "ready",
3977
5230
  url: host.url,
3978
- tokenFile: join9(dataDir, "token"),
5231
+ tokenFile: join12(dataDir, "token"),
3979
5232
  dataDir,
3980
5233
  model,
3981
5234
  web,
@@ -3987,6 +5240,7 @@ async function main(argv) {
3987
5240
  if (stopping)
3988
5241
  return;
3989
5242
  stopping = true;
5243
+ stopLogRotation();
3990
5244
  host.close().finally(() => process.exit(0));
3991
5245
  };
3992
5246
  process.on("SIGINT", stop);
@@ -3995,6 +5249,7 @@ async function main(argv) {
3995
5249
  if (end.reason !== "failed" || stopping)
3996
5250
  return;
3997
5251
  stopping = true;
5252
+ stopLogRotation();
3998
5253
  const error = end.error instanceof Error ? end.error.message : String(end.error);
3999
5254
  process.stderr.write(`${JSON.stringify({ event: "fatal", error })}
4000
5255
  `);