pinomad 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,17 @@ import {
5
5
  import {
6
6
  packagedVersion,
7
7
  webRoot
8
- } from "./chunk-fpb6f9n5.js";
8
+ } from "./chunk-3tf23vjq.js";
9
9
  import {
10
- PROTOCOL_VERSION,
10
+ HOST_SETTING_KEYS,
11
+ INVALID_FRAME_CLOSE_CODE,
12
+ PROTOCOL,
13
+ THINKING_LEVELS,
11
14
  UNAUTHORIZED_CLOSE_CODE,
12
- isClientFrame
13
- } from "./chunk-wgn3cx5q.js";
15
+ isQueueMode,
16
+ isThinkingLevel,
17
+ parseClientFrame
18
+ } from "./chunk-c155p0t0.js";
14
19
  import {
15
20
  DevicesDoc,
16
21
  ensureDevices,
@@ -40,12 +45,13 @@ import {
40
45
  import"./chunk-df32avw6.js";
41
46
 
42
47
  // apps/host/src/main.ts
43
- import { homedir as homedir2 } from "node:os";
44
- import { join as join6, resolve as resolve3 } from "node:path";
48
+ import { homedir as homedir3 } from "node:os";
49
+ import { join as join8, resolve as resolve3 } from "node:path";
45
50
  import { parseArgs } from "node:util";
46
51
  import { createModels } from "@earendil-works/pi-ai/models";
47
52
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
48
- import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
53
+ import { getAgentDir, ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
54
+ import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
49
55
 
50
56
  // apps/host/src/extensions/question.ts
51
57
  import {
@@ -92,6 +98,7 @@ var QuestionRequestSchema = Type.Object({
92
98
  var QuestionSchema = Type.Object({ requests: Type.Array(QuestionRequestSchema) });
93
99
  var DiffSchema = Type.Object({ patch: Type.String() });
94
100
  var CodemodeCallSchema = Type.Object({
101
+ id: Type.Optional(Type.Number()),
95
102
  name: Type.String(),
96
103
  args: Type.String(),
97
104
  status: Type.Union([Type.Literal("running"), Type.Literal("ok"), Type.Literal("error"), Type.Literal("cancelled")]),
@@ -99,7 +106,7 @@ var CodemodeCallSchema = Type.Object({
99
106
  error: Type.Optional(Type.String()),
100
107
  details: Type.Optional(Type.Unknown())
101
108
  });
102
- var CodemodeSchema = Type.Object({ code: Type.String(), calls: Type.Array(CodemodeCallSchema) });
109
+ var CodemodeSchema = Type.Object({ code: Type.Optional(Type.String()), calls: Type.Array(CodemodeCallSchema) });
103
110
 
104
111
  // apps/host/src/extensions/question.ts
105
112
  var QuestionDoc = defineDoc({
@@ -168,10 +175,11 @@ function result(questions, resolution) {
168
175
  } else {
169
176
  text = "Question cancelled.";
170
177
  }
171
- return { content: [{ type: "text", text }], details: { resolution } };
178
+ return { output: [{ type: "text", text }], details: { resolution } };
172
179
  }
173
180
  var askUserQuestion = defineTool({
174
181
  name: "ask_user_question",
182
+ callers: ["model"],
175
183
  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.",
176
184
  parameters: Type2.Object({
177
185
  questions: Type2.Array(QuestionItemSchema, { minItems: 1, maxItems: 4 })
@@ -198,13 +206,12 @@ var askUserQuestion = defineTool({
198
206
  });
199
207
  var question = {
200
208
  extension: defineExtension({ name: "question", tools: [askUserQuestion], tasks: [QuestionWait] }),
201
- modelOnly: ["ask_user_question"],
202
209
  docs: [{ token: QuestionDoc, presentation: "pinomad.question" }]
203
210
  };
204
211
 
205
212
  // apps/host/src/extensions/coding.ts
206
213
  import { wrapTool } from "@earendil-works/pi-durable";
207
- import { CodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
214
+ import { createCodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
208
215
  import { createPatch } from "diff";
209
216
  var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
210
217
  ...tool,
@@ -220,433 +227,68 @@ var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
220
227
  return { ...result2, details: { patch: createPatch(args.path, oldContent, args.content) } };
221
228
  }
222
229
  }));
223
- var coding = {
224
- extension: { ...CodingTools, wraps: [writeWithDiff] },
225
- tools: { edit: "pinomad.diff", write: "pinomad.diff" }
226
- };
230
+ function createCoding(options) {
231
+ return {
232
+ extension: { ...createCodingTools(options), wraps: [writeWithDiff] },
233
+ tools: { edit: "pinomad.diff", write: "pinomad.diff" }
234
+ };
235
+ }
236
+ var coding = createCoding();
227
237
 
228
238
  // apps/host/src/extensions/codemode.ts
229
- import { withAbortSignal } from "@earendil-works/chord/context";
230
- import {
231
- CodemodeSandbox,
232
- CodemodeSourceError,
233
- loadQuickJSWasm,
234
- parseCodemodeSource,
235
- renderToolSignature
236
- } from "@earendil-works/pi-codemode";
237
- import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
238
- import {
239
- defineExtension as defineExtension2,
240
- defineTool as defineTool2,
241
- section
242
- } from "@earendil-works/pi-durable";
243
- import { Type as Type3 } from "typebox";
239
+ import { CodemodeSandbox, loadQuickJSWasm } from "@earendil-works/pi-codemode";
240
+ import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
244
241
  var wasm = loadQuickJSWasm();
245
- var OUTPUT_CAP = 1024 * 1024;
246
- var OUTPUT_HALF = OUTPUT_CAP / 2;
247
-
248
- class NestedOutput {
249
- #head = "";
250
- #tail = "";
251
- #dropped = 0;
252
- #decoder = new TextDecoder;
253
- push(chunk) {
254
- const text = typeof chunk === "string" ? chunk : this.#decoder.decode(chunk, { stream: true });
255
- if (this.#dropped === 0 && this.#head.length + text.length <= OUTPUT_CAP) {
256
- this.#head += text;
257
- return;
258
- }
259
- if (this.#dropped === 0) {
260
- const rest = this.#head.slice(OUTPUT_HALF) + text;
261
- this.#head = this.#head.slice(0, OUTPUT_HALF);
262
- this.#dropped = Math.max(0, rest.length - OUTPUT_HALF);
263
- this.#tail = rest.slice(-OUTPUT_HALF);
264
- return;
265
- }
266
- const merged = this.#tail + text;
267
- this.#dropped += Math.max(0, merged.length - OUTPUT_HALF);
268
- this.#tail = merged.slice(-OUTPUT_HALF);
269
- }
270
- text() {
271
- const tail = this.#tail + this.#decoder.decode();
272
- return this.#dropped === 0 ? this.#head + tail : `${this.#head}
273
- [... ${this.#dropped} characters omitted ...]
274
- ${tail}`;
275
- }
276
- }
277
- var summarizeArgs = (args) => {
278
- let text;
279
- try {
280
- text = JSON.stringify(args) ?? "null";
281
- } catch {
282
- text = String(args);
283
- }
284
- return text.length <= 200 ? text : `${text.slice(0, 199)}…`;
285
- };
286
- var errorText = (error) => error instanceof Error ? error.message : String(error);
287
- var textOf = (content) => content.flatMap((block) => block.type === "text" ? [block.text] : []).join("");
288
- var TOKENIZE = /[^a-z0-9]+/;
289
- var tokens = (text) => text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(TOKENIZE).filter(Boolean);
290
- function search(callables, query, limit, namespace) {
291
- const docs = [...callables].filter((tool) => namespace === undefined || tool.namespace?.name === namespace).map((tool) => {
292
- const text = [tool.name, tool.description, tool.namespace?.name, tool.namespace?.description].filter(Boolean).join(" ");
293
- return { tool, terms: tokens(text) };
294
- });
295
- const avgLength = docs.length === 0 ? 1 : docs.reduce((sum, doc) => sum + doc.terms.length, 0) / docs.length;
296
- const frequency = new Map;
297
- for (const doc of docs)
298
- for (const term of new Set(doc.terms))
299
- frequency.set(term, (frequency.get(term) ?? 0) + 1);
300
- const scored = docs.map((doc) => {
301
- const counts = new Map;
302
- for (const term of doc.terms)
303
- counts.set(term, (counts.get(term) ?? 0) + 1);
304
- let score = 0;
305
- for (const term of tokens(query)) {
306
- const df = frequency.get(term);
307
- if (df === undefined)
308
- continue;
309
- const idf = Math.log(1 + (docs.length - df + 0.5) / (df + 0.5));
310
- const tf = counts.get(term) ?? 0;
311
- score += idf * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * doc.terms.length / avgLength));
312
- }
313
- return { tool: doc.tool, score };
314
- });
315
- return scored.filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, limit).map((entry) => ({
316
- name: entry.tool.name,
317
- description: entry.tool.description.length <= 300 ? entry.tool.description : `${entry.tool.description.slice(0, 299)}…`
318
- }));
319
- }
320
- function createCodemode(options) {
321
- const excluded = new Set([...options.modelOnly, "codemode"]);
322
- const modelOnlyList = [...excluded].join(", ");
323
- const tool = defineTool2({
324
- name: "codemode",
325
- description: DESCRIPTION.replace("{MODEL_ONLY}", modelOnlyList),
326
- parameters: Type3.Object({
327
- code: Type3.String({ minLength: 1, description: "Raw JavaScript source; see the tool description." })
328
- }),
329
- execute: async (args, api, context) => {
330
- let parsed;
331
- try {
332
- parsed = parseCodemodeSource(args.code);
333
- } catch (error) {
334
- if (error instanceof CodemodeSourceError) {
335
- return { content: [{ type: "text", text: `Script failed: ${error.message}` }], isError: true };
336
- }
337
- throw error;
338
- }
339
- const agent = await api.agent(context);
340
- const callables = new Map;
341
- for (const offered of agent.tools) {
342
- if (!excluded.has(offered.name))
343
- callables.set(offered.name, { kind: "agent", tool: offered });
344
- }
345
- for (const scriptTool of options.scriptTools())
346
- callables.set(scriptTool.name, { kind: "script", tool: scriptTool });
347
- const calls = [];
348
- let detailsBudget = 512 * 1024;
349
- const snapshot = () => ({
350
- code: parsed.code,
351
- calls: calls.map((call) => ({ ...call }))
352
- });
353
- const publish = () => {
354
- api.details(snapshot(), context).catch(() => {});
355
- };
356
- const executeNested = async (name, callArgs, signal) => {
357
- const record = { name, args: summarizeArgs(callArgs), status: "running" };
358
- calls.push(record);
359
- publish();
360
- const started2 = Date.now();
361
- try {
362
- const callable = callables.get(name);
363
- if (callable === undefined)
364
- throw new Error(`Unknown tool "${name}"`);
365
- const value = callable.kind === "script" ? await callable.tool.call(callArgs, signal) : await runAgentTool(callable.tool, callArgs, signal, record, calls.length - 1);
366
- record.status = signal.aborted ? "cancelled" : "ok";
367
- return value;
368
- } catch (error) {
369
- record.status = signal.aborted ? "cancelled" : "error";
370
- record.error = errorText(error).slice(0, 500);
371
- throw error;
372
- } finally {
373
- record.durationMs = Date.now() - started2;
374
- publish();
375
- }
376
- };
377
- const runAgentTool = async (offered, callArgs, signal, record, index) => {
378
- const prepared = offered.prepareArguments === undefined ? callArgs : offered.prepareArguments(callArgs);
379
- const validated = validateToolArguments(offered, {
380
- type: "toolCall",
381
- id: `${api.callId}/${index}`,
382
- name: offered.name,
383
- arguments: prepared
384
- });
385
- const output = new NestedOutput;
386
- const diagnostics = [];
387
- let reported;
388
- const derived = {
389
- ...api,
390
- callId: `${api.callId}/${index}`,
391
- output: (chunk) => output.push(chunk),
392
- outputWindow: undefined,
393
- diagnostic: (diagnostic) => diagnostics.push(`[${diagnostic.severity}] ${diagnostic.message}`),
394
- details: async (value) => {
395
- reported = value;
396
- }
397
- };
398
- let result2;
399
- try {
400
- result2 = await offered.execute(validated, derived, withAbortSignal(signal, context));
401
- } catch (error) {
402
- if (signal.aborted)
403
- throw error;
404
- throw new Error(`${[output.text(), errorText(error)].filter(Boolean).join(`
405
- `)}`.trim());
406
- }
407
- const captured = result2.details ?? reported;
408
- if (captured !== undefined && options.presentations[offered.name] !== undefined) {
409
- try {
410
- const size = JSON.stringify(captured).length;
411
- if (size <= 64 * 1024 && detailsBudget >= size) {
412
- detailsBudget -= size;
413
- record.details = captured;
414
- }
415
- } catch {}
416
- }
417
- const collected = [output.text(), ...diagnostics].filter(Boolean).join(`
418
- `);
419
- const content = result2.content ?? [{ type: "text", text: collected }];
420
- if (result2.isError === true)
421
- throw new Error(textOf(content) || collected);
422
- const texts = content.filter((block) => block.type === "text");
423
- return texts.length === content.length ? texts.map((block) => block.text).join("") : content;
424
- };
425
- const globals = [
426
- {
427
- name: "searchTools",
428
- spread: true,
429
- description: "Search callable tools by relevance.",
430
- execute: (callArgs) => {
431
- const [query, opts] = callArgs;
432
- return search([...callables.values()].map((callable) => callable.kind === "script" ? { name: callable.tool.name, description: callable.tool.description, namespace: callable.tool.namespace } : { name: callable.tool.name, description: callable.tool.description ?? "", namespace: undefined }), String(query ?? ""), typeof opts?.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 8, opts?.namespace);
433
- }
434
- },
435
- {
436
- name: "describeTool",
437
- description: "The declaration of one callable tool, or undefined.",
438
- execute: (callArgs) => {
439
- const callable = callables.get(String(callArgs));
440
- if (callable === undefined)
441
- return;
442
- return {
443
- name: callable.tool.name,
444
- description: callable.tool.description,
445
- declaration: renderToolSignature(callable.kind === "script" ? { name: callable.tool.name, inputSchema: callable.tool.inputSchema, outputSchema: callable.tool.outputSchema } : { name: callable.tool.name, inputSchema: callable.tool.parameters })
446
- };
447
- }
448
- },
449
- {
450
- name: "describeNamespace",
451
- description: "The tools of one script-only namespace, or undefined.",
452
- execute: (callArgs) => {
453
- const wanted = String(callArgs);
454
- const members = options.scriptTools().filter((tool2) => tool2.namespace?.name === wanted);
455
- if (members.length === 0)
456
- return;
457
- const first = members[0].namespace;
458
- return {
459
- name: wanted,
460
- ...first.description === undefined ? {} : { description: first.description },
461
- ...first.instructions === undefined ? {} : { instructions: first.instructions },
462
- tools: members.map((tool2) => ({ name: tool2.name, description: tool2.description }))
463
- };
464
- }
465
- }
466
- ];
467
- const sandbox = new CodemodeSandbox({
468
- tools: [...callables.values()].map((callable) => ({
469
- name: callable.tool.name,
470
- ...callable.tool.description === undefined ? {} : { description: callable.tool.description },
471
- inputSchema: callable.kind === "script" ? callable.tool.inputSchema : callable.tool.parameters,
472
- ...callable.kind === "script" && callable.tool.outputSchema !== undefined ? { outputSchema: callable.tool.outputSchema } : {},
473
- execute: (callArgs, { signal }) => executeNested(callable.tool.name, callArgs, signal)
474
- })),
475
- globals,
476
- memoryLimitBytes: 256 * 1024 * 1024,
477
- timeoutMs: parsed.options.timeoutMs ?? Infinity,
478
- wasm
479
- });
480
- const started = Date.now();
481
- try {
482
- const result2 = await sandbox.execute(parsed.code, { signal: context.abortSignal });
483
- context.abortSignal?.throwIfAborted();
484
- return {
485
- content: resultContent(result2, started, parsed.options.maxOutputTokens),
486
- isError: !result2.ok,
487
- details: snapshot()
488
- };
489
- } finally {
490
- await sandbox.close();
491
- }
492
- }
493
- });
242
+ function createCodemode() {
243
+ const sandbox = new CodemodeSandbox({ wasm, memoryLimitBytes: 256 * 1024 * 1024 });
244
+ const namespaced = (namespaces) => createCodemodeExtension({ sandbox, namespaces });
494
245
  return {
495
- extension: defineExtension2({
496
- name: "codemode",
497
- tools: [tool],
498
- sections: [
499
- section("codemode", (input) => {
500
- const offered = new Set(input.agent.tools.map((each) => each.name));
501
- const namespaces = new Map;
502
- for (const tool2 of options.scriptTools()) {
503
- if (tool2.namespace === undefined)
504
- continue;
505
- const entry = namespaces.get(tool2.namespace.name) ?? { scriptOnly: 0 };
506
- namespaces.set(tool2.namespace.name, {
507
- description: entry.description ?? tool2.namespace.description,
508
- scriptOnly: entry.scriptOnly + (offered.has(tool2.name) ? 0 : 1)
509
- });
510
- }
511
- const scriptOnly = [...namespaces.entries()].filter(([, entry]) => entry.scriptOnly > 0).sort(([a], [b]) => a.localeCompare(b));
512
- let text = `Every tool declared to you except ${modelOnlyList} can also be called from codemode scripts as tools.<name>(args), with the same arguments.`;
513
- if (scriptOnly.length > 0) {
514
- const lines = scriptOnly.map(([name, entry]) => {
515
- const firstLine = entry.description?.split(`
516
- `)[0];
517
- const clipped = firstLine === undefined ? "" : `: ${firstLine.slice(0, 200)}`;
518
- return `- ${name} (${entry.scriptOnly} tools)${clipped}`;
519
- });
520
- text += `
521
-
522
- These namespaces are reachable only from codemode scripts; find their tools with describeNamespace(name) or searchTools(query):
523
- ${lines.join(`
524
- `)}`;
525
- }
526
- return text;
527
- })
528
- ]
529
- }),
246
+ extension: namespaced([]),
530
247
  tools: { codemode: "pinomad.codemode" },
531
- modelOnly: ["codemode"]
248
+ namespaced,
249
+ close: () => sandbox.close()
532
250
  };
533
251
  }
534
- var DESCRIPTION = `Run a JavaScript script that calls other tools; only what the script outputs reaches you. Use it to run several tool calls in parallel (Promise.all / Promise.allSettled), to filter or aggregate large results before you see them, and to call tools that are only reachable from scripts, such as most MCP tools.
535
-
536
- \`code\` is raw JavaScript, not JSON and not a markdown fence. It runs as the body of an async function in a QuickJS sandbox, so top-level await and return work. There are no Node APIs, file system, network, timers, or modules: scripts reach the outside world only through tools. The script may start with one options line: // @options: {"max_output_tokens": 2000, "timeout_ms": 60000}. max_output_tokens defaults to 10000; longer output keeps its start and end. timeout_ms is unset by default.
537
-
538
- Globals:
539
- - tools.<name>(args): call a tool with one arguments object. Characters that are invalid in identifiers become _, so mcp__my-server__search is tools.mcp__my_server__search.
540
- - text(value): add to the output; strings as is, other values as JSON. console.log(...) and a top-level \`return value\` do the same.
541
- - image(block): add an image from an image block { type: "image", data, mimeType } or a base64 data: URL.
542
- - exit(): end the script successfully.
543
- - ALL_TOOLS: every callable tool as { name, description }.
544
- - searchTools(query, { limit?, namespace? }): resolves to the callable tools ranked by relevance (default limit 8), as { name, description }[].
545
- - describeTool(name): resolves to { name, description, declaration } with the tool's TypeScript declaration, or undefined.
546
- - describeNamespace(name): resolves to { name, description?, instructions?, tools } for a namespace such as an MCP server, or undefined.
547
- These three return promises: await them.
548
-
549
- What a call resolves to: MCP tools resolve to their CallToolResult { content, isError?, structuredContent? }, also when isError is true. Other tools resolve to their text output, or to an array of text and image blocks when the output has images. A call that fails or gets invalid arguments rejects with an Error carrying the tool's error text; use Promise.allSettled to keep the calls that succeed. Tool calls are real: calls made before a failure are not undone, and calls still running when the script ends are cancelled.
550
-
551
- Call describeTool before using a tool whose arguments you have not seen. Scripts cannot call {MODEL_ONLY}.`;
552
- function resultContent(result2, started, maxOutputTokens) {
553
- const seconds = ((Date.now() - started) / 1000).toFixed(1);
554
- const items = [
555
- { type: "text", text: result2.ok ? `Script completed in ${seconds}s` : `Script failed in ${seconds}s` }
556
- ];
557
- for (const item of result2.output) {
558
- items.push(item.type === "text" ? { type: "text", text: item.text } : { type: "image", data: item.data, mimeType: item.mimeType });
559
- }
560
- if (result2.ok && result2.value !== undefined) {
561
- items.push({ type: "text", text: typeof result2.value === "string" ? result2.value : JSON.stringify(result2.value) });
562
- }
563
- if (!result2.ok) {
564
- const error = result2.error;
565
- items.push({
566
- type: "text",
567
- text: error.kind === "timeout" ? "Script error: timed out" : error.kind === "aborted" ? "Script error: aborted" : `Script error: ${error.name ?? "Error"}: ${error.message}`
568
- });
569
- }
570
- return truncateText(mergeText(items), (maxOutputTokens ?? 1e4) * 4);
571
- }
572
- function mergeText(items) {
573
- const merged = [];
574
- for (const item of items) {
575
- const last = merged.at(-1);
576
- if (item.type === "text" && last?.type === "text")
577
- merged[merged.length - 1] = { type: "text", text: `${last.text}
578
- ${item.text}` };
579
- else
580
- merged.push(item);
581
- }
582
- return merged;
583
- }
584
- function truncateText(items, budget) {
585
- const total = items.reduce((sum, item) => sum + (item.type === "text" ? item.text.length : 0), 0);
586
- if (total <= budget)
587
- return items;
588
- const headBudget = Math.floor(budget / 2);
589
- const tailStart = total - (budget - headBudget);
590
- const omitted = total - headBudget - (total - tailStart);
591
- const out = [];
592
- let offset = 0;
593
- let marked = false;
594
- for (const item of items) {
595
- if (item.type !== "text") {
596
- out.push(item);
597
- continue;
598
- }
599
- const end = offset + item.text.length;
600
- const head = item.text.slice(0, Math.max(headBudget - offset, 0));
601
- const tail = end > tailStart ? item.text.slice(Math.max(tailStart - offset, 0)) : "";
602
- if (head !== "")
603
- out.push({ type: "text", text: head });
604
- if (!marked && end >= headBudget) {
605
- out.push({ type: "text", text: `
606
- [... ${omitted} characters omitted ...]
607
- ` });
608
- marked = true;
609
- }
610
- if (tail !== "")
611
- out.push({ type: "text", text: tail });
612
- offset = end;
613
- }
614
- return mergeText(out);
615
- }
616
252
 
617
253
  // apps/host/src/extensions/context.ts
618
254
  import { dirname, join, sep } from "node:path";
619
- import { defineExtension as defineExtension3, section as section2 } from "@earendil-works/pi-durable";
255
+ import { defineExtension as defineExtension2, section } from "@earendil-works/pi-durable";
620
256
  import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
621
257
  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.";
258
+ var FORK_SHARED_DIR = "This conversation is a fork: it shares this working directory with the conversation it was forked from, which may be editing the same files. If your task needs isolation, create your own git worktree (for example `git worktree add <dir> -b <branch>`) and use absolute paths inside it for every read, write, edit and bash command.";
622
259
  function createContext(options) {
623
260
  const { agentsHome } = options;
624
261
  const resolveCheckout = (input, context) => options.checkout?.(input, context);
625
262
  return {
626
- extension: defineExtension3({
263
+ extension: defineExtension2({
627
264
  name: "context",
628
265
  sections: [
629
- section2("preamble", () => PREAMBLE, { tag: false }),
630
- section2("environment", async (input, context) => {
266
+ section("preamble", () => PREAMBLE, { tag: false }),
267
+ section("environment", async (input, context) => {
631
268
  const cwd = input.agent.cwd;
632
269
  if (cwd === undefined)
633
270
  return;
634
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
635
- if (checkout === undefined)
636
- return `Working directory: ${cwd}`;
271
+ const resolved = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
272
+ const checkout = resolved?.checkout;
273
+ const forkLine = resolved?.fork === true && checkout?.own !== true ? [FORK_SHARED_DIR] : [];
274
+ if (checkout === undefined) {
275
+ return [`Working directory: ${cwd}`, ...forkLine].join(`
276
+ `);
277
+ }
637
278
  return [
638
279
  `Working directory: ${cwd}`,
639
- `This directory is a git worktree PiNomad created for this conversation, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.`,
280
+ checkout.own ? `This directory is a git worktree PiNomad created for this conversation, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.` : `This directory is a git worktree PiNomad created for the conversation this one belongs to, on branch ${checkout.branch} starting from commit ${checkout.base.slice(0, 12)}.`,
640
281
  `The user's project directory is ${checkout.projectPath} and must not be modified.`,
641
- "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed."
282
+ "This is a fresh checkout: ignored files (dependencies such as node_modules, or .env) are absent — install dependencies per AGENTS.md when needed.",
283
+ ...forkLine
642
284
  ].join(`
643
285
  `);
644
286
  }),
645
- section2("project_context", async (input, context) => {
287
+ section("project_context", async (input, context) => {
646
288
  const cwd = input.agent.cwd;
647
289
  if (cwd === undefined)
648
290
  return;
649
- const checkout = await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context);
291
+ const checkout = (await resolveCheckout({ conversationId: input.conversationId, read: input.read }, context))?.checkout;
650
292
  let files;
651
293
  if (checkout === undefined) {
652
294
  files = loadProjectContextFiles({ cwd, agentDir: agentsHome });
@@ -668,7 +310,7 @@ ${file.content}`).join(`
668
310
 
669
311
  `);
670
312
  }),
671
- section2("skills", (input) => {
313
+ section("skills", (input) => {
672
314
  const cwd = input.agent.cwd;
673
315
  if (cwd === undefined)
674
316
  return;
@@ -692,68 +334,124 @@ import {
692
334
  AgentDoc,
693
335
  AssistantEntry,
694
336
  configure,
695
- defineExtension as defineExtension4,
696
- defineTool as defineTool3
337
+ defineExtension as defineExtension3,
338
+ defineTool as defineTool2,
339
+ section as section2
697
340
  } from "@earendil-works/pi-durable";
698
- import { Type as Type4 } from "typebox";
341
+ import { Type as Type3 } from "typebox";
342
+
343
+ // apps/host/src/subagent-doc.ts
344
+ import { defineDoc as defineDoc2 } from "@earendil-works/pi-durable";
345
+ var SubagentDoc = defineDoc2({
346
+ kind: "pinomad.subagent",
347
+ version: 1,
348
+ scope: "conversation",
349
+ history: "latest",
350
+ fork: "initial",
351
+ initial: () => ({})
352
+ });
353
+
354
+ // apps/host/src/extensions/subagent.ts
699
355
  var INSTRUCTIONS = "You are a subagent working on one delegated task. You cannot ask the user questions; if you need a decision or clarification, stop and state exactly what you need in your final answer. Your final message is returned to the delegating agent as the result, so make it a complete, self-contained report.";
700
- var THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
356
+ var THINKING_LEVELS2 = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
701
357
  function createSubagent(options) {
702
358
  const available = () => options.modelSummaries().map((model) => `${model.provider}/${model.modelId}`).join(", ");
703
- const tool = defineTool3({
359
+ const roles = options.roles ?? (() => []);
360
+ const roleNamed = (name) => {
361
+ const found = roles().find((each) => each.name === name);
362
+ if (found !== undefined)
363
+ return found;
364
+ const defined = roles();
365
+ throw new Error(defined.length === 0 ? "No named agents are defined" : `Unknown agent "${name}". Available agents: ${defined.map((each) => each.name).join(", ")}`);
366
+ };
367
+ const tool = defineTool2({
704
368
  name: "subagent",
705
- 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; `description` only labels the call. " + `Available models: ${available()}.`,
706
- parameters: Type4.Object({
707
- task: Type4.String({
369
+ callers: ["model"],
370
+ 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()}.`,
371
+ parameters: Type3.Object({
372
+ task: Type3.String({
708
373
  minLength: 1,
709
374
  description: "Complete, self-contained instructions. The subagent sees none of this conversation."
710
375
  }),
711
- description: Type4.Optional(Type4.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
712
- model: Type4.Optional(Type4.String({ description: "Optional `provider/modelId` to use instead of yours." })),
713
- thinkingLevel: Type4.Optional(Type4.Union(THINKING_LEVELS.map((level) => Type4.Literal(level)), { description: "Optional thinking level instead of yours." }))
376
+ role: Type3.Optional(Type3.String({
377
+ 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."
378
+ })),
379
+ description: Type3.Optional(Type3.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
380
+ model: Type3.Optional(Type3.String({ description: "Optional `provider/modelId` to use instead of yours." })),
381
+ thinkingLevel: Type3.Optional(Type3.Union(THINKING_LEVELS2.map((level) => Type3.Literal(level)), { description: "Optional thinking level instead of yours." }))
714
382
  }),
715
383
  replay: "safe",
716
384
  execute: async (args, api, context) => {
717
385
  const child = await api.commit(async (tx) => {
718
386
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
719
- if (existing !== undefined)
720
- return existing.id;
387
+ if (existing !== undefined) {
388
+ const doc2 = await tx.doc(SubagentDoc, existing.id);
389
+ return {
390
+ id: existing.id,
391
+ ...doc2?.model === undefined ? {} : { model: doc2.model },
392
+ ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
393
+ };
394
+ }
395
+ const role = args.role === undefined ? undefined : roleNamed(args.role);
396
+ if (role?.model !== undefined && args.model !== undefined) {
397
+ throw new Error(`Agent "${role.name}" pins model ${role.model}; omit \`model\``);
398
+ }
399
+ if (role?.thinkingLevel !== undefined && args.thinkingLevel !== undefined) {
400
+ throw new Error(`Agent "${role.name}" pins thinking level ${role.thinkingLevel}; omit \`thinkingLevel\``);
401
+ }
402
+ const modelString = role?.model ?? args.model;
403
+ const levelString = role?.thinkingLevel ?? args.thinkingLevel;
721
404
  let model;
722
405
  let thinkingLevel;
723
406
  let override;
724
- if (args.model !== undefined) {
725
- const slash = args.model.indexOf("/");
726
- const provider = slash === -1 ? "" : args.model.slice(0, slash);
727
- const modelId = slash === -1 ? "" : args.model.slice(slash + 1);
407
+ if (modelString !== undefined) {
408
+ const slash = modelString.indexOf("/");
409
+ const provider = slash === -1 ? "" : modelString.slice(0, slash);
410
+ const modelId = slash === -1 ? "" : modelString.slice(slash + 1);
728
411
  override = provider === "" || modelId === "" ? undefined : options.models.getModel(provider, modelId);
729
412
  const listed = options.modelSummaries().some((each) => each.provider === provider && each.modelId === modelId);
730
- if (override === undefined || !listed)
731
- throw new Error(`Unknown model "${args.model}". Available models: ${available()}`);
413
+ if (override === undefined || !listed) {
414
+ throw new Error(role === undefined ? `Unknown model "${modelString}". Available models: ${available()}` : `Agent "${role.name}" uses model "${modelString}", which is not available. Available models: ${available()}`);
415
+ }
732
416
  model = { provider, modelId };
733
417
  }
734
- if (args.thinkingLevel !== undefined || override !== undefined) {
418
+ if (levelString !== undefined || override !== undefined) {
735
419
  const parent = await tx.doc(AgentDoc, api.conversationId);
736
420
  const effective = override ?? (parent.model === undefined ? undefined : options.models.getModel(parent.model.provider, parent.model.modelId));
737
- if (args.thinkingLevel !== undefined) {
738
- thinkingLevel = effective === undefined ? args.thinkingLevel : clampThinkingLevel(effective, args.thinkingLevel);
421
+ if (levelString !== undefined) {
422
+ thinkingLevel = effective === undefined ? levelString : clampThinkingLevel(effective, levelString);
739
423
  } else if (parent.thinkingLevel !== undefined) {
740
424
  thinkingLevel = clampThinkingLevel(override, parent.thinkingLevel);
741
425
  }
742
426
  }
743
427
  const change = {
744
428
  extensions: { remove: [extension, ...options.exclude] },
745
- instructions: INSTRUCTIONS,
429
+ instructions: role === undefined || role.prompt === "" ? INSTRUCTIONS : `${INSTRUCTIONS}
430
+
431
+ ${role.prompt}`,
746
432
  ...model === undefined ? {} : { model },
747
433
  ...thinkingLevel === undefined ? {} : { thinkingLevel }
748
434
  };
749
435
  const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
750
436
  await configure(tx, created.id, change);
751
- return created.id;
437
+ const childAgent = await tx.doc(AgentDoc, created.id);
438
+ const ref = childAgent.model ?? model ?? (await tx.doc(AgentDoc, api.conversationId)).model;
439
+ const startedAt = Date.now();
440
+ const doc = await tx.doc(SubagentDoc, created.id);
441
+ doc.startedAt = startedAt;
442
+ if (ref !== undefined)
443
+ doc.model = `${ref.provider}/${ref.modelId}`;
444
+ if (role !== undefined)
445
+ doc.role = role.name;
446
+ const label = args.description?.trim();
447
+ if (label !== undefined && label !== "")
448
+ doc.label = label;
449
+ return { id: created.id, ...ref === undefined ? {} : { model: doc.model }, startedAt };
752
450
  }, context);
753
- await api.details({ conversationId: child }, context);
754
- const handle = await api.conversation(child, context);
451
+ await api.details({ conversationId: child.id, ...child.model === undefined ? {} : { model: child.model }, ...child.startedAt === undefined ? {} : { startedAt: child.startedAt } }, context);
452
+ const handle = await api.conversation(child.id, context);
755
453
  if (handle === undefined)
756
- throw new Error(`Subagent conversation ${child} does not exist`);
454
+ throw new Error(`Subagent conversation ${child.id} does not exist`);
757
455
  const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` };
758
456
  const settled = await (await handle.submit(request, context)).wait(context);
759
457
  if (settled.status !== "done" || settled.type !== "input")
@@ -762,20 +460,39 @@ function createSubagent(options) {
762
460
  const message = answer?.model?.[0];
763
461
  const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
764
462
  const result2 = {
765
- content: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
766
- details: { conversationId: child }
463
+ output: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
464
+ details: {
465
+ conversationId: child.id,
466
+ ...child.model === undefined ? {} : { model: child.model },
467
+ ...child.startedAt === undefined ? {} : { startedAt: child.startedAt }
468
+ }
767
469
  };
768
470
  return result2;
769
471
  }
770
472
  });
771
- const extension = defineExtension4({ name: "subagent", tools: [tool] });
772
- return { extension, modelOnly: ["subagent"] };
473
+ const extension = defineExtension3({
474
+ name: "subagent",
475
+ tools: [tool],
476
+ sections: [
477
+ section2("agent_roles", () => {
478
+ const defined = roles();
479
+ if (defined.length === 0)
480
+ return;
481
+ return [
482
+ "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`.",
483
+ ...defined.map((each) => `- ${each.name}: ${each.description.replace(/\s+/g, " ")}`)
484
+ ].join(`
485
+ `);
486
+ })
487
+ ]
488
+ });
489
+ return { extension };
773
490
  }
774
491
 
775
492
  // apps/host/src/extensions/todo.ts
776
- import { defineDoc as defineDoc2, defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
777
- import { Type as Type5 } from "typebox";
778
- var TodoDoc = defineDoc2({
493
+ import { defineDoc as defineDoc3, defineExtension as defineExtension4, defineTool as defineTool3 } from "@earendil-works/pi-durable";
494
+ import { Type as Type4 } from "typebox";
495
+ var TodoDoc = defineDoc3({
779
496
  kind: "todo.list",
780
497
  version: 1,
781
498
  scope: "conversation",
@@ -783,33 +500,30 @@ var TodoDoc = defineDoc2({
783
500
  fork: "asOf",
784
501
  initial: () => ({ items: [] })
785
502
  });
786
- var todoWrite = defineTool4({
503
+ var todoWrite = defineTool3({
787
504
  name: "todo_write",
788
505
  description: "Replace the todo list shown to the user with the given items.",
789
- parameters: Type5.Object({ items: Type5.Array(TodoItemSchema) }),
506
+ parameters: Type4.Object({ items: Type4.Array(TodoItemSchema) }),
790
507
  replay: "safe",
791
508
  execute: async (args, api, context) => {
792
509
  await api.commit(async (tx) => {
793
510
  const doc = await tx.doc(TodoDoc, api.conversationId);
794
511
  doc.items = args.items.map((item) => ({ ...item }));
795
512
  }, context);
796
- return { content: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
513
+ return { output: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
797
514
  }
798
515
  });
799
516
  var todo = {
800
- extension: defineExtension5({ name: "todo", tools: [todoWrite] }),
517
+ extension: defineExtension4({ name: "todo", tools: [todoWrite] }),
801
518
  docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
802
519
  };
803
520
 
804
521
  // apps/host/src/host.ts
805
- import { randomBytes as randomBytes2 } from "node:crypto";
806
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "node:fs/promises";
807
- import { basename as basename2, join as join5, sep as sep4 } from "node:path";
808
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
809
- import {
810
- createRegistry,
811
- Harness
812
- } from "@earendil-works/pi-durable";
522
+ import { randomBytes as randomBytes3 } from "node:crypto";
523
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
524
+ import { basename as basename2, join as join7, sep as sep4 } from "node:path";
525
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
526
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
813
527
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
814
528
 
815
529
  // apps/host/src/organization.ts
@@ -818,7 +532,7 @@ import { basename, join as join3, resolve } from "node:path";
818
532
  import { clampThinkingLevel as clampThinkingLevel2 } from "@earendil-works/pi-ai";
819
533
  import {
820
534
  AgentDoc as AgentDoc2,
821
- defineDoc as defineDoc3
535
+ defineDoc as defineDoc4
822
536
  } from "@earendil-works/pi-durable";
823
537
 
824
538
  // apps/host/src/checkout.ts
@@ -849,27 +563,6 @@ async function gitBase(projectPath) {
849
563
  return;
850
564
  return { repo, subdir: relative(repo, projectPath), base };
851
565
  }
852
- var SNAPSHOT_IDENTITY = {
853
- GIT_AUTHOR_NAME: "PiNomad",
854
- GIT_AUTHOR_EMAIL: "pinomad@localhost",
855
- GIT_COMMITTER_NAME: "PiNomad",
856
- GIT_COMMITTER_EMAIL: "pinomad@localhost"
857
- };
858
- async function snapshotOf(sourceDir) {
859
- const indexFile = join2(tmpdir(), `pinomad-index-${randomBytes(8).toString("hex")}`);
860
- const env = { GIT_INDEX_FILE: indexFile };
861
- try {
862
- const { stdout: head } = await git(sourceDir, ["rev-parse", "--verify", "HEAD"], env);
863
- const base = head.trim();
864
- await git(sourceDir, ["read-tree", base], env);
865
- await git(sourceDir, ["add", "-A"], env);
866
- const { stdout: tree } = await git(sourceDir, ["write-tree"], env);
867
- const { stdout: commit } = await git(sourceDir, ["commit-tree", tree.trim(), "-p", base, "-m", "pinomad fork snapshot"], { ...env, ...SNAPSHOT_IDENTITY });
868
- return { base, snapshot: commit.trim() };
869
- } finally {
870
- await rm(indexFile, { force: true });
871
- }
872
- }
873
566
  var repoLocks = new Map;
874
567
  function locked(repo, work) {
875
568
  const next = (repoLocks.get(repo) ?? Promise.resolve()).then(work, work);
@@ -920,7 +613,7 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
920
613
  try {
921
614
  await git(dir, ["read-tree", "HEAD"], env);
922
615
  await git(dir, ["add", "-A"], env);
923
- const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", base], env);
616
+ const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], env);
924
617
  if (Buffer.byteLength(stdout) <= maxBytes)
925
618
  return { base, patch: stdout, truncated: false };
926
619
  const cut = stdout.slice(0, maxBytes);
@@ -932,8 +625,18 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
932
625
  }
933
626
  }
934
627
 
628
+ // apps/host/src/message-content.ts
629
+ function messageContent(text, images) {
630
+ if (images === undefined || images.length === 0)
631
+ return text;
632
+ return [
633
+ ...text === "" ? [] : [{ type: "text", text }],
634
+ ...images.map(({ data, mimeType }) => ({ type: "image", data, mimeType }))
635
+ ];
636
+ }
637
+
935
638
  // apps/host/src/organization.ts
936
- var IndexDoc = defineDoc3({
639
+ var IndexDoc = defineDoc4({
937
640
  kind: "pinomad.index",
938
641
  version: 1,
939
642
  scope: "session",
@@ -984,7 +687,7 @@ async function createConversation(harness, dataDir, input, defaults, models, con
984
687
  if (prior !== undefined) {
985
688
  const existing = await harness.conversation(prior.id, context);
986
689
  if (existing !== undefined) {
987
- await existing.submit({ type: "input", content: input.text, whenBusy: "followUp", requestId: input.requestId }, context);
690
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
988
691
  }
989
692
  return prior.id;
990
693
  }
@@ -1036,14 +739,14 @@ async function createConversation(harness, dataDir, input, defaults, models, con
1036
739
  }, context);
1037
740
  if (home.kind === "chat")
1038
741
  await mkdir(join3(dataDir, "chats", String(conversation.id)), { recursive: true });
1039
- await conversation.submit({ type: "input", content: input.text, whenBusy: "followUp", requestId: input.requestId }, context);
742
+ await conversation.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
1040
743
  return conversation.id;
1041
744
  } catch (error) {
1042
745
  if (!(error instanceof DuplicateRequest))
1043
746
  throw error;
1044
747
  const existing = await harness.conversation(error.conversationId, context);
1045
748
  if (existing !== undefined) {
1046
- await existing.submit({ type: "input", content: input.text, whenBusy: "followUp", requestId: input.requestId }, context);
749
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
1047
750
  }
1048
751
  return error.conversationId;
1049
752
  }
@@ -1057,21 +760,42 @@ async function archive(harness, id, archived, context) {
1057
760
  entry.archived = archived;
1058
761
  }, context);
1059
762
  }
1060
- async function checkoutInfo(input, context) {
763
+ var forkFlags = new WeakMap;
764
+ async function forkFlagOf(harness, id, context) {
765
+ const cached = forkFlags.get(harness)?.get(id);
766
+ if (cached !== undefined)
767
+ return cached;
768
+ const record = await harness.commit((tx) => tx.conversation(id), context);
769
+ if (record === undefined)
770
+ return false;
771
+ let flags = forkFlags.get(harness);
772
+ if (flags === undefined)
773
+ forkFlags.set(harness, flags = new Map);
774
+ flags.set(id, record.parent !== undefined);
775
+ return record.parent !== undefined;
776
+ }
777
+ async function checkoutInfo(harness, input, context) {
1061
778
  const agent = await input.read.snapshot(AgentDoc2, input.conversationId, context);
1062
779
  const cwd = agent?.cwd;
1063
780
  if (cwd === undefined)
1064
781
  return;
1065
- const index = await input.read.snapshot(IndexDoc, context);
1066
- const record = checkoutAt(index?.checkouts, cwd);
1067
- if (record === undefined)
1068
- return;
782
+ const [index, fork] = await Promise.all([
783
+ input.read.snapshot(IndexDoc, context),
784
+ forkFlagOf(harness, input.conversationId, context)
785
+ ]);
786
+ const checkout = checkoutAt(index?.checkouts, cwd);
1069
787
  return {
1070
- branch: record.branch,
1071
- base: record.base,
1072
- projectPath: join3(record.repo, record.subdir),
1073
- repo: record.repo,
1074
- worktreeRoot: record.path
788
+ fork,
789
+ ...checkout === undefined ? {} : {
790
+ checkout: {
791
+ branch: checkout.branch,
792
+ base: checkout.base,
793
+ projectPath: join3(checkout.repo, checkout.subdir),
794
+ repo: checkout.repo,
795
+ worktreeRoot: checkout.path,
796
+ own: checkout.conversationId === input.conversationId
797
+ }
798
+ }
1075
799
  };
1076
800
  }
1077
801
  async function cleanupArchivedWorktrees(harness, rootId, context) {
@@ -1126,8 +850,8 @@ import {
1126
850
  StreamableHttpTransport,
1127
851
  toLlmContent
1128
852
  } from "@earendil-works/pi-mcp";
1129
- import { defineExtension as defineExtension6, defineTool as defineTool5 } from "@earendil-works/pi-durable";
1130
- import { Type as Type6 } from "typebox";
853
+ import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
854
+ import { Type as Type5 } from "typebox";
1131
855
  var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1132
856
  var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1133
857
  function expand(value) {
@@ -1249,32 +973,23 @@ var callToolResultSchema = (tool) => ({
1249
973
  },
1250
974
  required: ["content"]
1251
975
  });
1252
- function scriptTool(client, timeoutMs, server, description, name, tool) {
1253
- return {
976
+ function mcpTool(client, timeoutMs, exposure, name, tool) {
977
+ return defineTool4({
1254
978
  name,
1255
979
  description: tool.description ?? tool.title ?? tool.name,
1256
- inputSchema: tool.inputSchema,
1257
- outputSchema: callToolResultSchema(tool),
1258
- namespace: {
1259
- name: server,
1260
- ...description === undefined ? {} : { description },
1261
- ...client.instructions === undefined ? {} : { instructions: client.instructions }
1262
- },
1263
- call: (args, signal) => client.callTool(tool.name, args, { signal, timeoutMs })
1264
- };
1265
- }
1266
- function asModelTool(tool) {
1267
- return defineTool5({
1268
- name: tool.name,
1269
- description: tool.description,
1270
- parameters: Type6.Unsafe({
980
+ parameters: Type5.Unsafe({
1271
981
  ...tool.inputSchema,
1272
982
  type: "object",
1273
983
  properties: tool.inputSchema.properties ?? {}
1274
984
  }),
985
+ ...exposure === "direct" ? {} : { callers: ["tools"] },
986
+ structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
1275
987
  execute: async (args, _api, context) => {
1276
- const result2 = await tool.call(args, context.abortSignal ?? NEVER_ABORT);
1277
- return { content: toLlmContent(result2), isError: result2.isError === true };
988
+ const result2 = await client.callTool(tool.name, args, {
989
+ signal: context.abortSignal ?? NEVER_ABORT,
990
+ timeoutMs
991
+ });
992
+ return { output: toLlmContent(result2), structuredOutput: result2, isError: result2.isError === true };
1278
993
  }
1279
994
  });
1280
995
  }
@@ -1283,6 +998,7 @@ function startMcp(options) {
1283
998
  const listeners = new Set;
1284
999
  const servers = new Map;
1285
1000
  const toolsets = new Map;
1001
+ const namespaces = new Map;
1286
1002
  const exposures = new Map;
1287
1003
  const live = new Set;
1288
1004
  let errors = [];
@@ -1293,8 +1009,8 @@ function startMcp(options) {
1293
1009
  listener(value);
1294
1010
  };
1295
1011
  const install = () => {
1296
- const direct = [...toolsets.entries()].flatMap(([name, tools]) => exposures.get(name) === "direct" ? tools.map(asModelTool) : []);
1297
- options.registry.install(defineExtension6({ name: "mcp", tools: direct }));
1012
+ options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
1013
+ options.onNamespaces?.([...namespaces.values()]);
1298
1014
  };
1299
1015
  const set = (name, patch) => {
1300
1016
  servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
@@ -1311,16 +1027,23 @@ function startMcp(options) {
1311
1027
  continue;
1312
1028
  }
1313
1029
  taken.add(wire);
1314
- tools.push(scriptTool(client, timeoutMs, name, description, wire, tool));
1030
+ tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
1315
1031
  }
1316
1032
  if (closed)
1317
1033
  return;
1318
1034
  toolsets.set(name, tools);
1035
+ namespaces.set(name, {
1036
+ name,
1037
+ ...description === undefined ? {} : { description },
1038
+ ...client.instructions === undefined ? {} : { instructions: client.instructions },
1039
+ prefix: callName(name, "")
1040
+ });
1319
1041
  set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
1320
1042
  install();
1321
1043
  };
1322
1044
  const fail = (name, error) => {
1323
1045
  toolsets.delete(name);
1046
+ namespaces.delete(name);
1324
1047
  set(name, { state: "failed", error, tools: 0 });
1325
1048
  install();
1326
1049
  };
@@ -1395,7 +1118,6 @@ ${stderr}`;
1395
1118
  });
1396
1119
  return {
1397
1120
  status,
1398
- scriptTools: () => [...toolsets.values()].flat(),
1399
1121
  async close() {
1400
1122
  closed = true;
1401
1123
  await Promise.all([...live].map((client) => client.close().catch(() => {})));
@@ -1403,22 +1125,559 @@ ${stderr}`;
1403
1125
  };
1404
1126
  }
1405
1127
 
1128
+ // apps/host/src/agent-roles.ts
1129
+ import { randomBytes as randomBytes2 } from "node:crypto";
1130
+ import { existsSync, watch } from "node:fs";
1131
+ import { mkdir as mkdir2, readdir, readFile as readFile2, rename, rm as rm2, writeFile } from "node:fs/promises";
1132
+ import { join as join4 } from "node:path";
1133
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1134
+
1135
+ // packages/protocol/src/agents.ts
1136
+ var MAX_AGENT_NAME = 32;
1137
+ var MAX_AGENT_DESCRIPTION = 1024;
1138
+ var NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1139
+ function agentNameProblem(name) {
1140
+ return name.length > MAX_AGENT_NAME || !NAME_PATTERN.test(name) ? `Name must be lowercase letters, digits and hyphens (max ${MAX_AGENT_NAME})` : undefined;
1141
+ }
1142
+ function agentRoleProblem(role) {
1143
+ const name = agentNameProblem(role.name);
1144
+ if (name !== undefined)
1145
+ return name;
1146
+ const description = role.description.trim();
1147
+ if (description === "")
1148
+ return "description is required";
1149
+ if (description.length > MAX_AGENT_DESCRIPTION)
1150
+ return `description is too long (max ${MAX_AGENT_DESCRIPTION})`;
1151
+ if (role.model !== undefined) {
1152
+ const slash = role.model.indexOf("/");
1153
+ if (slash <= 0 || slash === role.model.length - 1)
1154
+ return "model must be provider/modelId";
1155
+ }
1156
+ if (role.thinkingLevel !== undefined && !isThinkingLevel(role.thinkingLevel)) {
1157
+ return `thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
1158
+ }
1159
+ return;
1160
+ }
1161
+
1162
+ // apps/host/src/host-value.ts
1163
+ function constantValue(value) {
1164
+ return {
1165
+ value,
1166
+ subscribe: () => () => {}
1167
+ };
1168
+ }
1169
+
1170
+ class Published {
1171
+ #listeners = new Set;
1172
+ #equal;
1173
+ #value;
1174
+ constructor(value, equal) {
1175
+ this.#value = value;
1176
+ this.#equal = equal;
1177
+ }
1178
+ get value() {
1179
+ return this.#value;
1180
+ }
1181
+ subscribe(listener) {
1182
+ this.#listeners.add(listener);
1183
+ return () => this.#listeners.delete(listener);
1184
+ }
1185
+ set(next) {
1186
+ if (this.#equal(this.#value, next))
1187
+ return;
1188
+ this.#value = next;
1189
+ for (const listener of this.#listeners)
1190
+ listener(next);
1191
+ }
1192
+ }
1193
+ var sameList = (a, b, same) => a.length === b.length && a.every((entry, index) => same(entry, b[index]));
1194
+
1195
+ // apps/host/src/agent-roles.ts
1196
+ var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
1197
+ async function load(dir) {
1198
+ const roles = [];
1199
+ const problems = [];
1200
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
1201
+ for (const entry of entries) {
1202
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
1203
+ continue;
1204
+ const file = entry.name;
1205
+ const name = file.slice(0, -".md".length);
1206
+ const problem = (reason2) => void problems.push({ file, reason: reason2 });
1207
+ let parsed;
1208
+ try {
1209
+ parsed = parseFrontmatter(await readFile2(join4(dir, file), "utf8"));
1210
+ } catch (error) {
1211
+ problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
1212
+ continue;
1213
+ }
1214
+ if (typeof parsed.frontmatter !== "object" || parsed.frontmatter === null || Array.isArray(parsed.frontmatter)) {
1215
+ problem("Frontmatter must be a record of fields");
1216
+ continue;
1217
+ }
1218
+ const unknown = Object.keys(parsed.frontmatter).find((key) => !KNOWN_FIELDS.has(key));
1219
+ if (unknown !== undefined) {
1220
+ problem(`Unknown field "${unknown}"`);
1221
+ continue;
1222
+ }
1223
+ const bad = Object.entries(parsed.frontmatter).find(([, value]) => typeof value !== "string");
1224
+ if (bad !== undefined) {
1225
+ problem(`Field "${bad[0]}" must be a string`);
1226
+ continue;
1227
+ }
1228
+ const frontmatter = parsed.frontmatter;
1229
+ const role = {
1230
+ name,
1231
+ description: frontmatter.description ?? "",
1232
+ ...frontmatter.model === undefined ? {} : { model: frontmatter.model },
1233
+ ...frontmatter.thinkingLevel === undefined ? {} : { thinkingLevel: frontmatter.thinkingLevel },
1234
+ prompt: parsed.body.trim()
1235
+ };
1236
+ const reason = agentRoleProblem(role);
1237
+ if (reason !== undefined) {
1238
+ problem(reason);
1239
+ continue;
1240
+ }
1241
+ roles.push(role);
1242
+ }
1243
+ roles.sort((a, b) => a.name.localeCompare(b.name));
1244
+ problems.sort((a, b) => a.file.localeCompare(b.file));
1245
+ return { roles, problems };
1246
+ }
1247
+ function serialize(role) {
1248
+ const fields = [
1249
+ `description: ${JSON.stringify(role.description)}`,
1250
+ ...role.model === undefined ? [] : [`model: ${JSON.stringify(role.model)}`],
1251
+ ...role.thinkingLevel === undefined ? [] : [`thinkingLevel: ${JSON.stringify(role.thinkingLevel)}`]
1252
+ ];
1253
+ return `---
1254
+ ${fields.join(`
1255
+ `)}
1256
+ ---
1257
+
1258
+ ${role.prompt}
1259
+ `;
1260
+ }
1261
+ var sameRole = (a, b) => a.name === b.name && a.description === b.description && a.model === b.model && a.thinkingLevel === b.thinkingLevel && a.prompt === b.prompt;
1262
+ var sameProblem = (a, b) => a.file === b.file && a.reason === b.reason;
1263
+ async function openAgentRoles(options) {
1264
+ const dir = options.dir;
1265
+ await mkdir2(dir, { recursive: true });
1266
+ const debounceMs = options.debounceMs ?? 150;
1267
+ const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
1268
+ let closed = false;
1269
+ let reported = false;
1270
+ let timer;
1271
+ const reload = async () => {
1272
+ try {
1273
+ published.set(await load(dir));
1274
+ reported = false;
1275
+ } catch (error) {
1276
+ if (!reported)
1277
+ console.error(`agent-roles reload failed: ${error instanceof Error ? error.message : String(error)}`);
1278
+ reported = true;
1279
+ }
1280
+ };
1281
+ await reload();
1282
+ const watcher = watch(dir, () => {
1283
+ if (closed)
1284
+ return;
1285
+ clearTimeout(timer);
1286
+ timer = setTimeout(() => void reload(), debounceMs);
1287
+ });
1288
+ watcher.on("error", (error) => {
1289
+ if (!reported)
1290
+ console.error(`agent-roles watch failed: ${error.message}`);
1291
+ reported = true;
1292
+ });
1293
+ let pending = Promise.resolve();
1294
+ const enqueue = (operation) => {
1295
+ const run2 = pending.then(operation, operation);
1296
+ pending = run2.then(() => {}, () => {});
1297
+ return run2;
1298
+ };
1299
+ const write = async (role, mustExist) => {
1300
+ const problem = agentRoleProblem(role);
1301
+ if (problem !== undefined)
1302
+ throw new Error(problem);
1303
+ await enqueue(async () => {
1304
+ const target = join4(dir, `${role.name}.md`);
1305
+ const exists = existsSync(target);
1306
+ if (mustExist && !exists)
1307
+ throw new Error(`No such agent: "${role.name}"`);
1308
+ if (!mustExist && exists)
1309
+ throw new Error(`Agent "${role.name}" already exists`);
1310
+ const temp = join4(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
1311
+ try {
1312
+ await writeFile(temp, serialize(role));
1313
+ await rename(temp, target);
1314
+ } catch (error) {
1315
+ await rm2(temp, { force: true }).catch(() => {});
1316
+ throw error;
1317
+ }
1318
+ await reload();
1319
+ });
1320
+ };
1321
+ return {
1322
+ get value() {
1323
+ return published.value;
1324
+ },
1325
+ subscribe: (listener) => published.subscribe(listener),
1326
+ create: (role) => write(role, false),
1327
+ update: (role) => write(role, true),
1328
+ async remove(name) {
1329
+ const problem = agentNameProblem(name);
1330
+ if (problem !== undefined)
1331
+ throw new Error(problem);
1332
+ await enqueue(async () => {
1333
+ const target = join4(dir, `${name}.md`);
1334
+ if (!existsSync(target))
1335
+ throw new Error(`No such agent: "${name}"`);
1336
+ await rm2(target);
1337
+ await reload();
1338
+ });
1339
+ },
1340
+ close() {
1341
+ closed = true;
1342
+ clearTimeout(timer);
1343
+ watcher.close();
1344
+ }
1345
+ };
1346
+ }
1347
+
1348
+ // apps/host/src/host-settings.ts
1349
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1350
+ import { dirname as dirname2 } from "node:path";
1351
+ function settingsDefaults(settings) {
1352
+ return {
1353
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
1354
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
1355
+ };
1356
+ }
1357
+ var isModelRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.provider === "string" && value.provider !== "" && typeof value.modelId === "string" && value.modelId !== "";
1358
+ var fileValueError = (key, value, models) => {
1359
+ switch (key) {
1360
+ case "defaultModel":
1361
+ if (!isModelRef(value))
1362
+ return "malformed";
1363
+ return models.some((each) => each.provider === value.provider && each.modelId === value.modelId) ? undefined : "unavailable";
1364
+ case "defaultThinkingLevel":
1365
+ return isThinkingLevel(value) ? undefined : "malformed";
1366
+ case "steeringMode":
1367
+ case "followUpMode":
1368
+ return isQueueMode(value) ? undefined : "malformed";
1369
+ }
1370
+ };
1371
+ var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
1372
+ var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && sameList(a.custom, b.custom, (x, y) => x === y);
1373
+ var parse = (path, text) => {
1374
+ let parsed;
1375
+ try {
1376
+ parsed = JSON.parse(text);
1377
+ } catch (error) {
1378
+ throw new Error(`Invalid settings file ${path}: ${error instanceof Error ? error.message : String(error)}`);
1379
+ }
1380
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1381
+ throw new Error(`Invalid settings file ${path}: expected a JSON object`);
1382
+ }
1383
+ return parsed;
1384
+ };
1385
+ async function openHostSettings(options) {
1386
+ let file = await readFile3(options.path, "utf8").then((text) => parse(options.path, text), (error) => {
1387
+ if (error.code === "ENOENT")
1388
+ return {};
1389
+ throw new Error(`Cannot read settings file ${options.path}: ${error instanceof Error ? error.message : String(error)}`);
1390
+ });
1391
+ const warned = new Set;
1392
+ const compute = () => {
1393
+ const fileValues = {};
1394
+ const custom = [];
1395
+ for (const key of HOST_SETTING_KEYS) {
1396
+ if (!Object.hasOwn(file, key))
1397
+ continue;
1398
+ const value = file[key];
1399
+ const invalid = fileValueError(key, value, options.models.value);
1400
+ if (invalid === "malformed" && !warned.has(key)) {
1401
+ warned.add(key);
1402
+ console.error(`${options.path}: ignoring invalid value for settings key "${key}": ${JSON.stringify(value)}`);
1403
+ }
1404
+ if (invalid !== undefined)
1405
+ continue;
1406
+ fileValues[key] = value;
1407
+ custom.push(key);
1408
+ }
1409
+ const { fallback } = options;
1410
+ return {
1411
+ defaultModel: fileValues.defaultModel ?? fallback.defaultModel(),
1412
+ defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
1413
+ steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
1414
+ followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
1415
+ custom
1416
+ };
1417
+ };
1418
+ const published = new Published(compute(), sameSettings);
1419
+ options.models.subscribe(() => published.set(compute()));
1420
+ let tail = Promise.resolve();
1421
+ const write = async (patch) => {
1422
+ const model = patch.defaultModel;
1423
+ if (model !== undefined && model !== null && !options.models.value.some((each) => each.provider === model.provider && each.modelId === model.modelId)) {
1424
+ throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
1425
+ }
1426
+ const next = { ...file };
1427
+ for (const key of HOST_SETTING_KEYS) {
1428
+ const value = patch[key];
1429
+ if (value === undefined)
1430
+ continue;
1431
+ if (value === null)
1432
+ delete next[key];
1433
+ else
1434
+ next[key] = value;
1435
+ }
1436
+ const tmp = `${options.path}.tmp`;
1437
+ await mkdir3(dirname2(options.path), { recursive: true });
1438
+ await writeFile2(tmp, `${JSON.stringify(next, null, 2)}
1439
+ `, { mode: 384 });
1440
+ await rename2(tmp, options.path);
1441
+ file = next;
1442
+ published.set(compute());
1443
+ };
1444
+ return {
1445
+ get value() {
1446
+ return published.value;
1447
+ },
1448
+ subscribe: (listener) => published.subscribe(listener),
1449
+ update: (patch) => tail = tail.then(() => write(patch), () => write(patch))
1450
+ };
1451
+ }
1452
+
1406
1453
  // apps/host/src/host.ts
1407
1454
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1408
1455
  import lockfile from "proper-lockfile";
1409
1456
 
1410
1457
  // apps/host/src/gateway.ts
1411
1458
  import { createServer } from "node:http";
1412
- import { networkInterfaces } from "node:os";
1413
- import { join as join4, relative as relative2, sep as sep3 } from "node:path";
1414
- import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1459
+ import { homedir as homedir2, networkInterfaces } from "node:os";
1460
+ import { join as join6, sep as sep3 } from "node:path";
1461
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1415
1462
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1416
1463
  import {
1417
- AgentDoc as AgentDoc3
1464
+ AgentDoc as AgentDoc3,
1465
+ LiveDoc
1418
1466
  } from "@earendil-works/pi-durable";
1419
1467
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1420
1468
  import { Value as Value2 } from "typebox/value";
1421
1469
 
1470
+ // apps/host/src/folders.ts
1471
+ import { readdir as readdir2, realpath as realpath3, stat as stat3 } from "node:fs/promises";
1472
+ import { isAbsolute as isAbsolute2, join as join5 } from "node:path";
1473
+ var denied = (error) => {
1474
+ const code = error?.code;
1475
+ return code === "EACCES" || code === "EPERM";
1476
+ };
1477
+ async function listFolders(path, home) {
1478
+ if (path !== undefined && !isAbsolute2(path))
1479
+ throw new Error(`Not an absolute path: ${path}`);
1480
+ const given = path ?? home;
1481
+ const dir = await realpath3(given).catch((error) => {
1482
+ if (denied(error))
1483
+ throw new Error(`Permission denied: ${given}`);
1484
+ throw new Error(`No such directory: ${given}`);
1485
+ });
1486
+ if (!(await stat3(dir)).isDirectory())
1487
+ throw new Error(`Not a directory: ${given}`);
1488
+ let entries;
1489
+ try {
1490
+ entries = await readdir2(dir, { withFileTypes: true });
1491
+ } catch (error) {
1492
+ if (denied(error))
1493
+ throw new Error(`Permission denied: ${given}`);
1494
+ throw error;
1495
+ }
1496
+ const folders = [];
1497
+ for (const entry of entries) {
1498
+ if (entry.name.startsWith("."))
1499
+ continue;
1500
+ let isDirectory = entry.isDirectory();
1501
+ if (!isDirectory && entry.isSymbolicLink()) {
1502
+ isDirectory = await stat3(join5(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1503
+ }
1504
+ if (isDirectory)
1505
+ folders.push({ name: entry.name, path: join5(dir, entry.name) });
1506
+ }
1507
+ folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1508
+ return { path: dir, home: await realpath3(home).catch(() => home), folders };
1509
+ }
1510
+
1511
+ // packages/protocol/src/images.ts
1512
+ var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1513
+ var MAX_IMAGES_PER_MESSAGE = 4;
1514
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1515
+ var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
1516
+ function imageProblem(images) {
1517
+ if (images.length > MAX_IMAGES_PER_MESSAGE)
1518
+ return `At most ${MAX_IMAGES_PER_MESSAGE} images per message`;
1519
+ for (let i = 0;i < images.length; i++) {
1520
+ const image = images[i];
1521
+ if (!IMAGE_MIME_TYPES.includes(image.mimeType)) {
1522
+ return `Unsupported image type: ${image.mimeType} (use JPEG, PNG, GIF or WebP)`;
1523
+ }
1524
+ if (image.data === "" || image.data.length % 4 !== 0 || !BASE64.test(image.data)) {
1525
+ return `Image ${i + 1} is not valid base64`;
1526
+ }
1527
+ const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
1528
+ if (image.data.length * 3 / 4 - padding > MAX_IMAGE_BYTES)
1529
+ return `Image ${i + 1} is larger than 5 MB`;
1530
+ }
1531
+ return;
1532
+ }
1533
+
1534
+ // apps/host/src/auto-title.ts
1535
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1536
+
1537
+ // apps/host/src/title-doc.ts
1538
+ import { defineDoc as defineDoc5 } from "@earendil-works/pi-durable";
1539
+ var TitleDoc = defineDoc5({
1540
+ kind: "pinomad.title",
1541
+ version: 1,
1542
+ scope: "conversation",
1543
+ history: "latest",
1544
+ fork: "initial",
1545
+ initial: () => ({})
1546
+ });
1547
+
1548
+ // apps/host/src/auto-title.ts
1549
+ var context = BACKGROUND_CONTEXT;
1550
+ var TITLE_PROMPT = "You name conversations between a user and a coding agent. Reply with only the title: at most 6 words" + " (about 20 characters for Chinese or Japanese), in the same language as the user's message, no quotes," + " no trailing punctuation. Name the task, not the agent's answer.";
1551
+ var TITLE_INPUT_LIMIT = 2000;
1552
+ var TITLE_MAX_LENGTH = 80;
1553
+ var textOf = (content) => {
1554
+ if (typeof content === "string")
1555
+ return content;
1556
+ if (!Array.isArray(content))
1557
+ return "";
1558
+ return content.flatMap((block) => typeof block === "object" && block !== null && block.type === "text" ? [block.text ?? ""] : []).join("");
1559
+ };
1560
+ function cleanTitle(raw) {
1561
+ const line = raw.split(`
1562
+ `).map((each) => each.trim()).find((each) => each !== "");
1563
+ if (line === undefined)
1564
+ return;
1565
+ let text = line.replace(/^\s*title\s*:\s*/i, "");
1566
+ let before = "";
1567
+ while (text !== before) {
1568
+ before = text;
1569
+ text = text.replace(/^["'“”‘’「」`]+/, "").replace(/["'“”‘’「」`]+$/, "").replace(/[.。!!??]+$/, "");
1570
+ }
1571
+ text = text.replace(/\s+/g, " ").trim();
1572
+ if (text === "")
1573
+ return;
1574
+ return text.length > TITLE_MAX_LENGTH ? text.slice(0, TITLE_MAX_LENGTH).trimEnd() : text;
1575
+ }
1576
+ function attachAutoTitle(runs, deps) {
1577
+ const inFlight = new Set;
1578
+ const pending = new Set;
1579
+ const start = (id) => {
1580
+ inFlight.add(id);
1581
+ setImmediate(() => void title(id));
1582
+ };
1583
+ const title = async (id) => {
1584
+ try {
1585
+ const existing = await deps.harness.snapshot(TitleDoc, id, context);
1586
+ if (typeof existing?.title === "string" && existing.title !== "")
1587
+ return;
1588
+ const conversation = await deps.harness.conversation(id, context);
1589
+ if (conversation === undefined)
1590
+ return;
1591
+ let first;
1592
+ let newest;
1593
+ let cursor;
1594
+ do {
1595
+ const page = await conversation.entries({}, 256, cursor, context);
1596
+ first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id) ?? first;
1597
+ newest ??= page.items.find((entry) => entry.conversationId === id && entry.kind === "pi.assistant");
1598
+ cursor = page.next;
1599
+ } while (cursor !== undefined);
1600
+ const last = newest?.model?.[0];
1601
+ if (first === undefined || last?.role !== "assistant" || last.stopReason === "error" || last.stopReason === "aborted") {
1602
+ return;
1603
+ }
1604
+ const ref = deps.settings.value.defaultModel;
1605
+ const model = ref === null ? undefined : deps.models.getModel(ref.provider, ref.modelId);
1606
+ if (model === undefined)
1607
+ return;
1608
+ const firstText = (textOf(first.model?.[0] !== undefined ? first.model[0].content : "") || "(images only)").slice(0, TITLE_INPUT_LIMIT);
1609
+ const answer = textOf(last.content).slice(0, TITLE_INPUT_LIMIT);
1610
+ const request = {
1611
+ systemPrompt: TITLE_PROMPT,
1612
+ messages: [
1613
+ {
1614
+ role: "user",
1615
+ content: `User's first message:
1616
+ ${firstText}${answer === "" ? "" : `
1617
+
1618
+ Agent's reply:
1619
+ ${answer}`}`,
1620
+ timestamp: Date.now()
1621
+ }
1622
+ ]
1623
+ };
1624
+ const generated = await deps.models.completeSimple(model, request, { maxTokens: 256 });
1625
+ const cleaned = cleanTitle(generated.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(""));
1626
+ if (generated.stopReason === "error" || cleaned === undefined) {
1627
+ console.error(`auto-title: conversation ${id}: ${generated.stopReason === "error" ? generated.errorMessage ?? "provider error" : "unusable model output"}`);
1628
+ return;
1629
+ }
1630
+ await deps.harness.commit(async (tx) => {
1631
+ const doc = await tx.doc(TitleDoc, id);
1632
+ if (typeof doc.title === "string" && doc.title !== "")
1633
+ return;
1634
+ doc.title = cleaned;
1635
+ doc.source = "auto";
1636
+ }, context);
1637
+ } catch (error) {
1638
+ console.error(`auto-title: conversation ${id}: ${error instanceof Error ? error.message : String(error)}`);
1639
+ } finally {
1640
+ inFlight.delete(id);
1641
+ if (pending.delete(id))
1642
+ start(id);
1643
+ }
1644
+ };
1645
+ return runs.onRunEnd((id) => {
1646
+ if (inFlight.has(id)) {
1647
+ pending.add(id);
1648
+ return;
1649
+ }
1650
+ start(id);
1651
+ });
1652
+ }
1653
+
1654
+ // packages/protocol/src/tool-target.ts
1655
+ var TARGET_KEYS = [
1656
+ "path",
1657
+ "file_path",
1658
+ "filePath",
1659
+ "command",
1660
+ "cmd",
1661
+ "query",
1662
+ "pattern",
1663
+ "url",
1664
+ "name",
1665
+ "description",
1666
+ "task"
1667
+ ];
1668
+ var TARGET_MAX_LENGTH = 120;
1669
+ function toolTarget(args) {
1670
+ if (typeof args !== "object" || args === null)
1671
+ return;
1672
+ for (const key of TARGET_KEYS) {
1673
+ const value = args[key];
1674
+ if (typeof value === "string" && value.length > 0) {
1675
+ return value.length > TARGET_MAX_LENGTH ? `${value.slice(0, TARGET_MAX_LENGTH)}…` : value;
1676
+ }
1677
+ }
1678
+ return;
1679
+ }
1680
+
1422
1681
  // apps/host/src/relay-link.ts
1423
1682
  import { WebSocket } from "ws";
1424
1683
  function relayWsBase(origin) {
@@ -1468,7 +1727,7 @@ function startRelayLink(options) {
1468
1727
  }
1469
1728
  }
1470
1729
  }, pingIntervalMs);
1471
- function watch(socket) {
1730
+ function watch2(socket) {
1472
1731
  watched.add(socket);
1473
1732
  ponged.set(socket, true);
1474
1733
  socket.on("pong", () => ponged.set(socket, true));
@@ -1480,7 +1739,7 @@ function startRelayLink(options) {
1480
1739
  return;
1481
1740
  const socket = new WebSocket(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
1482
1741
  control = socket;
1483
- watch(socket);
1742
+ watch2(socket);
1484
1743
  socket.on("message", onControlMessage);
1485
1744
  socket.once("close", onControlClosed);
1486
1745
  }
@@ -1519,7 +1778,7 @@ function startRelayLink(options) {
1519
1778
  break;
1520
1779
  pendingAccepts += 1;
1521
1780
  const data2 = new WebSocket(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
1522
- watch(data2);
1781
+ watch2(data2);
1523
1782
  const release = () => {
1524
1783
  pendingAccepts -= 1;
1525
1784
  clearTimeout(ageOut);
@@ -1566,9 +1825,26 @@ function startRelayLink(options) {
1566
1825
  }
1567
1826
 
1568
1827
  // apps/host/src/gateway.ts
1569
- var context = BACKGROUND_CONTEXT;
1828
+ var context2 = BACKGROUND_CONTEXT2;
1829
+ var forkTails = new WeakMap;
1830
+ function serializedFork(harness, work) {
1831
+ const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
1832
+ const settled = next.then(() => {}, () => {});
1833
+ forkTails.set(harness, settled);
1834
+ settled.then(() => {
1835
+ if (forkTails.get(harness) === settled)
1836
+ forkTails.delete(harness);
1837
+ });
1838
+ return next;
1839
+ }
1840
+ var LOOPBACK_SECURE_PATH = "/secure";
1570
1841
  async function startGateway(options) {
1571
- const conversations = await ConversationList.open(options.harness);
1842
+ const conversations = await ConversationList.open(options.harness, options.docs);
1843
+ const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
1844
+ harness: options.harness,
1845
+ models: options.models,
1846
+ settings: options.settings
1847
+ });
1572
1848
  const http = createServer((request, response) => {
1573
1849
  serveWebClient(request, response, options.webRoot).catch(() => {
1574
1850
  if (!response.headersSent)
@@ -1606,22 +1882,35 @@ async function startGateway(options) {
1606
1882
  },
1607
1883
  createPairing: () => {
1608
1884
  const spec = options.secure;
1609
- if (spec === undefined || remote === undefined && relay === undefined)
1885
+ if (spec === undefined)
1610
1886
  return;
1611
1887
  const { secret, expiresAt } = spec.offers.create();
1612
1888
  const pair = `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret}`;
1613
1889
  if (relay !== undefined) {
1614
- const target = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1615
- return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
1890
+ const target2 = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1891
+ return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target2)}`, expiresAt };
1616
1892
  }
1617
- return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
1893
+ if (remote !== undefined)
1894
+ return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
1895
+ const target = `ws://127.0.0.1:${port}${LOOPBACK_SECURE_PATH}`;
1896
+ return { url: `http://127.0.0.1:${port}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
1618
1897
  }
1619
1898
  });
1620
1899
  clients.add(client);
1621
1900
  };
1622
1901
  server.on("connection", (socket, request) => {
1623
1902
  socket.on("error", () => socket.terminate());
1624
- const token = new URL(request.url ?? "/", "ws://127.0.0.1").searchParams.get("token");
1903
+ const url = new URL(request.url ?? "/", "ws://127.0.0.1");
1904
+ if (url.pathname === LOOPBACK_SECURE_PATH) {
1905
+ const secure2 = options.secure;
1906
+ if (secure2 === undefined) {
1907
+ socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1908
+ return;
1909
+ }
1910
+ secureHandshake(socket, secure2, options.harness, register);
1911
+ return;
1912
+ }
1913
+ const token = url.searchParams.get("token");
1625
1914
  const origin = request.headers.origin;
1626
1915
  if (token !== options.token || origin !== undefined && !browserOrigins.includes(origin)) {
1627
1916
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
@@ -1659,6 +1948,7 @@ async function startGateway(options) {
1659
1948
  broadcast,
1660
1949
  async close() {
1661
1950
  await relay?.close();
1951
+ detachAutoTitle?.();
1662
1952
  conversations.dispose();
1663
1953
  await Promise.all([...clients].map((client) => client.dispose()));
1664
1954
  for (const socket of server.clients)
@@ -1759,12 +2049,12 @@ function secureHandshake(socket, secure, harness, register) {
1759
2049
  return;
1760
2050
  }
1761
2051
  const deviceKey = toBase64Url(remoteStatic);
1762
- if (!await isRegistered(harness, deviceKey, context)) {
2052
+ if (!await isRegistered(harness, deviceKey, context2)) {
1763
2053
  if (hello.pair === undefined || !secure.offers.consume(hello.pair.secret)) {
1764
2054
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1765
2055
  return;
1766
2056
  }
1767
- await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context);
2057
+ await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context2);
1768
2058
  }
1769
2059
  const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
1770
2060
  return { result: result2, message, deviceKey };
@@ -1807,36 +2097,153 @@ async function startRemote(options, register) {
1807
2097
 
1808
2098
  class ConversationList {
1809
2099
  #listeners = new Set;
1810
- #list;
2100
+ #questionTokens;
2101
+ #questionKinds;
2102
+ #records = new Map;
2103
+ #facts = new Map;
2104
+ #index = new Map;
2105
+ #runEnd = new Set;
2106
+ #list = [];
1811
2107
  #unsubscribe = () => {};
1812
2108
  #notifying = false;
1813
- constructor(list) {
1814
- this.#list = list;
2109
+ constructor(docs) {
2110
+ this.#questionTokens = (docs ?? []).filter((doc) => doc.presentation === "pinomad.question").map((doc) => doc.token);
2111
+ this.#questionKinds = new Set(this.#questionTokens.map((token) => token.definition.kind));
1815
2112
  }
1816
- static async open(harness) {
2113
+ static async open(harness, docs) {
2114
+ const list = new ConversationList(docs);
1817
2115
  const summaries = [];
1818
2116
  let cursor;
1819
2117
  do {
1820
- const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context);
1821
- for (const record of page.items)
1822
- summaries.push({ ...summaryOf(record), ...await firstInput(harness, record.id) });
2118
+ const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context2);
2119
+ for (const record of page.items) {
2120
+ list.#records.set(record.id, record);
2121
+ const facts = await list.#load(harness, record);
2122
+ list.#facts.set(record.id, facts);
2123
+ list.#index.set(record.id, summaries.length);
2124
+ summaries.push(summaryOf(record, facts));
2125
+ }
1823
2126
  cursor = page.next;
1824
2127
  } while (cursor !== undefined);
1825
- const list = new ConversationList(summaries);
1826
- list.#unsubscribe = harness.subscribeCommits((publication) => {
1827
- let next = list.#list;
1828
- for (const change of publication.changes) {
1829
- if (change.type === "conversation") {
1830
- next = [...next, summaryOf(change.value)];
1831
- } else if (change.type === "entry" && change.value.kind === "pi.user") {
1832
- const id = change.value.conversationId;
1833
- next = next.map((summary) => summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary);
2128
+ list.#list = summaries;
2129
+ list.#unsubscribe = harness.subscribeCommits((publication) => list.#observe(publication));
2130
+ return list;
2131
+ }
2132
+ async#load(harness, record) {
2133
+ const history = await historyFacts(harness, record.id);
2134
+ const facts = { pending: new Set, ...history };
2135
+ const live = await harness.snapshot(LiveDoc, record.id, context2);
2136
+ facts.running = live?.run !== undefined;
2137
+ facts.tools = live?.tools;
2138
+ for (const token of this.#questionTokens) {
2139
+ const value = await harness.snapshot(token, record.id, context2);
2140
+ if (Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined)) {
2141
+ facts.pending.add(token.definition.kind);
2142
+ }
2143
+ }
2144
+ if (record.owner !== undefined) {
2145
+ const subagent = await harness.snapshot(SubagentDoc, record.id, context2);
2146
+ if (typeof subagent?.label === "string" && subagent.label !== "")
2147
+ facts.label = subagent.label;
2148
+ if (typeof subagent?.role === "string" && subagent.role !== "")
2149
+ facts.role = subagent.role;
2150
+ }
2151
+ const named = (await harness.snapshot(TitleDoc, record.id, context2))?.title;
2152
+ if (typeof named === "string" && named !== "")
2153
+ facts.named = named;
2154
+ return facts;
2155
+ }
2156
+ #observe(publication) {
2157
+ const touched = new Set;
2158
+ const ended = new Set;
2159
+ for (const change of publication.changes) {
2160
+ if (change.type === "conversation") {
2161
+ this.#records.set(change.value.id, change.value);
2162
+ touched.add(change.value.id);
2163
+ }
2164
+ }
2165
+ for (const change of publication.changes) {
2166
+ if (change.type === "entry") {
2167
+ const entry = change.value;
2168
+ if (entry.kind !== "pi.user" && entry.kind !== "pi.assistant")
2169
+ continue;
2170
+ const facts = this.#fact(entry.conversationId);
2171
+ if (entry.kind === "pi.user" && facts.title === undefined)
2172
+ Object.assign(facts, titleOf(entry));
2173
+ if (facts.newest !== undefined && entry.id <= facts.newest)
2174
+ continue;
2175
+ touched.add(entry.conversationId);
2176
+ facts.newest = entry.id;
2177
+ const message = entry.model?.[0];
2178
+ facts.updatedAt = typeof message?.timestamp === "number" ? message.timestamp : undefined;
2179
+ facts.failed = entry.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error";
2180
+ facts.calls = entry.kind === "pi.assistant" ? callsOf(message) : undefined;
2181
+ } else if (change.type === "document") {
2182
+ const id = change.conversationId;
2183
+ if (id === undefined)
2184
+ continue;
2185
+ const facts = this.#fact(id);
2186
+ if (change.record.kind === "pi.live") {
2187
+ const live = change.value;
2188
+ const wasRunning = facts.running === true;
2189
+ facts.running = live?.run !== undefined;
2190
+ facts.tools = live?.tools;
2191
+ touched.add(id);
2192
+ if (wasRunning && !facts.running && this.#records.get(id)?.owner === undefined)
2193
+ ended.add(id);
2194
+ } else if (this.#questionKinds.has(change.record.kind)) {
2195
+ const value = change.value;
2196
+ const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
2197
+ const next2 = new Set(facts.pending);
2198
+ if (pending)
2199
+ next2.add(change.record.kind);
2200
+ else
2201
+ next2.delete(change.record.kind);
2202
+ facts.pending = next2;
2203
+ touched.add(id);
2204
+ } else if (change.record.kind === SubagentDoc.definition.kind) {
2205
+ const value = change.value;
2206
+ facts.label = typeof value?.label === "string" && value.label !== "" ? value.label : undefined;
2207
+ facts.role = typeof value?.role === "string" && value.role !== "" ? value.role : undefined;
2208
+ touched.add(id);
2209
+ } else if (change.record.kind === TitleDoc.definition.kind) {
2210
+ const named = change.value?.title;
2211
+ facts.named = typeof named === "string" && named !== "" ? named : undefined;
2212
+ touched.add(id);
1834
2213
  }
1835
2214
  }
1836
- if (next !== list.#list)
1837
- list.#set(next);
1838
- });
1839
- return list;
2215
+ }
2216
+ let next;
2217
+ for (const id of touched) {
2218
+ const record = this.#records.get(id);
2219
+ if (record === undefined)
2220
+ continue;
2221
+ const summary = summaryOf(record, this.#fact(id));
2222
+ const index = this.#index.get(id);
2223
+ if (index === undefined) {
2224
+ next ??= this.#list.slice();
2225
+ this.#index.set(id, next.length);
2226
+ next.push(summary);
2227
+ } else if (!sameSummary(this.#list[index], summary)) {
2228
+ next ??= this.#list.slice();
2229
+ next[index] = summary;
2230
+ }
2231
+ }
2232
+ if (next !== undefined)
2233
+ this.#set(next);
2234
+ for (const id of ended)
2235
+ for (const listener of this.#runEnd)
2236
+ listener(id);
2237
+ }
2238
+ onRunEnd(listener) {
2239
+ this.#runEnd.add(listener);
2240
+ return () => this.#runEnd.delete(listener);
2241
+ }
2242
+ #fact(id) {
2243
+ let facts = this.#facts.get(id);
2244
+ if (facts === undefined)
2245
+ this.#facts.set(id, facts = { pending: new Set });
2246
+ return facts;
1840
2247
  }
1841
2248
  get value() {
1842
2249
  return this.#list;
@@ -1861,31 +2268,72 @@ class ConversationList {
1861
2268
  this.#listeners.clear();
1862
2269
  }
1863
2270
  }
1864
- function summaryOf(record) {
2271
+ function summaryOf(record, facts) {
1865
2272
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2273
+ const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2274
+ const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
2275
+ const activity = runningSlot === undefined ? undefined : {
2276
+ tool: runningSlot.name,
2277
+ ...facts.calls?.get(runningSlot.callId) === undefined ? {} : { target: facts.calls.get(runningSlot.callId) }
2278
+ };
1866
2279
  return {
1867
2280
  id: record.id,
1868
2281
  kind: record.owner !== undefined ? "subagent" : record.parent !== undefined ? "fork" : "conversation",
1869
- ...parent === undefined ? {} : { parent }
2282
+ ...parent === undefined ? {} : { parent },
2283
+ ...record.parent === undefined ? {} : { forkedAt: String(record.parent.at) },
2284
+ ...(facts.named ?? facts.title) === undefined ? {} : { title: facts.named ?? facts.title },
2285
+ ...status === undefined ? {} : { status },
2286
+ ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2287
+ ...facts.label === undefined ? {} : { label: facts.label },
2288
+ ...facts.role === undefined ? {} : { role: facts.role },
2289
+ ...activity === undefined ? {} : { activity }
1870
2290
  };
1871
2291
  }
2292
+ function sameSummary(a, b) {
2293
+ 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;
2294
+ }
1872
2295
  function titleOf(entry) {
1873
2296
  const message = entry?.model?.[0];
1874
2297
  if (message?.role !== "user")
1875
2298
  return {};
1876
2299
  const text = typeof message.content === "string" ? message.content : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
1877
- return { title: text.replace(/\s+/g, " ").trim() };
2300
+ const title = text.replace(/\s+/g, " ").trim();
2301
+ return title === "" ? {} : { title };
1878
2302
  }
1879
- async function firstInput(harness, id) {
1880
- const conversation = await harness.conversation(id, context);
2303
+ async function historyFacts(harness, id) {
2304
+ const conversation = await harness.conversation(id, context2);
1881
2305
  let first;
2306
+ let newest;
1882
2307
  let cursor;
1883
2308
  do {
1884
- const page = await conversation.entries({}, 256, cursor, context);
1885
- first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id) ?? first;
2309
+ const page = await conversation.entries({}, 256, cursor, context2);
2310
+ first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id && titleOf(entry).title !== undefined) ?? first;
2311
+ newest ??= page.items.find((entry) => entry.conversationId === id && (entry.kind === "pi.user" || entry.kind === "pi.assistant"));
1886
2312
  cursor = page.next;
1887
2313
  } while (cursor !== undefined);
1888
- return titleOf(first);
2314
+ const message = newest?.model?.[0];
2315
+ return {
2316
+ ...titleOf(first),
2317
+ ...newest === undefined ? {} : {
2318
+ newest: newest.id,
2319
+ ...typeof message?.timestamp === "number" ? { updatedAt: message.timestamp } : {},
2320
+ failed: newest.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error",
2321
+ ...newest.kind === "pi.assistant" ? { calls: callsOf(message) } : {}
2322
+ }
2323
+ };
2324
+ }
2325
+ function callsOf(message) {
2326
+ if (message?.role !== "assistant")
2327
+ return;
2328
+ const content = message.content;
2329
+ if (!Array.isArray(content))
2330
+ return;
2331
+ const calls = new Map;
2332
+ for (const block of content) {
2333
+ if (block?.type === "toolCall" && typeof block.id === "string")
2334
+ calls.set(block.id, toolTarget(block.arguments));
2335
+ }
2336
+ return calls.size === 0 ? undefined : calls;
1889
2337
  }
1890
2338
 
1891
2339
  class GatewayClient {
@@ -1893,38 +2341,59 @@ class GatewayClient {
1893
2341
  #connection;
1894
2342
  #hooks;
1895
2343
  #options;
1896
- #conversations;
2344
+ #hostValues;
1897
2345
  #subscriptions = new Map;
1898
2346
  #disposed = false;
1899
2347
  constructor(connection, options, conversations, hooks) {
1900
2348
  this.#connection = connection;
1901
2349
  this.#options = options;
1902
- this.#conversations = conversations;
2350
+ this.#hostValues = {
2351
+ conversations,
2352
+ mcp: options.mcp ?? constantValue(null),
2353
+ models: options.modelAuth?.models ?? constantValue(options.modelSummaries()),
2354
+ auth: options.modelAuth?.auth ?? constantValue({ logins: [] }),
2355
+ settings: options.settings,
2356
+ agents: options.agents ?? constantValue({ roles: [], problems: [] })
2357
+ };
1903
2358
  this.#hooks = hooks;
1904
2359
  this.devicePublicKey = hooks.devicePublicKey;
1905
2360
  connection.handle({
1906
2361
  message: (text) => {
1907
- let frame;
2362
+ let parsed;
1908
2363
  try {
1909
- frame = JSON.parse(text);
2364
+ parsed = parseClientFrame(JSON.parse(text));
1910
2365
  } catch {
1911
- connection.close(1008, "Invalid client frame");
2366
+ connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
1912
2367
  return;
1913
2368
  }
1914
- if (!isClientFrame(frame)) {
1915
- connection.close(1008, "Invalid client frame");
1916
- return;
2369
+ switch (parsed.kind) {
2370
+ case "frame":
2371
+ this.#receive(parsed.frame).catch(() => connection.close(1011, "Gateway request failed"));
2372
+ return;
2373
+ case "unsupportedCall":
2374
+ this.send({ type: "result", id: parsed.id, ok: false, error: `Unsupported method: ${parsed.method}` });
2375
+ return;
2376
+ case "unsupportedStream":
2377
+ if (parsed.type === "subscribe") {
2378
+ this.send({ type: "ended", stream: parsed.stream, reason: "Unsupported stream" });
2379
+ }
2380
+ return;
2381
+ case "unknownType":
2382
+ return;
2383
+ case "malformed":
2384
+ connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
2385
+ return;
1917
2386
  }
1918
- this.#receive(frame).catch(() => connection.close(1011, "Gateway request failed"));
1919
2387
  },
1920
2388
  closed: () => hooks.closed(this)
1921
2389
  });
1922
2390
  this.send({
1923
2391
  type: "hello",
1924
- protocol: PROTOCOL_VERSION,
2392
+ protocol: PROTOCOL,
2393
+ ...packagedVersion === undefined ? {} : { hostVersion: packagedVersion },
1925
2394
  session: options.session,
1926
2395
  models: options.modelSummaries(),
1927
- defaults: options.defaults,
2396
+ defaults: settingsDefaults(options.settings.value),
1928
2397
  docs: (options.docs ?? []).map((doc) => ({
1929
2398
  kind: doc.token.definition.kind,
1930
2399
  ...doc.presentation === undefined ? {} : { presentation: doc.presentation }
@@ -1975,36 +2444,34 @@ class GatewayClient {
1975
2444
  state.subscription = subscription;
1976
2445
  }
1977
2446
  async#open(stream, send) {
1978
- if (stream === "conversations") {
1979
- send({ type: "snapshot", stream, value: this.#conversations.value });
1980
- const unsubscribe = this.#conversations.subscribe((value) => void send({ type: "snapshot", stream, value }));
1981
- return { stop: unsubscribe };
2447
+ const source = this.#hostValue(stream);
2448
+ if (source !== undefined) {
2449
+ send({ type: "snapshot", stream, value: source.value });
2450
+ return { stop: source.subscribe((value) => void send({ type: "snapshot", stream, value })) };
1982
2451
  }
1983
2452
  if (stream === "index") {
1984
- const watch = await this.#options.harness.watchDoc(IndexDoc, context);
1985
- if (watch === undefined)
2453
+ const watch2 = await this.#options.harness.watchDoc(IndexDoc, context2);
2454
+ if (watch2 === undefined)
1986
2455
  throw new Error("Index document is missing");
1987
- return this.#forward(stream, watch, send);
2456
+ return this.#forward(stream, watch2, send);
1988
2457
  }
1989
2458
  if (stream === "devices") {
1990
- const watch = await this.#options.harness.watchDoc(DevicesDoc, context);
1991
- if (watch === undefined)
2459
+ const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context2);
2460
+ if (watch2 === undefined)
1992
2461
  throw new Error("Devices document is missing");
1993
- return this.#forward(stream, watch, send);
2462
+ return this.#forward(stream, watch2, send);
1994
2463
  }
1995
2464
  if (stream === "tasks")
1996
- return this.#forward(stream, await this.#options.harness.watchTaskGraph(context), send);
1997
- if (stream === "mcp") {
1998
- await send({ type: "snapshot", stream, value: this.#options.mcp?.value ?? null });
1999
- const mcp = this.#options.mcp;
2000
- return mcp === undefined ? { stop: () => {} } : { stop: mcp.subscribe((value) => void send({ type: "snapshot", stream, value })) };
2001
- }
2465
+ return this.#forward(stream, await this.#options.harness.watchTaskGraph(context2), send);
2002
2466
  if (stream.startsWith("doc:")) {
2003
2467
  const at = stream.lastIndexOf(":");
2004
2468
  return this.#openDoc(stream, stream.slice("doc:".length, at), Number(stream.slice(at + 1)), send);
2005
2469
  }
2006
2470
  const conversation = await this.#conversation(Number(stream.slice("conversation:".length)));
2007
- return this.#forward(stream, await conversation.watch(context), send);
2471
+ return this.#forward(stream, await conversation.watch(context2), send);
2472
+ }
2473
+ #hostValue(stream) {
2474
+ return Object.hasOwn(this.#hostValues, stream) ? this.#hostValues[stream] : undefined;
2008
2475
  }
2009
2476
  async#openDoc(stream, kind, id, send) {
2010
2477
  const doc = this.#options.docs?.find((candidate) => candidate.token.definition.kind === kind);
@@ -2027,7 +2494,7 @@ class GatewayClient {
2027
2494
  setImmediate(() => signalCreation());
2028
2495
  });
2029
2496
  try {
2030
- const existing = await harness.watchDoc(token, id, context);
2497
+ const existing = await harness.watchDoc(token, id, context2);
2031
2498
  if (existing !== undefined) {
2032
2499
  unsubscribe();
2033
2500
  return this.#forward(stream, existing, send);
@@ -2040,13 +2507,13 @@ class GatewayClient {
2040
2507
  created.then(async () => {
2041
2508
  if (stopped)
2042
2509
  return;
2043
- const watch = await harness.watchDoc(token, id, context);
2044
- if (watch === undefined)
2510
+ const watch2 = await harness.watchDoc(token, id, context2);
2511
+ if (watch2 === undefined)
2045
2512
  return;
2046
2513
  if (stopped)
2047
- await watch.stop();
2514
+ await watch2.stop();
2048
2515
  else {
2049
- inner = await this.#forward(stream, watch, send);
2516
+ inner = await this.#forward(stream, watch2, send);
2050
2517
  if (stopped)
2051
2518
  await inner.stop();
2052
2519
  }
@@ -2063,10 +2530,10 @@ class GatewayClient {
2063
2530
  }
2064
2531
  };
2065
2532
  }
2066
- async#forward(stream, watch, send) {
2067
- await send({ type: "snapshot", stream, value: watch.value });
2068
- watch.start((_value, ops) => send({ type: "ops", stream, ops }));
2069
- return watch;
2533
+ async#forward(stream, watch2, send) {
2534
+ await send({ type: "snapshot", stream, value: watch2.value });
2535
+ watch2.start((_value, ops) => send({ type: "ops", stream, ops }));
2536
+ return watch2;
2070
2537
  }
2071
2538
  async#unsubscribe(stream) {
2072
2539
  const subscription = this.#subscriptions.get(stream);
@@ -2074,7 +2541,7 @@ class GatewayClient {
2074
2541
  await subscription?.subscription?.stop();
2075
2542
  }
2076
2543
  async#conversation(id) {
2077
- const conversation = await this.#options.harness.conversation(id, context);
2544
+ const conversation = await this.#options.harness.conversation(id, context2);
2078
2545
  if (conversation === undefined)
2079
2546
  throw new Error(`Conversation ${id} does not exist`);
2080
2547
  return conversation;
@@ -2090,27 +2557,31 @@ class GatewayClient {
2090
2557
  async#call(method, args) {
2091
2558
  switch (method) {
2092
2559
  case "addProject":
2093
- return addProject(this.#options.harness, args.path, context);
2560
+ return addProject(this.#options.harness, args.path, context2);
2094
2561
  case "removeProject":
2095
- await removeProject(this.#options.harness, args.path, context);
2562
+ await removeProject(this.#options.harness, args.path, context2);
2096
2563
  return null;
2097
2564
  case "createConversation": {
2098
2565
  const create = args;
2566
+ const problem = imageProblem(create.images ?? []);
2567
+ if (problem !== undefined)
2568
+ throw new Error(problem);
2099
2569
  const conversationId = await createConversation(this.#options.harness, this.#options.dataDir, {
2100
2570
  home: create.home,
2101
2571
  text: create.text,
2102
2572
  requestId: create.requestId,
2573
+ ...create.images === undefined ? {} : { images: create.images },
2103
2574
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2104
2575
  ...create.model === undefined ? {} : { model: create.model },
2105
2576
  ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2106
- }, this.#options.defaults, this.#options.models, context);
2577
+ }, settingsDefaults(this.#options.settings.value), this.#options.models, context2);
2107
2578
  return { conversationId };
2108
2579
  }
2109
2580
  case "archive": {
2110
2581
  const input = args;
2111
- await archive(this.#options.harness, input.conversationId, input.archived, context);
2582
+ await archive(this.#options.harness, input.conversationId, input.archived, context2);
2112
2583
  if (input.archived) {
2113
- const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context);
2584
+ const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context2);
2114
2585
  for (const { record, reason } of kept) {
2115
2586
  await this.send({ type: "notice", level: "warning", message: `Kept worktree ${record.path} (branch ${record.branch}): ${reason}` });
2116
2587
  }
@@ -2125,31 +2596,88 @@ class GatewayClient {
2125
2596
  }
2126
2597
  case "revokeDevice": {
2127
2598
  const { publicKey } = args;
2128
- await revokeDevice(this.#options.harness, publicKey, context);
2599
+ await revokeDevice(this.#options.harness, publicKey, context2);
2129
2600
  this.#hooks.closeDevice(publicKey);
2130
2601
  return null;
2131
2602
  }
2603
+ case "logout": {
2604
+ const modelAuth = this.#options.modelAuth;
2605
+ if (modelAuth === undefined)
2606
+ throw new Error("Logging out is not available on this host");
2607
+ await modelAuth.logout(args.providerId);
2608
+ return null;
2609
+ }
2610
+ case "updateSettings": {
2611
+ await this.#options.settings.update(args.patch);
2612
+ return null;
2613
+ }
2614
+ case "createAgent": {
2615
+ const store = this.#options.agents;
2616
+ if (store === undefined)
2617
+ throw new Error("Named agents are not available on this host");
2618
+ await store.create(args.role);
2619
+ return null;
2620
+ }
2621
+ case "updateAgent": {
2622
+ const store = this.#options.agents;
2623
+ if (store === undefined)
2624
+ throw new Error("Named agents are not available on this host");
2625
+ await store.update(args.role);
2626
+ return null;
2627
+ }
2628
+ case "deleteAgent": {
2629
+ const store = this.#options.agents;
2630
+ if (store === undefined)
2631
+ throw new Error("Named agents are not available on this host");
2632
+ await store.remove(args.name);
2633
+ return null;
2634
+ }
2635
+ case "listFolders":
2636
+ return listFolders(args.path, homedir2());
2132
2637
  }
2133
2638
  const conversation = await this.#conversation(args.conversationId);
2134
2639
  switch (method) {
2135
2640
  case "submit": {
2136
- const { text, whenBusy, requestId } = args;
2137
- const dismissed = await this.#dismissPendingQuestions(conversation.id, context);
2138
- const submission = await conversation.submit({ type: "input", content: text, whenBusy: dismissed ? "steer" : whenBusy, requestId }, context);
2139
- submission.wait(context).then((settled) => {
2641
+ const { text, whenBusy, requestId, images } = args;
2642
+ const problem = imageProblem(images ?? []);
2643
+ if (problem !== undefined)
2644
+ throw new Error(problem);
2645
+ const dismissed = await this.#dismissPendingQuestions(conversation.id, context2);
2646
+ const submission = await conversation.submit({ type: "input", content: messageContent(text, images), whenBusy: dismissed ? "steer" : whenBusy, requestId }, context2);
2647
+ submission.wait(context2).then((settled) => {
2140
2648
  if (settled.status === "unanswered" && settled.reason !== "aborted") {
2141
2649
  this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
2142
2650
  }
2143
2651
  }, () => {});
2144
2652
  return { submissionId: submission.id };
2145
2653
  }
2654
+ case "renameConversation": {
2655
+ const { title } = args;
2656
+ const trimmed = title.trim();
2657
+ if (trimmed === "")
2658
+ throw new Error("Title cannot be empty");
2659
+ if (trimmed.length > 200)
2660
+ throw new Error("Title is too long (200 characters at most)");
2661
+ await this.#options.harness.commit(async (tx) => {
2662
+ const record = await tx.conversation(conversation.id);
2663
+ if (record === undefined)
2664
+ throw new Error(`Conversation ${conversation.id} does not exist`);
2665
+ if (record.owner !== undefined) {
2666
+ throw new Error("Subagent conversations are named by the call that started them");
2667
+ }
2668
+ const doc = await tx.doc(TitleDoc, conversation.id);
2669
+ doc.title = trimmed;
2670
+ doc.source = "manual";
2671
+ }, context2);
2672
+ return null;
2673
+ }
2146
2674
  case "abort":
2147
- await conversation.abort(context);
2675
+ await conversation.abort(context2);
2148
2676
  return null;
2149
2677
  case "compact": {
2150
2678
  const { instructions } = args;
2151
- const taskId = await conversation.compact(instructions, context);
2152
- this.#options.harness.waitForTask(taskId, context).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
2679
+ const taskId = await conversation.compact(instructions, context2);
2680
+ this.#options.harness.waitForTask(taskId, context2).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
2153
2681
  return { taskId };
2154
2682
  }
2155
2683
  case "setModel": {
@@ -2158,7 +2686,7 @@ class GatewayClient {
2158
2686
  if (model === undefined)
2159
2687
  throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
2160
2688
  const thinking = (await this.#agent(conversation.id)).thinkingLevel ?? "off";
2161
- await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context);
2689
+ await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context2);
2162
2690
  return null;
2163
2691
  }
2164
2692
  case "setThinkingLevel": {
@@ -2170,20 +2698,39 @@ class GatewayClient {
2170
2698
  if (!getSupportedThinkingLevels(model).includes(level)) {
2171
2699
  throw new Error(`Thinking level ${level} is not supported by ${agent.model.provider}/${agent.model.modelId}`);
2172
2700
  }
2173
- await conversation.configure({ thinkingLevel: level }, context);
2701
+ await conversation.configure({ thinkingLevel: level }, context2);
2174
2702
  return null;
2175
2703
  }
2176
2704
  case "fork": {
2177
2705
  const { entryId, removeTools = [] } = args;
2178
- const agent = await conversation.agent(context);
2179
- const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2180
- const init = await this.#forkCheckout(agent.cwd);
2181
- const fork = await conversation.fork(Number(entryId), {
2182
- ownership: { kind: "ownerless" },
2183
- ...remove.length === 0 ? {} : { agent: { tools: { remove } } },
2184
- ...init === undefined ? {} : { init }
2185
- }, context);
2186
- return { conversationId: fork.id };
2706
+ return await serializedFork(this.#options.harness, async () => {
2707
+ const at = Number(entryId);
2708
+ await this.#options.harness.commit(async (tx) => {
2709
+ const record = await tx.conversation(conversation.id);
2710
+ if (record === undefined)
2711
+ throw new Error(`Conversation ${conversation.id} does not exist`);
2712
+ if (record.parent !== undefined || record.owner !== undefined) {
2713
+ throw new Error("Only top-level conversations can be forked");
2714
+ }
2715
+ let cursor;
2716
+ do {
2717
+ const page = await tx.scanConversations({}, 256, cursor);
2718
+ for (const candidate of page.items) {
2719
+ if (candidate.parent?.conversationId === conversation.id && candidate.parent.at === at) {
2720
+ throw new Error("This message already has a fork");
2721
+ }
2722
+ }
2723
+ cursor = page.next;
2724
+ } while (cursor !== undefined);
2725
+ }, context2);
2726
+ const agent = await conversation.agent(context2);
2727
+ const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2728
+ const fork = await conversation.fork(at, {
2729
+ ownership: { kind: "ownerless" },
2730
+ ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
2731
+ }, context2);
2732
+ return { conversationId: fork.id };
2733
+ });
2187
2734
  }
2188
2735
  case "answer": {
2189
2736
  const { kind, requestId, answers } = args;
@@ -2217,16 +2764,16 @@ class GatewayClient {
2217
2764
  }
2218
2765
  request.resolution = { outcome: "answered", answers, at: Date.now() };
2219
2766
  return { first: true };
2220
- }, context);
2767
+ }, context2);
2221
2768
  }
2222
2769
  case "changes": {
2223
- return await this.#changes(conversation, context);
2770
+ return await this.#changes(conversation, context2);
2224
2771
  }
2225
2772
  }
2226
2773
  }
2227
- async#dismissPendingQuestions(conversationId, context2) {
2774
+ async#dismissPendingQuestions(conversationId, context3) {
2228
2775
  const questionDocs = (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
2229
- const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context2)));
2776
+ const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context3)));
2230
2777
  const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
2231
2778
  if (!pending)
2232
2779
  return false;
@@ -2244,18 +2791,18 @@ class GatewayClient {
2244
2791
  }
2245
2792
  }
2246
2793
  return dismissed;
2247
- }, context2);
2794
+ }, context3);
2248
2795
  }
2249
2796
  get #chatsPrefix() {
2250
- return join4(this.#options.dataDir, "chats") + sep3;
2797
+ return join6(this.#options.dataDir, "chats") + sep3;
2251
2798
  }
2252
- async#changes(conversation, context2) {
2253
- const cwd = (await conversation.agent(context2)).cwd;
2799
+ async#changes(conversation, context3) {
2800
+ const cwd = (await conversation.agent(context3)).cwd;
2254
2801
  if (cwd === undefined)
2255
2802
  return { available: false, reason: "No working directory" };
2256
2803
  if (cwd.startsWith(this.#chatsPrefix))
2257
2804
  return { available: false, reason: "Chat conversations have no repository" };
2258
- const index = await this.#options.harness.snapshot(IndexDoc, context2);
2805
+ const index = await this.#options.harness.snapshot(IndexDoc, context3);
2259
2806
  const record = checkoutAt(index?.checkouts, cwd);
2260
2807
  if (record !== undefined) {
2261
2808
  if (!await worktreeExists(record.path))
@@ -2267,43 +2814,8 @@ class GatewayClient {
2267
2814
  return { available: false, reason: "Not in a git repository" };
2268
2815
  return { available: true, ...await changesOf(base.repo, base.base) };
2269
2816
  }
2270
- async#forkCheckout(cwd) {
2271
- if (cwd === undefined)
2272
- return;
2273
- const index = await this.#options.harness.snapshot(IndexDoc, context);
2274
- const existing = checkoutAt(index?.checkouts, cwd);
2275
- let source;
2276
- if (existing !== undefined) {
2277
- source = { repo: existing.repo, dir: existing.path, subdir: relative2(existing.path, cwd) };
2278
- } else {
2279
- if (cwd.startsWith(this.#chatsPrefix))
2280
- return;
2281
- const base2 = await gitBase(cwd);
2282
- if (base2 === undefined)
2283
- return;
2284
- source = { repo: base2.repo, dir: base2.repo, subdir: base2.subdir };
2285
- }
2286
- const { base, snapshot } = await snapshotOf(source.dir);
2287
- const suffix = branchSuffix();
2288
- return async (tx, id) => {
2289
- const path = worktreePath(this.#options.dataDir, id);
2290
- const doc = await tx.doc(IndexDoc);
2291
- if (doc.checkouts === undefined)
2292
- doc.checkouts = [];
2293
- doc.checkouts.push({
2294
- conversationId: id,
2295
- path,
2296
- repo: source.repo,
2297
- subdir: source.subdir,
2298
- branch: worktreeBranch(id, suffix),
2299
- base,
2300
- snapshot
2301
- });
2302
- (await tx.doc(AgentDoc3, id)).cwd = join4(path, source.subdir);
2303
- };
2304
- }
2305
2817
  async#agent(id) {
2306
- return await this.#options.harness.snapshot(AgentDoc3, id, context) ?? {};
2818
+ return await this.#options.harness.snapshot(AgentDoc3, id, context2) ?? {};
2307
2819
  }
2308
2820
  async dispose() {
2309
2821
  if (this.#disposed)
@@ -2317,20 +2829,20 @@ class GatewayClient {
2317
2829
  }
2318
2830
 
2319
2831
  // apps/host/src/host.ts
2320
- var context2 = BACKGROUND_CONTEXT2;
2832
+ var context3 = BACKGROUND_CONTEXT3;
2321
2833
  async function hostToken(dataDir) {
2322
- const path = join5(dataDir, "token");
2834
+ const path = join7(dataDir, "token");
2323
2835
  try {
2324
- return (await readFile2(path, "utf8")).trim();
2836
+ return (await readFile4(path, "utf8")).trim();
2325
2837
  } catch {
2326
- const token = randomBytes2(24).toString("base64url");
2327
- await writeFile(path, `${token}
2838
+ const token = randomBytes3(24).toString("base64url");
2839
+ await writeFile3(path, `${token}
2328
2840
  `, { mode: 384 });
2329
2841
  return token;
2330
2842
  }
2331
2843
  }
2332
2844
  async function openHost(options) {
2333
- await mkdir2(options.dataDir, { recursive: true });
2845
+ await mkdir4(options.dataDir, { recursive: true });
2334
2846
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
2335
2847
  const release = await lockfile.lock(options.dataDir, {
2336
2848
  realpath: false,
@@ -2341,18 +2853,43 @@ async function openHost(options) {
2341
2853
  });
2342
2854
  let harness;
2343
2855
  let mcp;
2856
+ let agentRoles;
2857
+ let extensions = [];
2344
2858
  try {
2345
2859
  const token = await hostToken(options.dataDir);
2346
2860
  const reports = [];
2347
2861
  let report = (error) => void reports.push(error);
2348
2862
  const registry = createRegistry();
2349
- const extensions = typeof options.extensions === "function" ? options.extensions({ scriptTools: () => mcp?.scriptTools() ?? [] }) : options.extensions ?? [];
2863
+ agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
2864
+ extensions = typeof options.extensions === "function" ? options.extensions({
2865
+ harness: () => {
2866
+ if (harness === undefined)
2867
+ throw new Error("harness is not open yet");
2868
+ return harness;
2869
+ },
2870
+ agentRoles
2871
+ }) : options.extensions ?? [];
2350
2872
  for (const { extension } of extensions)
2351
2873
  registry.install(extension);
2352
- mcp = options.mcpConfig === undefined ? undefined : startMcp({ configPath: options.mcpConfig, registry });
2874
+ mcp = options.mcpConfig === undefined ? undefined : startMcp({
2875
+ configPath: options.mcpConfig,
2876
+ registry,
2877
+ onNamespaces: (namespaces) => {
2878
+ for (const each of extensions) {
2879
+ const rebuilt = each.namespaced?.(namespaces);
2880
+ if (rebuilt !== undefined)
2881
+ registry.install(rebuilt);
2882
+ }
2883
+ }
2884
+ });
2885
+ const hostSettings = await openHostSettings({
2886
+ path: join7(options.dataDir, "settings.json"),
2887
+ fallback: options.settingsFallback,
2888
+ models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
2889
+ });
2353
2890
  const environments = new Map;
2354
- const chatsDir = join5(options.dataDir, "chats");
2355
- harness = await Harness.open(await openNodeSqliteStorage(join5(options.dataDir, "session.sqlite")), {
2891
+ const chatsDir = join7(options.dataDir, "chats");
2892
+ harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join7(options.dataDir, "session.sqlite")), {
2356
2893
  models: options.models,
2357
2894
  registry,
2358
2895
  env: async (target) => {
@@ -2361,15 +2898,15 @@ async function openHost(options) {
2361
2898
  return;
2362
2899
  const worktrees = worktreeRoot(options.dataDir);
2363
2900
  if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
2364
- const index = await target.read.snapshot(IndexDoc, context2);
2901
+ const index = await target.read.snapshot(IndexDoc, context3);
2365
2902
  const record = checkoutAt(index?.checkouts, cwd);
2366
2903
  if (record !== undefined) {
2367
2904
  await ensureWorktree(record);
2368
- await mkdir2(cwd, { recursive: true });
2905
+ await mkdir4(cwd, { recursive: true });
2369
2906
  }
2370
2907
  }
2371
2908
  if (cwd.startsWith(`${chatsDir}/`))
2372
- await mkdir2(cwd, { recursive: true });
2909
+ await mkdir4(cwd, { recursive: true });
2373
2910
  let env = environments.get(cwd);
2374
2911
  if (env === undefined) {
2375
2912
  env = new NodeExecutionEnv({ cwd });
@@ -2377,54 +2914,51 @@ async function openHost(options) {
2377
2914
  }
2378
2915
  return env;
2379
2916
  },
2380
- ...options.settings === undefined ? {} : { settings: options.settings },
2917
+ ...options.settings === undefined ? {} : {
2918
+ settings: typeof options.settings === "function" ? options.settings(hostSettings) : options.settings
2919
+ },
2381
2920
  onReport: (error) => report(error)
2382
- }, context2);
2383
- const { initialModel } = options;
2384
- await ensureIndex(harness, context2);
2385
- await ensureDevices(harness, context2);
2921
+ }, context3);
2922
+ await ensureIndex(harness, context3);
2923
+ await ensureDevices(harness, context3);
2386
2924
  for (const path of options.projects ?? [])
2387
- await addProject(harness, path, context2);
2388
- const remoteAccess = options.remote !== undefined || options.relay !== undefined;
2389
- const hostKey = remoteAccess ? await loadHostKey(options.dataDir) : undefined;
2390
- const offers = remoteAccess ? pairingOffers(options.remote?.pairingTtlMs) : undefined;
2925
+ await addProject(harness, path, context3);
2926
+ const hostKey = await loadHostKey(options.dataDir);
2927
+ const offers = pairingOffers(options.remote?.pairingTtlMs);
2391
2928
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
2392
- const defaults = initialModel === undefined ? {} : {
2393
- model: { provider: initialModel.provider, modelId: initialModel.modelId },
2394
- ...initialModel.thinkingLevel === undefined ? {} : { thinkingLevel: initialModel.thinkingLevel }
2395
- };
2396
2929
  const gateway = await startGateway({
2397
2930
  harness,
2398
2931
  models: options.models,
2399
2932
  modelSummaries: options.modelSummaries,
2400
2933
  session: { id: basename2(options.dataDir), directory: options.dataDir },
2401
2934
  dataDir: options.dataDir,
2402
- defaults,
2935
+ settings: hostSettings,
2403
2936
  token,
2404
2937
  port: options.port,
2938
+ agents: agentRoles,
2405
2939
  ...options.browserOrigins === undefined ? {} : { browserOrigins: options.browserOrigins },
2406
2940
  docs: extensions.flatMap((extension) => extension.docs ?? []),
2407
2941
  toolPresentations: Object.assign({}, ...extensions.map((extension) => extension.tools ?? {})),
2408
2942
  ...mcp === undefined ? {} : { mcp: mcp.status },
2943
+ ...options.modelAuth === undefined ? {} : { modelAuth: options.modelAuth },
2409
2944
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
2410
- ...hostKey === undefined || offers === undefined ? {} : {
2411
- secure: {
2412
- hostKey,
2413
- offers,
2414
- ...options.remote?.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: options.remote.handshakeTimeoutMs },
2415
- ...options.remote === undefined ? {} : {
2416
- lan: {
2417
- port: options.remote.port,
2418
- ...options.remote.publicUrl === undefined ? {} : { publicUrl: options.remote.publicUrl }
2419
- }
2420
- },
2421
- ...options.relay === undefined || relayKey === undefined ? {} : {
2422
- relay: {
2423
- origin: options.relay.origin,
2424
- signingKey: relayKey,
2425
- ...options.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: options.relay.reconnectDelayMs },
2426
- ...options.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: options.relay.pingIntervalMs }
2427
- }
2945
+ ...options.autoTitle === undefined ? {} : { autoTitle: options.autoTitle },
2946
+ secure: {
2947
+ hostKey,
2948
+ offers,
2949
+ ...options.remote?.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: options.remote.handshakeTimeoutMs },
2950
+ ...options.remote === undefined ? {} : {
2951
+ lan: {
2952
+ port: options.remote.port,
2953
+ ...options.remote.publicUrl === undefined ? {} : { publicUrl: options.remote.publicUrl }
2954
+ }
2955
+ },
2956
+ ...options.relay === undefined || relayKey === undefined ? {} : {
2957
+ relay: {
2958
+ origin: options.relay.origin,
2959
+ signingKey: relayKey,
2960
+ ...options.relay.reconnectDelayMs === undefined ? {} : { reconnectDelayMs: options.relay.reconnectDelayMs },
2961
+ ...options.relay.pingIntervalMs === undefined ? {} : { pingIntervalMs: options.relay.pingIntervalMs }
2428
2962
  }
2429
2963
  }
2430
2964
  }
@@ -2439,15 +2973,21 @@ async function openHost(options) {
2439
2973
  url: gateway.url,
2440
2974
  token,
2441
2975
  harness,
2976
+ closed: opened.closed,
2977
+ settings: hostSettings,
2442
2978
  ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
2443
2979
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
2444
2980
  close() {
2445
2981
  closing ??= (async () => {
2446
2982
  try {
2447
2983
  await gateway.close();
2448
- await opened.close(context2);
2984
+ await opened.close(context3);
2449
2985
  await mcp?.close();
2450
- await Promise.all([...environments.values()].map((env) => env.cleanup(context2)));
2986
+ agentRoles?.close();
2987
+ for (const each of extensions)
2988
+ await each.close?.();
2989
+ options.modelAuth?.close();
2990
+ await Promise.all([...environments.values()].map((env) => env.cleanup(context3)));
2451
2991
  } finally {
2452
2992
  await release();
2453
2993
  }
@@ -2456,15 +2996,20 @@ async function openHost(options) {
2456
2996
  }
2457
2997
  };
2458
2998
  } catch (error) {
2999
+ options.modelAuth?.close();
3000
+ agentRoles?.close();
3001
+ for (const each of extensions)
3002
+ await each.close?.().catch(() => {});
2459
3003
  await mcp?.close().catch(() => {});
2460
- await harness?.close(context2).catch(() => {});
3004
+ await harness?.close(context3).catch(() => {});
2461
3005
  await release().catch(() => {});
2462
3006
  throw error;
2463
3007
  }
2464
3008
  }
2465
3009
 
2466
- // apps/host/src/main.ts
2467
- import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
3010
+ // apps/host/src/model-auth.ts
3011
+ import { existsSync as existsSync2, watch as watch2 } from "node:fs";
3012
+ import { basename as basename3, dirname as dirname3 } from "node:path";
2468
3013
 
2469
3014
  // apps/host/src/pi-setup.ts
2470
3015
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -2532,7 +3077,7 @@ function configureHarnessHttp(settingsManager) {
2532
3077
  applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
2533
3078
  configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
2534
3079
  }
2535
- function createHarnessSettings(settingsManager) {
3080
+ function createHarnessSettings(settingsManager, modes) {
2536
3081
  return {
2537
3082
  get stream() {
2538
3083
  const provider = settingsManager.getProviderRetrySettings();
@@ -2550,10 +3095,10 @@ function createHarnessSettings(settingsManager) {
2550
3095
  return settingsManager.getRetrySettings();
2551
3096
  },
2552
3097
  get steeringMode() {
2553
- return settingsManager.getSteeringMode();
3098
+ return modes.value.steeringMode;
2554
3099
  },
2555
3100
  get followUpMode() {
2556
- return settingsManager.getFollowUpMode();
3101
+ return modes.value.followUpMode;
2557
3102
  }
2558
3103
  };
2559
3104
  }
@@ -2573,11 +3118,81 @@ function modelSummaries(modelRuntime) {
2573
3118
  modelId: model.id,
2574
3119
  name: model.name,
2575
3120
  contextWindow: model.contextWindow,
2576
- thinkingLevels: getSupportedThinkingLevels2(model)
3121
+ thinkingLevels: getSupportedThinkingLevels2(model),
3122
+ images: model.input.includes("image")
2577
3123
  }));
2578
3124
  }
2579
3125
 
3126
+ // apps/host/src/model-auth.ts
3127
+ 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);
3128
+ var sameLogin = (a, b) => a.providerId === b.providerId && a.name === b.name && a.kind === b.kind && a.subscription === b.subscription;
3129
+ async function storedLogins(modelRuntime) {
3130
+ const credentials = await modelRuntime.listCredentials();
3131
+ const logins = credentials.map((credential) => ({
3132
+ providerId: credential.providerId,
3133
+ name: modelRuntime.getProvider(credential.providerId)?.name ?? credential.providerId,
3134
+ kind: credential.type,
3135
+ subscription: modelRuntime.isUsingSubscription(credential.providerId)
3136
+ }));
3137
+ logins.sort((a, b) => a.providerId.localeCompare(b.providerId));
3138
+ return { logins };
3139
+ }
3140
+ async function watchModelAuth(modelRuntime, options) {
3141
+ const debounceMs = options.debounceMs ?? 150;
3142
+ const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel2));
3143
+ const auth = new Published(await storedLogins(modelRuntime), (a, b) => sameList(a.logins, b.logins, sameLogin));
3144
+ const fileName = basename3(options.authPath);
3145
+ let watcher;
3146
+ let timer;
3147
+ let reported = false;
3148
+ let closed = false;
3149
+ const refresh = async () => {
3150
+ try {
3151
+ await modelRuntime.getAvailable();
3152
+ models.set(modelSummaries(modelRuntime));
3153
+ auth.set(await storedLogins(modelRuntime));
3154
+ reported = false;
3155
+ } catch (error) {
3156
+ if (!reported)
3157
+ console.error(`model-auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
3158
+ reported = true;
3159
+ }
3160
+ };
3161
+ const directory = dirname3(options.authPath);
3162
+ if (existsSync2(directory)) {
3163
+ watcher = watch2(directory, (_event, changed) => {
3164
+ if (closed || changed !== null && changed !== fileName)
3165
+ return;
3166
+ clearTimeout(timer);
3167
+ timer = setTimeout(() => void refresh(), debounceMs);
3168
+ });
3169
+ watcher.on("error", (error) => {
3170
+ if (!reported)
3171
+ console.error(`model-auth watch failed: ${error.message}`);
3172
+ reported = true;
3173
+ });
3174
+ }
3175
+ return {
3176
+ models,
3177
+ auth,
3178
+ async logout(providerId) {
3179
+ if (!auth.value.logins.some((login) => login.providerId === providerId)) {
3180
+ throw new Error(`No stored credential for provider "${providerId}"`);
3181
+ }
3182
+ await modelRuntime.logout(providerId);
3183
+ models.set(modelSummaries(modelRuntime));
3184
+ auth.set(await storedLogins(modelRuntime));
3185
+ },
3186
+ close() {
3187
+ closed = true;
3188
+ clearTimeout(timer);
3189
+ watcher?.close();
3190
+ }
3191
+ };
3192
+ }
3193
+
2580
3194
  // apps/host/src/main.ts
3195
+ import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
2581
3196
  async function main(argv) {
2582
3197
  const { values } = parseArgs({
2583
3198
  args: [...argv],
@@ -2595,28 +3210,27 @@ async function main(argv) {
2595
3210
  }
2596
3211
  });
2597
3212
  const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
2598
- const extensionsFor = (models, summaries) => (host2) => {
2599
- const others = [
2600
- createContext({ agentsHome: join6(homedir2(), ".agents"), checkout: checkoutInfo }),
2601
- coding,
2602
- todo,
2603
- question,
2604
- createSubagent({ models, modelSummaries: summaries, exclude: [question.extension] })
2605
- ];
2606
- return [
2607
- ...others,
2608
- createCodemode({
2609
- scriptTools: host2.scriptTools,
2610
- modelOnly: others.flatMap((extension) => extension.modelOnly ?? []),
2611
- presentations: Object.assign({}, ...others.map((extension) => extension.tools ?? {}))
2612
- })
2613
- ];
2614
- };
3213
+ const extensionsFor = (models, summaries, options2) => (host2) => [
3214
+ createContext({
3215
+ agentsHome: join8(homedir3(), ".agents"),
3216
+ checkout: (input, context4) => checkoutInfo(host2.harness(), input, context4)
3217
+ }),
3218
+ createCoding(options2),
3219
+ todo,
3220
+ question,
3221
+ createSubagent({
3222
+ models,
3223
+ modelSummaries: summaries,
3224
+ exclude: [question.extension],
3225
+ roles: () => host2.agentRoles.value.roles
3226
+ }),
3227
+ createCodemode()
3228
+ ];
2615
3229
  const common = {
2616
3230
  dataDir,
2617
3231
  projects: values.project ?? [],
2618
3232
  port: Number(values.port),
2619
- mcpConfig: join6(homedir2(), ".agents", "mcp.json"),
3233
+ mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
2620
3234
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
2621
3235
  webRoot,
2622
3236
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -2640,7 +3254,8 @@ async function main(argv) {
2640
3254
  modelId: model2.id,
2641
3255
  name: model2.name,
2642
3256
  contextWindow: model2.contextWindow,
2643
- thinkingLevels: getSupportedThinkingLevels3(model2)
3257
+ thinkingLevels: getSupportedThinkingLevels3(model2),
3258
+ images: model2.input.includes("image")
2644
3259
  }
2645
3260
  ];
2646
3261
  return {
@@ -2648,32 +3263,49 @@ async function main(argv) {
2648
3263
  models,
2649
3264
  modelSummaries: summaries,
2650
3265
  extensions: extensionsFor(models, summaries),
2651
- initialModel: { provider: model2.provider, modelId: model2.id }
3266
+ autoTitle: false,
3267
+ settingsFallback: {
3268
+ defaultModel: () => ({ provider: model2.provider, modelId: model2.id }),
3269
+ defaultThinkingLevel: () => null,
3270
+ steeringMode: () => "one-at-a-time",
3271
+ followUpMode: () => "one-at-a-time"
3272
+ }
2652
3273
  };
2653
3274
  }
2654
3275
  async function piOptions() {
2655
3276
  const modelRuntime = await ModelRuntime.create();
3277
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
2656
3278
  const settingsManager = SettingsManager.create(process.cwd());
2657
3279
  configureHarnessHttp(settingsManager);
2658
- const initialModel = defaultModel(settingsManager, modelRuntime);
2659
- const summaries = () => modelSummaries(modelRuntime);
3280
+ const summaries = () => modelAuth.models.value;
3281
+ const images = await createNodePhotonImages();
2660
3282
  return {
2661
3283
  ...common,
2662
3284
  models: modelRuntime,
2663
3285
  modelSummaries: summaries,
2664
- extensions: extensionsFor(modelRuntime, summaries),
2665
- settings: createHarnessSettings(settingsManager),
2666
- ...initialModel === undefined ? {} : { initialModel }
3286
+ modelAuth,
3287
+ extensions: extensionsFor(modelRuntime, summaries, { images }),
3288
+ settings: (store) => createHarnessSettings(settingsManager, store),
3289
+ settingsFallback: {
3290
+ defaultModel: () => {
3291
+ const model2 = defaultModel(settingsManager, modelRuntime);
3292
+ return model2 === undefined ? null : { provider: model2.provider, modelId: model2.modelId };
3293
+ },
3294
+ defaultThinkingLevel: () => settingsManager.getDefaultThinkingLevel() ?? null,
3295
+ steeringMode: () => settingsManager.getSteeringMode(),
3296
+ followUpMode: () => settingsManager.getFollowUpMode()
3297
+ }
2667
3298
  };
2668
3299
  }
2669
3300
  const options = values.faux !== undefined ? fauxOptions(() => () => fauxAssistantMessage(values.faux)) : await piOptions();
2670
3301
  const host = await openHost(options);
2671
- const model = options.initialModel === undefined ? null : `${options.initialModel.provider}/${options.initialModel.modelId}`;
3302
+ const { defaultModel: effectiveModel } = host.settings.value;
3303
+ const model = effectiveModel === null ? null : `${effectiveModel.provider}/${effectiveModel.modelId}`;
2672
3304
  const web = packagedVersion === undefined ? "http://127.0.0.1:5199/" : new URL(host.url.replace(/^ws/, "http")).href;
2673
3305
  console.log(JSON.stringify({
2674
3306
  event: "ready",
2675
3307
  url: host.url,
2676
- tokenFile: join6(dataDir, "token"),
3308
+ tokenFile: join8(dataDir, "token"),
2677
3309
  dataDir,
2678
3310
  model,
2679
3311
  web,
@@ -2689,6 +3321,15 @@ async function main(argv) {
2689
3321
  };
2690
3322
  process.on("SIGINT", stop);
2691
3323
  process.on("SIGTERM", stop);
3324
+ host.closed.then((end) => {
3325
+ if (end.reason !== "failed" || stopping)
3326
+ return;
3327
+ stopping = true;
3328
+ const error = end.error instanceof Error ? end.error.message : String(end.error);
3329
+ process.stderr.write(`${JSON.stringify({ event: "fatal", error })}
3330
+ `);
3331
+ host.close().finally(() => process.exit(1));
3332
+ });
2692
3333
  }
2693
3334
  if (false)
2694
3335
  ;