pinomad 0.2.0 → 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,13 +5,17 @@ import {
5
5
  import {
6
6
  packagedVersion,
7
7
  webRoot
8
- } from "./chunk-rkfdsacq.js";
8
+ } from "./chunk-3tf23vjq.js";
9
9
  import {
10
+ HOST_SETTING_KEYS,
10
11
  INVALID_FRAME_CLOSE_CODE,
11
12
  PROTOCOL,
13
+ THINKING_LEVELS,
12
14
  UNAUTHORIZED_CLOSE_CODE,
15
+ isQueueMode,
16
+ isThinkingLevel,
13
17
  parseClientFrame
14
- } from "./chunk-h35fermn.js";
18
+ } from "./chunk-c155p0t0.js";
15
19
  import {
16
20
  DevicesDoc,
17
21
  ensureDevices,
@@ -41,12 +45,13 @@ import {
41
45
  import"./chunk-df32avw6.js";
42
46
 
43
47
  // apps/host/src/main.ts
44
- import { homedir as homedir2 } from "node:os";
45
- 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";
46
50
  import { parseArgs } from "node:util";
47
51
  import { createModels } from "@earendil-works/pi-ai/models";
48
52
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
49
- 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";
50
55
 
51
56
  // apps/host/src/extensions/question.ts
52
57
  import {
@@ -93,6 +98,7 @@ var QuestionRequestSchema = Type.Object({
93
98
  var QuestionSchema = Type.Object({ requests: Type.Array(QuestionRequestSchema) });
94
99
  var DiffSchema = Type.Object({ patch: Type.String() });
95
100
  var CodemodeCallSchema = Type.Object({
101
+ id: Type.Optional(Type.Number()),
96
102
  name: Type.String(),
97
103
  args: Type.String(),
98
104
  status: Type.Union([Type.Literal("running"), Type.Literal("ok"), Type.Literal("error"), Type.Literal("cancelled")]),
@@ -100,7 +106,7 @@ var CodemodeCallSchema = Type.Object({
100
106
  error: Type.Optional(Type.String()),
101
107
  details: Type.Optional(Type.Unknown())
102
108
  });
103
- 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) });
104
110
 
105
111
  // apps/host/src/extensions/question.ts
106
112
  var QuestionDoc = defineDoc({
@@ -169,10 +175,11 @@ function result(questions, resolution) {
169
175
  } else {
170
176
  text = "Question cancelled.";
171
177
  }
172
- return { content: [{ type: "text", text }], details: { resolution } };
178
+ return { output: [{ type: "text", text }], details: { resolution } };
173
179
  }
174
180
  var askUserQuestion = defineTool({
175
181
  name: "ask_user_question",
182
+ callers: ["model"],
176
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.",
177
184
  parameters: Type2.Object({
178
185
  questions: Type2.Array(QuestionItemSchema, { minItems: 1, maxItems: 4 })
@@ -199,13 +206,12 @@ var askUserQuestion = defineTool({
199
206
  });
200
207
  var question = {
201
208
  extension: defineExtension({ name: "question", tools: [askUserQuestion], tasks: [QuestionWait] }),
202
- modelOnly: ["ask_user_question"],
203
209
  docs: [{ token: QuestionDoc, presentation: "pinomad.question" }]
204
210
  };
205
211
 
206
212
  // apps/host/src/extensions/coding.ts
207
213
  import { wrapTool } from "@earendil-works/pi-durable";
208
- import { CodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
214
+ import { createCodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
209
215
  import { createPatch } from "diff";
210
216
  var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
211
217
  ...tool,
@@ -221,403 +227,32 @@ var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
221
227
  return { ...result2, details: { patch: createPatch(args.path, oldContent, args.content) } };
222
228
  }
223
229
  }));
224
- var coding = {
225
- extension: { ...CodingTools, wraps: [writeWithDiff] },
226
- tools: { edit: "pinomad.diff", write: "pinomad.diff" }
227
- };
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();
228
237
 
229
238
  // apps/host/src/extensions/codemode.ts
230
- import { withAbortSignal } from "@earendil-works/chord/context";
231
- import {
232
- CodemodeSandbox,
233
- CodemodeSourceError,
234
- loadQuickJSWasm,
235
- parseCodemodeSource,
236
- renderToolSignature
237
- } from "@earendil-works/pi-codemode";
238
- import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
239
- import {
240
- defineExtension as defineExtension2,
241
- defineTool as defineTool2,
242
- section
243
- } from "@earendil-works/pi-durable";
244
- import { Type as Type3 } from "typebox";
239
+ import { CodemodeSandbox, loadQuickJSWasm } from "@earendil-works/pi-codemode";
240
+ import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
245
241
  var wasm = loadQuickJSWasm();
246
- var OUTPUT_CAP = 1024 * 1024;
247
- var OUTPUT_HALF = OUTPUT_CAP / 2;
248
-
249
- class NestedOutput {
250
- #head = "";
251
- #tail = "";
252
- #dropped = 0;
253
- #decoder = new TextDecoder;
254
- push(chunk) {
255
- const text = typeof chunk === "string" ? chunk : this.#decoder.decode(chunk, { stream: true });
256
- if (this.#dropped === 0 && this.#head.length + text.length <= OUTPUT_CAP) {
257
- this.#head += text;
258
- return;
259
- }
260
- if (this.#dropped === 0) {
261
- const rest = this.#head.slice(OUTPUT_HALF) + text;
262
- this.#head = this.#head.slice(0, OUTPUT_HALF);
263
- this.#dropped = Math.max(0, rest.length - OUTPUT_HALF);
264
- this.#tail = rest.slice(-OUTPUT_HALF);
265
- return;
266
- }
267
- const merged = this.#tail + text;
268
- this.#dropped += Math.max(0, merged.length - OUTPUT_HALF);
269
- this.#tail = merged.slice(-OUTPUT_HALF);
270
- }
271
- text() {
272
- const tail = this.#tail + this.#decoder.decode();
273
- return this.#dropped === 0 ? this.#head + tail : `${this.#head}
274
- [... ${this.#dropped} characters omitted ...]
275
- ${tail}`;
276
- }
277
- }
278
- var summarizeArgs = (args) => {
279
- let text;
280
- try {
281
- text = JSON.stringify(args) ?? "null";
282
- } catch {
283
- text = String(args);
284
- }
285
- return text.length <= 200 ? text : `${text.slice(0, 199)}…`;
286
- };
287
- var errorText = (error) => error instanceof Error ? error.message : String(error);
288
- var textOf = (content) => content.flatMap((block) => block.type === "text" ? [block.text] : []).join("");
289
- var TOKENIZE = /[^a-z0-9]+/;
290
- var tokens = (text) => text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(TOKENIZE).filter(Boolean);
291
- function search(callables, query, limit, namespace) {
292
- const docs = [...callables].filter((tool) => namespace === undefined || tool.namespace?.name === namespace).map((tool) => {
293
- const text = [tool.name, tool.description, tool.namespace?.name, tool.namespace?.description].filter(Boolean).join(" ");
294
- return { tool, terms: tokens(text) };
295
- });
296
- const avgLength = docs.length === 0 ? 1 : docs.reduce((sum, doc) => sum + doc.terms.length, 0) / docs.length;
297
- const frequency = new Map;
298
- for (const doc of docs)
299
- for (const term of new Set(doc.terms))
300
- frequency.set(term, (frequency.get(term) ?? 0) + 1);
301
- const scored = docs.map((doc) => {
302
- const counts = new Map;
303
- for (const term of doc.terms)
304
- counts.set(term, (counts.get(term) ?? 0) + 1);
305
- let score = 0;
306
- for (const term of tokens(query)) {
307
- const df = frequency.get(term);
308
- if (df === undefined)
309
- continue;
310
- const idf = Math.log(1 + (docs.length - df + 0.5) / (df + 0.5));
311
- const tf = counts.get(term) ?? 0;
312
- score += idf * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * doc.terms.length / avgLength));
313
- }
314
- return { tool: doc.tool, score };
315
- });
316
- return scored.filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, limit).map((entry) => ({
317
- name: entry.tool.name,
318
- description: entry.tool.description.length <= 300 ? entry.tool.description : `${entry.tool.description.slice(0, 299)}…`
319
- }));
320
- }
321
- function createCodemode(options) {
322
- const excluded = new Set([...options.modelOnly, "codemode"]);
323
- const modelOnlyList = [...excluded].join(", ");
324
- const tool = defineTool2({
325
- name: "codemode",
326
- description: DESCRIPTION.replace("{MODEL_ONLY}", modelOnlyList),
327
- parameters: Type3.Object({
328
- code: Type3.String({ minLength: 1, description: "Raw JavaScript source; see the tool description." })
329
- }),
330
- execute: async (args, api, context) => {
331
- let parsed;
332
- try {
333
- parsed = parseCodemodeSource(args.code);
334
- } catch (error) {
335
- if (error instanceof CodemodeSourceError) {
336
- return { content: [{ type: "text", text: `Script failed: ${error.message}` }], isError: true };
337
- }
338
- throw error;
339
- }
340
- const agent = await api.agent(context);
341
- const callables = new Map;
342
- for (const offered of agent.tools) {
343
- if (!excluded.has(offered.name))
344
- callables.set(offered.name, { kind: "agent", tool: offered });
345
- }
346
- for (const scriptTool of options.scriptTools())
347
- callables.set(scriptTool.name, { kind: "script", tool: scriptTool });
348
- const calls = [];
349
- let detailsBudget = 512 * 1024;
350
- const snapshot = () => ({
351
- code: parsed.code,
352
- calls: calls.map((call) => ({ ...call }))
353
- });
354
- const publish = () => {
355
- api.details(snapshot(), context).catch(() => {});
356
- };
357
- const executeNested = async (name, callArgs, signal) => {
358
- const record = { name, args: summarizeArgs(callArgs), status: "running" };
359
- calls.push(record);
360
- publish();
361
- const started2 = Date.now();
362
- try {
363
- const callable = callables.get(name);
364
- if (callable === undefined)
365
- throw new Error(`Unknown tool "${name}"`);
366
- const value = callable.kind === "script" ? await callable.tool.call(callArgs, signal) : await runAgentTool(callable.tool, callArgs, signal, record, calls.length - 1);
367
- record.status = signal.aborted ? "cancelled" : "ok";
368
- return value;
369
- } catch (error) {
370
- record.status = signal.aborted ? "cancelled" : "error";
371
- record.error = errorText(error).slice(0, 500);
372
- throw error;
373
- } finally {
374
- record.durationMs = Date.now() - started2;
375
- publish();
376
- }
377
- };
378
- const runAgentTool = async (offered, callArgs, signal, record, index) => {
379
- const prepared = offered.prepareArguments === undefined ? callArgs : offered.prepareArguments(callArgs);
380
- const validated = validateToolArguments(offered, {
381
- type: "toolCall",
382
- id: `${api.callId}/${index}`,
383
- name: offered.name,
384
- arguments: prepared
385
- });
386
- const output = new NestedOutput;
387
- const diagnostics = [];
388
- let reported;
389
- const derived = {
390
- ...api,
391
- callId: `${api.callId}/${index}`,
392
- output: (chunk) => output.push(chunk),
393
- outputWindow: undefined,
394
- diagnostic: (diagnostic) => diagnostics.push(`[${diagnostic.severity}] ${diagnostic.message}`),
395
- details: async (value) => {
396
- reported = value;
397
- }
398
- };
399
- let result2;
400
- try {
401
- result2 = await offered.execute(validated, derived, withAbortSignal(signal, context));
402
- } catch (error) {
403
- if (signal.aborted)
404
- throw error;
405
- throw new Error(`${[output.text(), errorText(error)].filter(Boolean).join(`
406
- `)}`.trim());
407
- }
408
- const captured = result2.details ?? reported;
409
- if (captured !== undefined && options.presentations[offered.name] !== undefined) {
410
- try {
411
- const size = JSON.stringify(captured).length;
412
- if (size <= 64 * 1024 && detailsBudget >= size) {
413
- detailsBudget -= size;
414
- record.details = captured;
415
- }
416
- } catch {}
417
- }
418
- const collected = [output.text(), ...diagnostics].filter(Boolean).join(`
419
- `);
420
- const content = result2.content ?? [{ type: "text", text: collected }];
421
- if (result2.isError === true)
422
- throw new Error(textOf(content) || collected);
423
- const texts = content.filter((block) => block.type === "text");
424
- return texts.length === content.length ? texts.map((block) => block.text).join("") : content;
425
- };
426
- const globals = [
427
- {
428
- name: "searchTools",
429
- spread: true,
430
- description: "Search callable tools by relevance.",
431
- execute: (callArgs) => {
432
- const [query, opts] = callArgs;
433
- 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);
434
- }
435
- },
436
- {
437
- name: "describeTool",
438
- description: "The declaration of one callable tool, or undefined.",
439
- execute: (callArgs) => {
440
- const callable = callables.get(String(callArgs));
441
- if (callable === undefined)
442
- return;
443
- return {
444
- name: callable.tool.name,
445
- description: callable.tool.description,
446
- 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 })
447
- };
448
- }
449
- },
450
- {
451
- name: "describeNamespace",
452
- description: "The tools of one script-only namespace, or undefined.",
453
- execute: (callArgs) => {
454
- const wanted = String(callArgs);
455
- const members = options.scriptTools().filter((tool2) => tool2.namespace?.name === wanted);
456
- if (members.length === 0)
457
- return;
458
- const first = members[0].namespace;
459
- return {
460
- name: wanted,
461
- ...first.description === undefined ? {} : { description: first.description },
462
- ...first.instructions === undefined ? {} : { instructions: first.instructions },
463
- tools: members.map((tool2) => ({ name: tool2.name, description: tool2.description }))
464
- };
465
- }
466
- }
467
- ];
468
- const sandbox = new CodemodeSandbox({
469
- tools: [...callables.values()].map((callable) => ({
470
- name: callable.tool.name,
471
- ...callable.tool.description === undefined ? {} : { description: callable.tool.description },
472
- inputSchema: callable.kind === "script" ? callable.tool.inputSchema : callable.tool.parameters,
473
- ...callable.kind === "script" && callable.tool.outputSchema !== undefined ? { outputSchema: callable.tool.outputSchema } : {},
474
- execute: (callArgs, { signal }) => executeNested(callable.tool.name, callArgs, signal)
475
- })),
476
- globals,
477
- memoryLimitBytes: 256 * 1024 * 1024,
478
- timeoutMs: parsed.options.timeoutMs ?? Infinity,
479
- wasm
480
- });
481
- const started = Date.now();
482
- try {
483
- const result2 = await sandbox.execute(parsed.code, { signal: context.abortSignal });
484
- context.abortSignal?.throwIfAborted();
485
- return {
486
- content: resultContent(result2, started, parsed.options.maxOutputTokens),
487
- isError: !result2.ok,
488
- details: snapshot()
489
- };
490
- } finally {
491
- await sandbox.close();
492
- }
493
- }
494
- });
242
+ function createCodemode() {
243
+ const sandbox = new CodemodeSandbox({ wasm, memoryLimitBytes: 256 * 1024 * 1024 });
244
+ const namespaced = (namespaces) => createCodemodeExtension({ sandbox, namespaces });
495
245
  return {
496
- extension: defineExtension2({
497
- name: "codemode",
498
- tools: [tool],
499
- sections: [
500
- section("codemode", (input) => {
501
- const offered = new Set(input.agent.tools.map((each) => each.name));
502
- const namespaces = new Map;
503
- for (const tool2 of options.scriptTools()) {
504
- if (tool2.namespace === undefined)
505
- continue;
506
- const entry = namespaces.get(tool2.namespace.name) ?? { scriptOnly: 0 };
507
- namespaces.set(tool2.namespace.name, {
508
- description: entry.description ?? tool2.namespace.description,
509
- scriptOnly: entry.scriptOnly + (offered.has(tool2.name) ? 0 : 1)
510
- });
511
- }
512
- const scriptOnly = [...namespaces.entries()].filter(([, entry]) => entry.scriptOnly > 0).sort(([a], [b]) => a.localeCompare(b));
513
- let text = `Every tool declared to you except ${modelOnlyList} can also be called from codemode scripts as tools.<name>(args), with the same arguments.`;
514
- if (scriptOnly.length > 0) {
515
- const lines = scriptOnly.map(([name, entry]) => {
516
- const firstLine = entry.description?.split(`
517
- `)[0];
518
- const clipped = firstLine === undefined ? "" : `: ${firstLine.slice(0, 200)}`;
519
- return `- ${name} (${entry.scriptOnly} tools)${clipped}`;
520
- });
521
- text += `
522
-
523
- These namespaces are reachable only from codemode scripts; find their tools with describeNamespace(name) or searchTools(query):
524
- ${lines.join(`
525
- `)}`;
526
- }
527
- return text;
528
- })
529
- ]
530
- }),
246
+ extension: namespaced([]),
531
247
  tools: { codemode: "pinomad.codemode" },
532
- modelOnly: ["codemode"]
248
+ namespaced,
249
+ close: () => sandbox.close()
533
250
  };
534
251
  }
535
- 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.
536
-
537
- \`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.
538
-
539
- Globals:
540
- - 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.
541
- - text(value): add to the output; strings as is, other values as JSON. console.log(...) and a top-level \`return value\` do the same.
542
- - image(block): add an image from an image block { type: "image", data, mimeType } or a base64 data: URL.
543
- - exit(): end the script successfully.
544
- - ALL_TOOLS: every callable tool as { name, description }.
545
- - searchTools(query, { limit?, namespace? }): resolves to the callable tools ranked by relevance (default limit 8), as { name, description }[].
546
- - describeTool(name): resolves to { name, description, declaration } with the tool's TypeScript declaration, or undefined.
547
- - describeNamespace(name): resolves to { name, description?, instructions?, tools } for a namespace such as an MCP server, or undefined.
548
- These three return promises: await them.
549
-
550
- 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.
551
-
552
- Call describeTool before using a tool whose arguments you have not seen. Scripts cannot call {MODEL_ONLY}.`;
553
- function resultContent(result2, started, maxOutputTokens) {
554
- const seconds = ((Date.now() - started) / 1000).toFixed(1);
555
- const items = [
556
- { type: "text", text: result2.ok ? `Script completed in ${seconds}s` : `Script failed in ${seconds}s` }
557
- ];
558
- for (const item of result2.output) {
559
- items.push(item.type === "text" ? { type: "text", text: item.text } : { type: "image", data: item.data, mimeType: item.mimeType });
560
- }
561
- if (result2.ok && result2.value !== undefined) {
562
- items.push({ type: "text", text: typeof result2.value === "string" ? result2.value : JSON.stringify(result2.value) });
563
- }
564
- if (!result2.ok) {
565
- const error = result2.error;
566
- items.push({
567
- type: "text",
568
- text: error.kind === "timeout" ? "Script error: timed out" : error.kind === "aborted" ? "Script error: aborted" : `Script error: ${error.name ?? "Error"}: ${error.message}`
569
- });
570
- }
571
- return truncateText(mergeText(items), (maxOutputTokens ?? 1e4) * 4);
572
- }
573
- function mergeText(items) {
574
- const merged = [];
575
- for (const item of items) {
576
- const last = merged.at(-1);
577
- if (item.type === "text" && last?.type === "text")
578
- merged[merged.length - 1] = { type: "text", text: `${last.text}
579
- ${item.text}` };
580
- else
581
- merged.push(item);
582
- }
583
- return merged;
584
- }
585
- function truncateText(items, budget) {
586
- const total = items.reduce((sum, item) => sum + (item.type === "text" ? item.text.length : 0), 0);
587
- if (total <= budget)
588
- return items;
589
- const headBudget = Math.floor(budget / 2);
590
- const tailStart = total - (budget - headBudget);
591
- const omitted = total - headBudget - (total - tailStart);
592
- const out = [];
593
- let offset = 0;
594
- let marked = false;
595
- for (const item of items) {
596
- if (item.type !== "text") {
597
- out.push(item);
598
- continue;
599
- }
600
- const end = offset + item.text.length;
601
- const head = item.text.slice(0, Math.max(headBudget - offset, 0));
602
- const tail = end > tailStart ? item.text.slice(Math.max(tailStart - offset, 0)) : "";
603
- if (head !== "")
604
- out.push({ type: "text", text: head });
605
- if (!marked && end >= headBudget) {
606
- out.push({ type: "text", text: `
607
- [... ${omitted} characters omitted ...]
608
- ` });
609
- marked = true;
610
- }
611
- if (tail !== "")
612
- out.push({ type: "text", text: tail });
613
- offset = end;
614
- }
615
- return mergeText(out);
616
- }
617
252
 
618
253
  // apps/host/src/extensions/context.ts
619
254
  import { dirname, join, sep } from "node:path";
620
- import { defineExtension as defineExtension3, section as section2 } from "@earendil-works/pi-durable";
255
+ import { defineExtension as defineExtension2, section } from "@earendil-works/pi-durable";
621
256
  import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
622
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.";
623
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.";
@@ -625,11 +260,11 @@ function createContext(options) {
625
260
  const { agentsHome } = options;
626
261
  const resolveCheckout = (input, context) => options.checkout?.(input, context);
627
262
  return {
628
- extension: defineExtension3({
263
+ extension: defineExtension2({
629
264
  name: "context",
630
265
  sections: [
631
- section2("preamble", () => PREAMBLE, { tag: false }),
632
- section2("environment", async (input, context) => {
266
+ section("preamble", () => PREAMBLE, { tag: false }),
267
+ section("environment", async (input, context) => {
633
268
  const cwd = input.agent.cwd;
634
269
  if (cwd === undefined)
635
270
  return;
@@ -649,7 +284,7 @@ function createContext(options) {
649
284
  ].join(`
650
285
  `);
651
286
  }),
652
- section2("project_context", async (input, context) => {
287
+ section("project_context", async (input, context) => {
653
288
  const cwd = input.agent.cwd;
654
289
  if (cwd === undefined)
655
290
  return;
@@ -675,7 +310,7 @@ ${file.content}`).join(`
675
310
 
676
311
  `);
677
312
  }),
678
- section2("skills", (input) => {
313
+ section("skills", (input) => {
679
314
  const cwd = input.agent.cwd;
680
315
  if (cwd === undefined)
681
316
  return;
@@ -699,10 +334,11 @@ import {
699
334
  AgentDoc,
700
335
  AssistantEntry,
701
336
  configure,
702
- defineExtension as defineExtension4,
703
- defineTool as defineTool3
337
+ defineExtension as defineExtension3,
338
+ defineTool as defineTool2,
339
+ section as section2
704
340
  } from "@earendil-works/pi-durable";
705
- import { Type as Type4 } from "typebox";
341
+ import { Type as Type3 } from "typebox";
706
342
 
707
343
  // apps/host/src/subagent-doc.ts
708
344
  import { defineDoc as defineDoc2 } from "@earendil-works/pi-durable";
@@ -717,20 +353,32 @@ var SubagentDoc = defineDoc2({
717
353
 
718
354
  // apps/host/src/extensions/subagent.ts
719
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.";
720
- var THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
356
+ var THINKING_LEVELS2 = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
721
357
  function createSubagent(options) {
722
358
  const available = () => options.modelSummaries().map((model) => `${model.provider}/${model.modelId}`).join(", ");
723
- 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({
724
368
  name: "subagent",
725
- 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()}.`,
726
- parameters: Type4.Object({
727
- 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({
728
373
  minLength: 1,
729
374
  description: "Complete, self-contained instructions. The subagent sees none of this conversation."
730
375
  }),
731
- description: Type4.Optional(Type4.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
732
- model: Type4.Optional(Type4.String({ description: "Optional `provider/modelId` to use instead of yours." })),
733
- 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." }))
734
382
  }),
735
383
  replay: "safe",
736
384
  execute: async (args, api, context) => {
@@ -744,31 +392,43 @@ function createSubagent(options) {
744
392
  ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
745
393
  };
746
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;
747
404
  let model;
748
405
  let thinkingLevel;
749
406
  let override;
750
- if (args.model !== undefined) {
751
- const slash = args.model.indexOf("/");
752
- const provider = slash === -1 ? "" : args.model.slice(0, slash);
753
- 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);
754
411
  override = provider === "" || modelId === "" ? undefined : options.models.getModel(provider, modelId);
755
412
  const listed = options.modelSummaries().some((each) => each.provider === provider && each.modelId === modelId);
756
- if (override === undefined || !listed)
757
- 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
+ }
758
416
  model = { provider, modelId };
759
417
  }
760
- if (args.thinkingLevel !== undefined || override !== undefined) {
418
+ if (levelString !== undefined || override !== undefined) {
761
419
  const parent = await tx.doc(AgentDoc, api.conversationId);
762
420
  const effective = override ?? (parent.model === undefined ? undefined : options.models.getModel(parent.model.provider, parent.model.modelId));
763
- if (args.thinkingLevel !== undefined) {
764
- thinkingLevel = effective === undefined ? args.thinkingLevel : clampThinkingLevel(effective, args.thinkingLevel);
421
+ if (levelString !== undefined) {
422
+ thinkingLevel = effective === undefined ? levelString : clampThinkingLevel(effective, levelString);
765
423
  } else if (parent.thinkingLevel !== undefined) {
766
424
  thinkingLevel = clampThinkingLevel(override, parent.thinkingLevel);
767
425
  }
768
426
  }
769
427
  const change = {
770
428
  extensions: { remove: [extension, ...options.exclude] },
771
- instructions: INSTRUCTIONS,
429
+ instructions: role === undefined || role.prompt === "" ? INSTRUCTIONS : `${INSTRUCTIONS}
430
+
431
+ ${role.prompt}`,
772
432
  ...model === undefined ? {} : { model },
773
433
  ...thinkingLevel === undefined ? {} : { thinkingLevel }
774
434
  };
@@ -781,6 +441,8 @@ function createSubagent(options) {
781
441
  doc.startedAt = startedAt;
782
442
  if (ref !== undefined)
783
443
  doc.model = `${ref.provider}/${ref.modelId}`;
444
+ if (role !== undefined)
445
+ doc.role = role.name;
784
446
  const label = args.description?.trim();
785
447
  if (label !== undefined && label !== "")
786
448
  doc.label = label;
@@ -798,7 +460,7 @@ function createSubagent(options) {
798
460
  const message = answer?.model?.[0];
799
461
  const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
800
462
  const result2 = {
801
- content: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
463
+ output: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
802
464
  details: {
803
465
  conversationId: child.id,
804
466
  ...child.model === undefined ? {} : { model: child.model },
@@ -808,13 +470,28 @@ function createSubagent(options) {
808
470
  return result2;
809
471
  }
810
472
  });
811
- const extension = defineExtension4({ name: "subagent", tools: [tool] });
812
- 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 };
813
490
  }
814
491
 
815
492
  // apps/host/src/extensions/todo.ts
816
- import { defineDoc as defineDoc3, defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
817
- import { Type as Type5 } from "typebox";
493
+ import { defineDoc as defineDoc3, defineExtension as defineExtension4, defineTool as defineTool3 } from "@earendil-works/pi-durable";
494
+ import { Type as Type4 } from "typebox";
818
495
  var TodoDoc = defineDoc3({
819
496
  kind: "todo.list",
820
497
  version: 1,
@@ -823,33 +500,30 @@ var TodoDoc = defineDoc3({
823
500
  fork: "asOf",
824
501
  initial: () => ({ items: [] })
825
502
  });
826
- var todoWrite = defineTool4({
503
+ var todoWrite = defineTool3({
827
504
  name: "todo_write",
828
505
  description: "Replace the todo list shown to the user with the given items.",
829
- parameters: Type5.Object({ items: Type5.Array(TodoItemSchema) }),
506
+ parameters: Type4.Object({ items: Type4.Array(TodoItemSchema) }),
830
507
  replay: "safe",
831
508
  execute: async (args, api, context) => {
832
509
  await api.commit(async (tx) => {
833
510
  const doc = await tx.doc(TodoDoc, api.conversationId);
834
511
  doc.items = args.items.map((item) => ({ ...item }));
835
512
  }, context);
836
- 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).` }] };
837
514
  }
838
515
  });
839
516
  var todo = {
840
- extension: defineExtension5({ name: "todo", tools: [todoWrite] }),
517
+ extension: defineExtension4({ name: "todo", tools: [todoWrite] }),
841
518
  docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
842
519
  };
843
520
 
844
521
  // apps/host/src/host.ts
845
- import { randomBytes as randomBytes2 } from "node:crypto";
846
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "node:fs/promises";
847
- import { basename as basename2, join as join5, sep as sep4 } from "node:path";
848
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
849
- import {
850
- createRegistry,
851
- Harness
852
- } 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";
853
527
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
854
528
 
855
529
  // apps/host/src/organization.ts
@@ -939,7 +613,7 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
939
613
  try {
940
614
  await git(dir, ["read-tree", "HEAD"], env);
941
615
  await git(dir, ["add", "-A"], env);
942
- 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);
943
617
  if (Buffer.byteLength(stdout) <= maxBytes)
944
618
  return { base, patch: stdout, truncated: false };
945
619
  const cut = stdout.slice(0, maxBytes);
@@ -951,6 +625,16 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
951
625
  }
952
626
  }
953
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
+
954
638
  // apps/host/src/organization.ts
955
639
  var IndexDoc = defineDoc4({
956
640
  kind: "pinomad.index",
@@ -1003,7 +687,7 @@ async function createConversation(harness, dataDir, input, defaults, models, con
1003
687
  if (prior !== undefined) {
1004
688
  const existing = await harness.conversation(prior.id, context);
1005
689
  if (existing !== undefined) {
1006
- 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);
1007
691
  }
1008
692
  return prior.id;
1009
693
  }
@@ -1055,14 +739,14 @@ async function createConversation(harness, dataDir, input, defaults, models, con
1055
739
  }, context);
1056
740
  if (home.kind === "chat")
1057
741
  await mkdir(join3(dataDir, "chats", String(conversation.id)), { recursive: true });
1058
- 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);
1059
743
  return conversation.id;
1060
744
  } catch (error) {
1061
745
  if (!(error instanceof DuplicateRequest))
1062
746
  throw error;
1063
747
  const existing = await harness.conversation(error.conversationId, context);
1064
748
  if (existing !== undefined) {
1065
- 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);
1066
750
  }
1067
751
  return error.conversationId;
1068
752
  }
@@ -1166,8 +850,8 @@ import {
1166
850
  StreamableHttpTransport,
1167
851
  toLlmContent
1168
852
  } from "@earendil-works/pi-mcp";
1169
- import { defineExtension as defineExtension6, defineTool as defineTool5 } from "@earendil-works/pi-durable";
1170
- 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";
1171
855
  var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1172
856
  var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1173
857
  function expand(value) {
@@ -1289,32 +973,23 @@ var callToolResultSchema = (tool) => ({
1289
973
  },
1290
974
  required: ["content"]
1291
975
  });
1292
- function scriptTool(client, timeoutMs, server, description, name, tool) {
1293
- return {
976
+ function mcpTool(client, timeoutMs, exposure, name, tool) {
977
+ return defineTool4({
1294
978
  name,
1295
979
  description: tool.description ?? tool.title ?? tool.name,
1296
- inputSchema: tool.inputSchema,
1297
- outputSchema: callToolResultSchema(tool),
1298
- namespace: {
1299
- name: server,
1300
- ...description === undefined ? {} : { description },
1301
- ...client.instructions === undefined ? {} : { instructions: client.instructions }
1302
- },
1303
- call: (args, signal) => client.callTool(tool.name, args, { signal, timeoutMs })
1304
- };
1305
- }
1306
- function asModelTool(tool) {
1307
- return defineTool5({
1308
- name: tool.name,
1309
- description: tool.description,
1310
- parameters: Type6.Unsafe({
980
+ parameters: Type5.Unsafe({
1311
981
  ...tool.inputSchema,
1312
982
  type: "object",
1313
983
  properties: tool.inputSchema.properties ?? {}
1314
984
  }),
985
+ ...exposure === "direct" ? {} : { callers: ["tools"] },
986
+ structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
1315
987
  execute: async (args, _api, context) => {
1316
- const result2 = await tool.call(args, context.abortSignal ?? NEVER_ABORT);
1317
- 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 };
1318
993
  }
1319
994
  });
1320
995
  }
@@ -1323,6 +998,7 @@ function startMcp(options) {
1323
998
  const listeners = new Set;
1324
999
  const servers = new Map;
1325
1000
  const toolsets = new Map;
1001
+ const namespaces = new Map;
1326
1002
  const exposures = new Map;
1327
1003
  const live = new Set;
1328
1004
  let errors = [];
@@ -1333,8 +1009,8 @@ function startMcp(options) {
1333
1009
  listener(value);
1334
1010
  };
1335
1011
  const install = () => {
1336
- const direct = [...toolsets.entries()].flatMap(([name, tools]) => exposures.get(name) === "direct" ? tools.map(asModelTool) : []);
1337
- options.registry.install(defineExtension6({ name: "mcp", tools: direct }));
1012
+ options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
1013
+ options.onNamespaces?.([...namespaces.values()]);
1338
1014
  };
1339
1015
  const set = (name, patch) => {
1340
1016
  servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
@@ -1351,16 +1027,23 @@ function startMcp(options) {
1351
1027
  continue;
1352
1028
  }
1353
1029
  taken.add(wire);
1354
- tools.push(scriptTool(client, timeoutMs, name, description, wire, tool));
1030
+ tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
1355
1031
  }
1356
1032
  if (closed)
1357
1033
  return;
1358
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
+ });
1359
1041
  set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
1360
1042
  install();
1361
1043
  };
1362
1044
  const fail = (name, error) => {
1363
1045
  toolsets.delete(name);
1046
+ namespaces.delete(name);
1364
1047
  set(name, { state: "failed", error, tools: 0 });
1365
1048
  install();
1366
1049
  };
@@ -1435,7 +1118,6 @@ ${stderr}`;
1435
1118
  });
1436
1119
  return {
1437
1120
  status,
1438
- scriptTools: () => [...toolsets.values()].flat(),
1439
1121
  async close() {
1440
1122
  closed = true;
1441
1123
  await Promise.all([...live].map((client) => client.close().catch(() => {})));
@@ -1443,15 +1125,340 @@ ${stderr}`;
1443
1125
  };
1444
1126
  }
1445
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
+
1446
1453
  // apps/host/src/host.ts
1447
1454
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1448
1455
  import lockfile from "proper-lockfile";
1449
1456
 
1450
1457
  // apps/host/src/gateway.ts
1451
1458
  import { createServer } from "node:http";
1452
- import { networkInterfaces } from "node:os";
1453
- import { join as join4, sep as sep3 } from "node:path";
1454
- 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";
1455
1462
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1456
1463
  import {
1457
1464
  AgentDoc as AgentDoc3,
@@ -1460,6 +1467,190 @@ import {
1460
1467
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1461
1468
  import { Value as Value2 } from "typebox/value";
1462
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
+
1463
1654
  // packages/protocol/src/tool-target.ts
1464
1655
  var TARGET_KEYS = [
1465
1656
  "path",
@@ -1536,7 +1727,7 @@ function startRelayLink(options) {
1536
1727
  }
1537
1728
  }
1538
1729
  }, pingIntervalMs);
1539
- function watch(socket) {
1730
+ function watch2(socket) {
1540
1731
  watched.add(socket);
1541
1732
  ponged.set(socket, true);
1542
1733
  socket.on("pong", () => ponged.set(socket, true));
@@ -1548,7 +1739,7 @@ function startRelayLink(options) {
1548
1739
  return;
1549
1740
  const socket = new WebSocket(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
1550
1741
  control = socket;
1551
- watch(socket);
1742
+ watch2(socket);
1552
1743
  socket.on("message", onControlMessage);
1553
1744
  socket.once("close", onControlClosed);
1554
1745
  }
@@ -1587,7 +1778,7 @@ function startRelayLink(options) {
1587
1778
  break;
1588
1779
  pendingAccepts += 1;
1589
1780
  const data2 = new WebSocket(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
1590
- watch(data2);
1781
+ watch2(data2);
1591
1782
  const release = () => {
1592
1783
  pendingAccepts -= 1;
1593
1784
  clearTimeout(ageOut);
@@ -1634,7 +1825,7 @@ function startRelayLink(options) {
1634
1825
  }
1635
1826
 
1636
1827
  // apps/host/src/gateway.ts
1637
- var context = BACKGROUND_CONTEXT;
1828
+ var context2 = BACKGROUND_CONTEXT2;
1638
1829
  var forkTails = new WeakMap;
1639
1830
  function serializedFork(harness, work) {
1640
1831
  const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
@@ -1649,6 +1840,11 @@ function serializedFork(harness, work) {
1649
1840
  var LOOPBACK_SECURE_PATH = "/secure";
1650
1841
  async function startGateway(options) {
1651
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
+ });
1652
1848
  const http = createServer((request, response) => {
1653
1849
  serveWebClient(request, response, options.webRoot).catch(() => {
1654
1850
  if (!response.headersSent)
@@ -1752,6 +1948,7 @@ async function startGateway(options) {
1752
1948
  broadcast,
1753
1949
  async close() {
1754
1950
  await relay?.close();
1951
+ detachAutoTitle?.();
1755
1952
  conversations.dispose();
1756
1953
  await Promise.all([...clients].map((client) => client.dispose()));
1757
1954
  for (const socket of server.clients)
@@ -1852,12 +2049,12 @@ function secureHandshake(socket, secure, harness, register) {
1852
2049
  return;
1853
2050
  }
1854
2051
  const deviceKey = toBase64Url(remoteStatic);
1855
- if (!await isRegistered(harness, deviceKey, context)) {
2052
+ if (!await isRegistered(harness, deviceKey, context2)) {
1856
2053
  if (hello.pair === undefined || !secure.offers.consume(hello.pair.secret)) {
1857
2054
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1858
2055
  return;
1859
2056
  }
1860
- await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context);
2057
+ await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context2);
1861
2058
  }
1862
2059
  const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
1863
2060
  return { result: result2, message, deviceKey };
@@ -1905,6 +2102,7 @@ class ConversationList {
1905
2102
  #records = new Map;
1906
2103
  #facts = new Map;
1907
2104
  #index = new Map;
2105
+ #runEnd = new Set;
1908
2106
  #list = [];
1909
2107
  #unsubscribe = () => {};
1910
2108
  #notifying = false;
@@ -1917,7 +2115,7 @@ class ConversationList {
1917
2115
  const summaries = [];
1918
2116
  let cursor;
1919
2117
  do {
1920
- const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context);
2118
+ const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context2);
1921
2119
  for (const record of page.items) {
1922
2120
  list.#records.set(record.id, record);
1923
2121
  const facts = await list.#load(harness, record);
@@ -1934,24 +2132,30 @@ class ConversationList {
1934
2132
  async#load(harness, record) {
1935
2133
  const history = await historyFacts(harness, record.id);
1936
2134
  const facts = { pending: new Set, ...history };
1937
- const live = await harness.snapshot(LiveDoc, record.id, context);
2135
+ const live = await harness.snapshot(LiveDoc, record.id, context2);
1938
2136
  facts.running = live?.run !== undefined;
1939
2137
  facts.tools = live?.tools;
1940
2138
  for (const token of this.#questionTokens) {
1941
- const value = await harness.snapshot(token, record.id, context);
2139
+ const value = await harness.snapshot(token, record.id, context2);
1942
2140
  if (Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined)) {
1943
2141
  facts.pending.add(token.definition.kind);
1944
2142
  }
1945
2143
  }
1946
2144
  if (record.owner !== undefined) {
1947
- const label = (await harness.snapshot(SubagentDoc, record.id, context))?.label;
1948
- if (typeof label === "string" && label !== "")
1949
- facts.label = label;
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;
1950
2150
  }
2151
+ const named = (await harness.snapshot(TitleDoc, record.id, context2))?.title;
2152
+ if (typeof named === "string" && named !== "")
2153
+ facts.named = named;
1951
2154
  return facts;
1952
2155
  }
1953
2156
  #observe(publication) {
1954
2157
  const touched = new Set;
2158
+ const ended = new Set;
1955
2159
  for (const change of publication.changes) {
1956
2160
  if (change.type === "conversation") {
1957
2161
  this.#records.set(change.value.id, change.value);
@@ -1981,9 +2185,12 @@ class ConversationList {
1981
2185
  const facts = this.#fact(id);
1982
2186
  if (change.record.kind === "pi.live") {
1983
2187
  const live = change.value;
2188
+ const wasRunning = facts.running === true;
1984
2189
  facts.running = live?.run !== undefined;
1985
2190
  facts.tools = live?.tools;
1986
2191
  touched.add(id);
2192
+ if (wasRunning && !facts.running && this.#records.get(id)?.owner === undefined)
2193
+ ended.add(id);
1987
2194
  } else if (this.#questionKinds.has(change.record.kind)) {
1988
2195
  const value = change.value;
1989
2196
  const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
@@ -1995,8 +2202,13 @@ class ConversationList {
1995
2202
  facts.pending = next2;
1996
2203
  touched.add(id);
1997
2204
  } else if (change.record.kind === SubagentDoc.definition.kind) {
1998
- const label = change.value?.label;
1999
- facts.label = typeof label === "string" && label !== "" ? label : undefined;
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;
2000
2212
  touched.add(id);
2001
2213
  }
2002
2214
  }
@@ -2019,6 +2231,13 @@ class ConversationList {
2019
2231
  }
2020
2232
  if (next !== undefined)
2021
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);
2022
2241
  }
2023
2242
  #fact(id) {
2024
2243
  let facts = this.#facts.get(id);
@@ -2062,31 +2281,33 @@ function summaryOf(record, facts) {
2062
2281
  kind: record.owner !== undefined ? "subagent" : record.parent !== undefined ? "fork" : "conversation",
2063
2282
  ...parent === undefined ? {} : { parent },
2064
2283
  ...record.parent === undefined ? {} : { forkedAt: String(record.parent.at) },
2065
- ...facts.title === undefined ? {} : { title: facts.title },
2284
+ ...(facts.named ?? facts.title) === undefined ? {} : { title: facts.named ?? facts.title },
2066
2285
  ...status === undefined ? {} : { status },
2067
2286
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2068
2287
  ...facts.label === undefined ? {} : { label: facts.label },
2288
+ ...facts.role === undefined ? {} : { role: facts.role },
2069
2289
  ...activity === undefined ? {} : { activity }
2070
2290
  };
2071
2291
  }
2072
2292
  function sameSummary(a, b) {
2073
- return a.id === b.id && a.kind === b.kind && a.parent === b.parent && a.forkedAt === b.forkedAt && a.title === b.title && a.status === b.status && a.updatedAt === b.updatedAt && a.label === b.label && a.activity?.tool === b.activity?.tool && a.activity?.target === b.activity?.target;
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;
2074
2294
  }
2075
2295
  function titleOf(entry) {
2076
2296
  const message = entry?.model?.[0];
2077
2297
  if (message?.role !== "user")
2078
2298
  return {};
2079
2299
  const text = typeof message.content === "string" ? message.content : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ");
2080
- return { title: text.replace(/\s+/g, " ").trim() };
2300
+ const title = text.replace(/\s+/g, " ").trim();
2301
+ return title === "" ? {} : { title };
2081
2302
  }
2082
2303
  async function historyFacts(harness, id) {
2083
- const conversation = await harness.conversation(id, context);
2304
+ const conversation = await harness.conversation(id, context2);
2084
2305
  let first;
2085
2306
  let newest;
2086
2307
  let cursor;
2087
2308
  do {
2088
- const page = await conversation.entries({}, 256, cursor, context);
2089
- 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;
2090
2311
  newest ??= page.items.find((entry) => entry.conversationId === id && (entry.kind === "pi.user" || entry.kind === "pi.assistant"));
2091
2312
  cursor = page.next;
2092
2313
  } while (cursor !== undefined);
@@ -2120,13 +2341,20 @@ class GatewayClient {
2120
2341
  #connection;
2121
2342
  #hooks;
2122
2343
  #options;
2123
- #conversations;
2344
+ #hostValues;
2124
2345
  #subscriptions = new Map;
2125
2346
  #disposed = false;
2126
2347
  constructor(connection, options, conversations, hooks) {
2127
2348
  this.#connection = connection;
2128
2349
  this.#options = options;
2129
- 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
+ };
2130
2358
  this.#hooks = hooks;
2131
2359
  this.devicePublicKey = hooks.devicePublicKey;
2132
2360
  connection.handle({
@@ -2165,7 +2393,7 @@ class GatewayClient {
2165
2393
  ...packagedVersion === undefined ? {} : { hostVersion: packagedVersion },
2166
2394
  session: options.session,
2167
2395
  models: options.modelSummaries(),
2168
- defaults: options.defaults,
2396
+ defaults: settingsDefaults(options.settings.value),
2169
2397
  docs: (options.docs ?? []).map((doc) => ({
2170
2398
  kind: doc.token.definition.kind,
2171
2399
  ...doc.presentation === undefined ? {} : { presentation: doc.presentation }
@@ -2216,36 +2444,34 @@ class GatewayClient {
2216
2444
  state.subscription = subscription;
2217
2445
  }
2218
2446
  async#open(stream, send) {
2219
- if (stream === "conversations") {
2220
- send({ type: "snapshot", stream, value: this.#conversations.value });
2221
- const unsubscribe = this.#conversations.subscribe((value) => void send({ type: "snapshot", stream, value }));
2222
- 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 })) };
2223
2451
  }
2224
2452
  if (stream === "index") {
2225
- const watch = await this.#options.harness.watchDoc(IndexDoc, context);
2226
- if (watch === undefined)
2453
+ const watch2 = await this.#options.harness.watchDoc(IndexDoc, context2);
2454
+ if (watch2 === undefined)
2227
2455
  throw new Error("Index document is missing");
2228
- return this.#forward(stream, watch, send);
2456
+ return this.#forward(stream, watch2, send);
2229
2457
  }
2230
2458
  if (stream === "devices") {
2231
- const watch = await this.#options.harness.watchDoc(DevicesDoc, context);
2232
- if (watch === undefined)
2459
+ const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context2);
2460
+ if (watch2 === undefined)
2233
2461
  throw new Error("Devices document is missing");
2234
- return this.#forward(stream, watch, send);
2462
+ return this.#forward(stream, watch2, send);
2235
2463
  }
2236
2464
  if (stream === "tasks")
2237
- return this.#forward(stream, await this.#options.harness.watchTaskGraph(context), send);
2238
- if (stream === "mcp") {
2239
- await send({ type: "snapshot", stream, value: this.#options.mcp?.value ?? null });
2240
- const mcp = this.#options.mcp;
2241
- return mcp === undefined ? { stop: () => {} } : { stop: mcp.subscribe((value) => void send({ type: "snapshot", stream, value })) };
2242
- }
2465
+ return this.#forward(stream, await this.#options.harness.watchTaskGraph(context2), send);
2243
2466
  if (stream.startsWith("doc:")) {
2244
2467
  const at = stream.lastIndexOf(":");
2245
2468
  return this.#openDoc(stream, stream.slice("doc:".length, at), Number(stream.slice(at + 1)), send);
2246
2469
  }
2247
2470
  const conversation = await this.#conversation(Number(stream.slice("conversation:".length)));
2248
- 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;
2249
2475
  }
2250
2476
  async#openDoc(stream, kind, id, send) {
2251
2477
  const doc = this.#options.docs?.find((candidate) => candidate.token.definition.kind === kind);
@@ -2268,7 +2494,7 @@ class GatewayClient {
2268
2494
  setImmediate(() => signalCreation());
2269
2495
  });
2270
2496
  try {
2271
- const existing = await harness.watchDoc(token, id, context);
2497
+ const existing = await harness.watchDoc(token, id, context2);
2272
2498
  if (existing !== undefined) {
2273
2499
  unsubscribe();
2274
2500
  return this.#forward(stream, existing, send);
@@ -2281,13 +2507,13 @@ class GatewayClient {
2281
2507
  created.then(async () => {
2282
2508
  if (stopped)
2283
2509
  return;
2284
- const watch = await harness.watchDoc(token, id, context);
2285
- if (watch === undefined)
2510
+ const watch2 = await harness.watchDoc(token, id, context2);
2511
+ if (watch2 === undefined)
2286
2512
  return;
2287
2513
  if (stopped)
2288
- await watch.stop();
2514
+ await watch2.stop();
2289
2515
  else {
2290
- inner = await this.#forward(stream, watch, send);
2516
+ inner = await this.#forward(stream, watch2, send);
2291
2517
  if (stopped)
2292
2518
  await inner.stop();
2293
2519
  }
@@ -2304,10 +2530,10 @@ class GatewayClient {
2304
2530
  }
2305
2531
  };
2306
2532
  }
2307
- async#forward(stream, watch, send) {
2308
- await send({ type: "snapshot", stream, value: watch.value });
2309
- watch.start((_value, ops) => send({ type: "ops", stream, ops }));
2310
- 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;
2311
2537
  }
2312
2538
  async#unsubscribe(stream) {
2313
2539
  const subscription = this.#subscriptions.get(stream);
@@ -2315,7 +2541,7 @@ class GatewayClient {
2315
2541
  await subscription?.subscription?.stop();
2316
2542
  }
2317
2543
  async#conversation(id) {
2318
- const conversation = await this.#options.harness.conversation(id, context);
2544
+ const conversation = await this.#options.harness.conversation(id, context2);
2319
2545
  if (conversation === undefined)
2320
2546
  throw new Error(`Conversation ${id} does not exist`);
2321
2547
  return conversation;
@@ -2331,27 +2557,31 @@ class GatewayClient {
2331
2557
  async#call(method, args) {
2332
2558
  switch (method) {
2333
2559
  case "addProject":
2334
- return addProject(this.#options.harness, args.path, context);
2560
+ return addProject(this.#options.harness, args.path, context2);
2335
2561
  case "removeProject":
2336
- await removeProject(this.#options.harness, args.path, context);
2562
+ await removeProject(this.#options.harness, args.path, context2);
2337
2563
  return null;
2338
2564
  case "createConversation": {
2339
2565
  const create = args;
2566
+ const problem = imageProblem(create.images ?? []);
2567
+ if (problem !== undefined)
2568
+ throw new Error(problem);
2340
2569
  const conversationId = await createConversation(this.#options.harness, this.#options.dataDir, {
2341
2570
  home: create.home,
2342
2571
  text: create.text,
2343
2572
  requestId: create.requestId,
2573
+ ...create.images === undefined ? {} : { images: create.images },
2344
2574
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2345
2575
  ...create.model === undefined ? {} : { model: create.model },
2346
2576
  ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2347
- }, this.#options.defaults, this.#options.models, context);
2577
+ }, settingsDefaults(this.#options.settings.value), this.#options.models, context2);
2348
2578
  return { conversationId };
2349
2579
  }
2350
2580
  case "archive": {
2351
2581
  const input = args;
2352
- await archive(this.#options.harness, input.conversationId, input.archived, context);
2582
+ await archive(this.#options.harness, input.conversationId, input.archived, context2);
2353
2583
  if (input.archived) {
2354
- const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context);
2584
+ const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context2);
2355
2585
  for (const { record, reason } of kept) {
2356
2586
  await this.send({ type: "notice", level: "warning", message: `Kept worktree ${record.path} (branch ${record.branch}): ${reason}` });
2357
2587
  }
@@ -2366,31 +2596,88 @@ class GatewayClient {
2366
2596
  }
2367
2597
  case "revokeDevice": {
2368
2598
  const { publicKey } = args;
2369
- await revokeDevice(this.#options.harness, publicKey, context);
2599
+ await revokeDevice(this.#options.harness, publicKey, context2);
2370
2600
  this.#hooks.closeDevice(publicKey);
2371
2601
  return null;
2372
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());
2373
2637
  }
2374
2638
  const conversation = await this.#conversation(args.conversationId);
2375
2639
  switch (method) {
2376
2640
  case "submit": {
2377
- const { text, whenBusy, requestId } = args;
2378
- const dismissed = await this.#dismissPendingQuestions(conversation.id, context);
2379
- const submission = await conversation.submit({ type: "input", content: text, whenBusy: dismissed ? "steer" : whenBusy, requestId }, context);
2380
- 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) => {
2381
2648
  if (settled.status === "unanswered" && settled.reason !== "aborted") {
2382
2649
  this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
2383
2650
  }
2384
2651
  }, () => {});
2385
2652
  return { submissionId: submission.id };
2386
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
+ }
2387
2674
  case "abort":
2388
- await conversation.abort(context);
2675
+ await conversation.abort(context2);
2389
2676
  return null;
2390
2677
  case "compact": {
2391
2678
  const { instructions } = args;
2392
- const taskId = await conversation.compact(instructions, context);
2393
- 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}.` }), () => {});
2394
2681
  return { taskId };
2395
2682
  }
2396
2683
  case "setModel": {
@@ -2399,7 +2686,7 @@ class GatewayClient {
2399
2686
  if (model === undefined)
2400
2687
  throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
2401
2688
  const thinking = (await this.#agent(conversation.id)).thinkingLevel ?? "off";
2402
- await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context);
2689
+ await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context2);
2403
2690
  return null;
2404
2691
  }
2405
2692
  case "setThinkingLevel": {
@@ -2411,7 +2698,7 @@ class GatewayClient {
2411
2698
  if (!getSupportedThinkingLevels(model).includes(level)) {
2412
2699
  throw new Error(`Thinking level ${level} is not supported by ${agent.model.provider}/${agent.model.modelId}`);
2413
2700
  }
2414
- await conversation.configure({ thinkingLevel: level }, context);
2701
+ await conversation.configure({ thinkingLevel: level }, context2);
2415
2702
  return null;
2416
2703
  }
2417
2704
  case "fork": {
@@ -2435,13 +2722,13 @@ class GatewayClient {
2435
2722
  }
2436
2723
  cursor = page.next;
2437
2724
  } while (cursor !== undefined);
2438
- }, context);
2439
- const agent = await conversation.agent(context);
2725
+ }, context2);
2726
+ const agent = await conversation.agent(context2);
2440
2727
  const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2441
2728
  const fork = await conversation.fork(at, {
2442
2729
  ownership: { kind: "ownerless" },
2443
2730
  ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
2444
- }, context);
2731
+ }, context2);
2445
2732
  return { conversationId: fork.id };
2446
2733
  });
2447
2734
  }
@@ -2477,16 +2764,16 @@ class GatewayClient {
2477
2764
  }
2478
2765
  request.resolution = { outcome: "answered", answers, at: Date.now() };
2479
2766
  return { first: true };
2480
- }, context);
2767
+ }, context2);
2481
2768
  }
2482
2769
  case "changes": {
2483
- return await this.#changes(conversation, context);
2770
+ return await this.#changes(conversation, context2);
2484
2771
  }
2485
2772
  }
2486
2773
  }
2487
- async#dismissPendingQuestions(conversationId, context2) {
2774
+ async#dismissPendingQuestions(conversationId, context3) {
2488
2775
  const questionDocs = (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
2489
- 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)));
2490
2777
  const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
2491
2778
  if (!pending)
2492
2779
  return false;
@@ -2504,18 +2791,18 @@ class GatewayClient {
2504
2791
  }
2505
2792
  }
2506
2793
  return dismissed;
2507
- }, context2);
2794
+ }, context3);
2508
2795
  }
2509
2796
  get #chatsPrefix() {
2510
- return join4(this.#options.dataDir, "chats") + sep3;
2797
+ return join6(this.#options.dataDir, "chats") + sep3;
2511
2798
  }
2512
- async#changes(conversation, context2) {
2513
- const cwd = (await conversation.agent(context2)).cwd;
2799
+ async#changes(conversation, context3) {
2800
+ const cwd = (await conversation.agent(context3)).cwd;
2514
2801
  if (cwd === undefined)
2515
2802
  return { available: false, reason: "No working directory" };
2516
2803
  if (cwd.startsWith(this.#chatsPrefix))
2517
2804
  return { available: false, reason: "Chat conversations have no repository" };
2518
- const index = await this.#options.harness.snapshot(IndexDoc, context2);
2805
+ const index = await this.#options.harness.snapshot(IndexDoc, context3);
2519
2806
  const record = checkoutAt(index?.checkouts, cwd);
2520
2807
  if (record !== undefined) {
2521
2808
  if (!await worktreeExists(record.path))
@@ -2528,7 +2815,7 @@ class GatewayClient {
2528
2815
  return { available: true, ...await changesOf(base.repo, base.base) };
2529
2816
  }
2530
2817
  async#agent(id) {
2531
- return await this.#options.harness.snapshot(AgentDoc3, id, context) ?? {};
2818
+ return await this.#options.harness.snapshot(AgentDoc3, id, context2) ?? {};
2532
2819
  }
2533
2820
  async dispose() {
2534
2821
  if (this.#disposed)
@@ -2542,20 +2829,20 @@ class GatewayClient {
2542
2829
  }
2543
2830
 
2544
2831
  // apps/host/src/host.ts
2545
- var context2 = BACKGROUND_CONTEXT2;
2832
+ var context3 = BACKGROUND_CONTEXT3;
2546
2833
  async function hostToken(dataDir) {
2547
- const path = join5(dataDir, "token");
2834
+ const path = join7(dataDir, "token");
2548
2835
  try {
2549
- return (await readFile2(path, "utf8")).trim();
2836
+ return (await readFile4(path, "utf8")).trim();
2550
2837
  } catch {
2551
- const token = randomBytes2(24).toString("base64url");
2552
- await writeFile(path, `${token}
2838
+ const token = randomBytes3(24).toString("base64url");
2839
+ await writeFile3(path, `${token}
2553
2840
  `, { mode: 384 });
2554
2841
  return token;
2555
2842
  }
2556
2843
  }
2557
2844
  async function openHost(options) {
2558
- await mkdir2(options.dataDir, { recursive: true });
2845
+ await mkdir4(options.dataDir, { recursive: true });
2559
2846
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
2560
2847
  const release = await lockfile.lock(options.dataDir, {
2561
2848
  realpath: false,
@@ -2566,25 +2853,43 @@ async function openHost(options) {
2566
2853
  });
2567
2854
  let harness;
2568
2855
  let mcp;
2856
+ let agentRoles;
2857
+ let extensions = [];
2569
2858
  try {
2570
2859
  const token = await hostToken(options.dataDir);
2571
2860
  const reports = [];
2572
2861
  let report = (error) => void reports.push(error);
2573
2862
  const registry = createRegistry();
2574
- const extensions = typeof options.extensions === "function" ? options.extensions({
2575
- scriptTools: () => mcp?.scriptTools() ?? [],
2863
+ agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
2864
+ extensions = typeof options.extensions === "function" ? options.extensions({
2576
2865
  harness: () => {
2577
2866
  if (harness === undefined)
2578
2867
  throw new Error("harness is not open yet");
2579
2868
  return harness;
2580
- }
2869
+ },
2870
+ agentRoles
2581
2871
  }) : options.extensions ?? [];
2582
2872
  for (const { extension } of extensions)
2583
2873
  registry.install(extension);
2584
- 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
+ });
2585
2890
  const environments = new Map;
2586
- const chatsDir = join5(options.dataDir, "chats");
2587
- 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")), {
2588
2893
  models: options.models,
2589
2894
  registry,
2590
2895
  env: async (target) => {
@@ -2593,15 +2898,15 @@ async function openHost(options) {
2593
2898
  return;
2594
2899
  const worktrees = worktreeRoot(options.dataDir);
2595
2900
  if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
2596
- const index = await target.read.snapshot(IndexDoc, context2);
2901
+ const index = await target.read.snapshot(IndexDoc, context3);
2597
2902
  const record = checkoutAt(index?.checkouts, cwd);
2598
2903
  if (record !== undefined) {
2599
2904
  await ensureWorktree(record);
2600
- await mkdir2(cwd, { recursive: true });
2905
+ await mkdir4(cwd, { recursive: true });
2601
2906
  }
2602
2907
  }
2603
2908
  if (cwd.startsWith(`${chatsDir}/`))
2604
- await mkdir2(cwd, { recursive: true });
2909
+ await mkdir4(cwd, { recursive: true });
2605
2910
  let env = environments.get(cwd);
2606
2911
  if (env === undefined) {
2607
2912
  env = new NodeExecutionEnv({ cwd });
@@ -2609,35 +2914,35 @@ async function openHost(options) {
2609
2914
  }
2610
2915
  return env;
2611
2916
  },
2612
- ...options.settings === undefined ? {} : { settings: options.settings },
2917
+ ...options.settings === undefined ? {} : {
2918
+ settings: typeof options.settings === "function" ? options.settings(hostSettings) : options.settings
2919
+ },
2613
2920
  onReport: (error) => report(error)
2614
- }, context2);
2615
- const { initialModel } = options;
2616
- await ensureIndex(harness, context2);
2617
- await ensureDevices(harness, context2);
2921
+ }, context3);
2922
+ await ensureIndex(harness, context3);
2923
+ await ensureDevices(harness, context3);
2618
2924
  for (const path of options.projects ?? [])
2619
- await addProject(harness, path, context2);
2925
+ await addProject(harness, path, context3);
2620
2926
  const hostKey = await loadHostKey(options.dataDir);
2621
2927
  const offers = pairingOffers(options.remote?.pairingTtlMs);
2622
2928
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
2623
- const defaults = initialModel === undefined ? {} : {
2624
- model: { provider: initialModel.provider, modelId: initialModel.modelId },
2625
- ...initialModel.thinkingLevel === undefined ? {} : { thinkingLevel: initialModel.thinkingLevel }
2626
- };
2627
2929
  const gateway = await startGateway({
2628
2930
  harness,
2629
2931
  models: options.models,
2630
2932
  modelSummaries: options.modelSummaries,
2631
2933
  session: { id: basename2(options.dataDir), directory: options.dataDir },
2632
2934
  dataDir: options.dataDir,
2633
- defaults,
2935
+ settings: hostSettings,
2634
2936
  token,
2635
2937
  port: options.port,
2938
+ agents: agentRoles,
2636
2939
  ...options.browserOrigins === undefined ? {} : { browserOrigins: options.browserOrigins },
2637
2940
  docs: extensions.flatMap((extension) => extension.docs ?? []),
2638
2941
  toolPresentations: Object.assign({}, ...extensions.map((extension) => extension.tools ?? {})),
2639
2942
  ...mcp === undefined ? {} : { mcp: mcp.status },
2943
+ ...options.modelAuth === undefined ? {} : { modelAuth: options.modelAuth },
2640
2944
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
2945
+ ...options.autoTitle === undefined ? {} : { autoTitle: options.autoTitle },
2641
2946
  secure: {
2642
2947
  hostKey,
2643
2948
  offers,
@@ -2668,15 +2973,21 @@ async function openHost(options) {
2668
2973
  url: gateway.url,
2669
2974
  token,
2670
2975
  harness,
2976
+ closed: opened.closed,
2977
+ settings: hostSettings,
2671
2978
  ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
2672
2979
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
2673
2980
  close() {
2674
2981
  closing ??= (async () => {
2675
2982
  try {
2676
2983
  await gateway.close();
2677
- await opened.close(context2);
2984
+ await opened.close(context3);
2678
2985
  await mcp?.close();
2679
- 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)));
2680
2991
  } finally {
2681
2992
  await release();
2682
2993
  }
@@ -2685,15 +2996,20 @@ async function openHost(options) {
2685
2996
  }
2686
2997
  };
2687
2998
  } catch (error) {
2999
+ options.modelAuth?.close();
3000
+ agentRoles?.close();
3001
+ for (const each of extensions)
3002
+ await each.close?.().catch(() => {});
2688
3003
  await mcp?.close().catch(() => {});
2689
- await harness?.close(context2).catch(() => {});
3004
+ await harness?.close(context3).catch(() => {});
2690
3005
  await release().catch(() => {});
2691
3006
  throw error;
2692
3007
  }
2693
3008
  }
2694
3009
 
2695
- // apps/host/src/main.ts
2696
- 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";
2697
3013
 
2698
3014
  // apps/host/src/pi-setup.ts
2699
3015
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -2761,7 +3077,7 @@ function configureHarnessHttp(settingsManager) {
2761
3077
  applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
2762
3078
  configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
2763
3079
  }
2764
- function createHarnessSettings(settingsManager) {
3080
+ function createHarnessSettings(settingsManager, modes) {
2765
3081
  return {
2766
3082
  get stream() {
2767
3083
  const provider = settingsManager.getProviderRetrySettings();
@@ -2779,10 +3095,10 @@ function createHarnessSettings(settingsManager) {
2779
3095
  return settingsManager.getRetrySettings();
2780
3096
  },
2781
3097
  get steeringMode() {
2782
- return settingsManager.getSteeringMode();
3098
+ return modes.value.steeringMode;
2783
3099
  },
2784
3100
  get followUpMode() {
2785
- return settingsManager.getFollowUpMode();
3101
+ return modes.value.followUpMode;
2786
3102
  }
2787
3103
  };
2788
3104
  }
@@ -2802,11 +3118,81 @@ function modelSummaries(modelRuntime) {
2802
3118
  modelId: model.id,
2803
3119
  name: model.name,
2804
3120
  contextWindow: model.contextWindow,
2805
- thinkingLevels: getSupportedThinkingLevels2(model)
3121
+ thinkingLevels: getSupportedThinkingLevels2(model),
3122
+ images: model.input.includes("image")
2806
3123
  }));
2807
3124
  }
2808
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
+
2809
3194
  // apps/host/src/main.ts
3195
+ import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
2810
3196
  async function main(argv) {
2811
3197
  const { values } = parseArgs({
2812
3198
  args: [...argv],
@@ -2824,31 +3210,27 @@ async function main(argv) {
2824
3210
  }
2825
3211
  });
2826
3212
  const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
2827
- const extensionsFor = (models, summaries) => (host2) => {
2828
- const others = [
2829
- createContext({
2830
- agentsHome: join6(homedir2(), ".agents"),
2831
- checkout: (input, context3) => checkoutInfo(host2.harness(), input, context3)
2832
- }),
2833
- coding,
2834
- todo,
2835
- question,
2836
- createSubagent({ models, modelSummaries: summaries, exclude: [question.extension] })
2837
- ];
2838
- return [
2839
- ...others,
2840
- createCodemode({
2841
- scriptTools: host2.scriptTools,
2842
- modelOnly: others.flatMap((extension) => extension.modelOnly ?? []),
2843
- presentations: Object.assign({}, ...others.map((extension) => extension.tools ?? {}))
2844
- })
2845
- ];
2846
- };
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
+ ];
2847
3229
  const common = {
2848
3230
  dataDir,
2849
3231
  projects: values.project ?? [],
2850
3232
  port: Number(values.port),
2851
- mcpConfig: join6(homedir2(), ".agents", "mcp.json"),
3233
+ mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
2852
3234
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
2853
3235
  webRoot,
2854
3236
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -2872,7 +3254,8 @@ async function main(argv) {
2872
3254
  modelId: model2.id,
2873
3255
  name: model2.name,
2874
3256
  contextWindow: model2.contextWindow,
2875
- thinkingLevels: getSupportedThinkingLevels3(model2)
3257
+ thinkingLevels: getSupportedThinkingLevels3(model2),
3258
+ images: model2.input.includes("image")
2876
3259
  }
2877
3260
  ];
2878
3261
  return {
@@ -2880,32 +3263,49 @@ async function main(argv) {
2880
3263
  models,
2881
3264
  modelSummaries: summaries,
2882
3265
  extensions: extensionsFor(models, summaries),
2883
- 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
+ }
2884
3273
  };
2885
3274
  }
2886
3275
  async function piOptions() {
2887
3276
  const modelRuntime = await ModelRuntime.create();
3277
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
2888
3278
  const settingsManager = SettingsManager.create(process.cwd());
2889
3279
  configureHarnessHttp(settingsManager);
2890
- const initialModel = defaultModel(settingsManager, modelRuntime);
2891
- const summaries = () => modelSummaries(modelRuntime);
3280
+ const summaries = () => modelAuth.models.value;
3281
+ const images = await createNodePhotonImages();
2892
3282
  return {
2893
3283
  ...common,
2894
3284
  models: modelRuntime,
2895
3285
  modelSummaries: summaries,
2896
- extensions: extensionsFor(modelRuntime, summaries),
2897
- settings: createHarnessSettings(settingsManager),
2898
- ...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
+ }
2899
3298
  };
2900
3299
  }
2901
3300
  const options = values.faux !== undefined ? fauxOptions(() => () => fauxAssistantMessage(values.faux)) : await piOptions();
2902
3301
  const host = await openHost(options);
2903
- 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}`;
2904
3304
  const web = packagedVersion === undefined ? "http://127.0.0.1:5199/" : new URL(host.url.replace(/^ws/, "http")).href;
2905
3305
  console.log(JSON.stringify({
2906
3306
  event: "ready",
2907
3307
  url: host.url,
2908
- tokenFile: join6(dataDir, "token"),
3308
+ tokenFile: join8(dataDir, "token"),
2909
3309
  dataDir,
2910
3310
  model,
2911
3311
  web,
@@ -2921,6 +3321,15 @@ async function main(argv) {
2921
3321
  };
2922
3322
  process.on("SIGINT", stop);
2923
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
+ });
2924
3333
  }
2925
3334
  if (false)
2926
3335
  ;