pinomad 0.2.0 → 0.4.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.
@@ -1,52 +1,62 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ normalizeOrigin,
4
+ readServerBinding,
5
+ readServerConfig,
6
+ resolveRelayOrigin
7
+ } from "./chunk-302y6by9.js";
2
8
  import {
3
9
  serveWebClient
4
10
  } from "./chunk-r6w5aqkw.js";
11
+ import {
12
+ relayWsBase,
13
+ startRelayLink
14
+ } from "./chunk-knaw1a9g.js";
5
15
  import {
6
16
  packagedVersion,
7
17
  webRoot
8
- } from "./chunk-rkfdsacq.js";
18
+ } from "./chunk-fgx2sh7d.js";
9
19
  import {
20
+ HOST_SETTING_KEYS,
10
21
  INVALID_FRAME_CLOSE_CODE,
11
22
  PROTOCOL,
23
+ THINKING_LEVELS,
12
24
  UNAUTHORIZED_CLOSE_CODE,
25
+ isQueueMode,
26
+ isThinkingLevel,
27
+ organize,
13
28
  parseClientFrame
14
- } from "./chunk-h35fermn.js";
29
+ } from "./chunk-myjp910s.js";
15
30
  import {
16
31
  DevicesDoc,
32
+ RevocationsDoc,
33
+ admitDevice,
17
34
  ensureDevices,
18
- isRegistered,
19
35
  loadHostKey,
20
36
  loadRelayKey,
21
- pairingOffers,
22
- registerDevice,
23
- revokeDevice
24
- } from "./chunk-fxaewke2.js";
37
+ pairingOffers
38
+ } from "./chunk-837gh8nr.js";
25
39
  import {
26
- RELAY_CLOSE,
27
40
  SECURE_PROLOGUE,
28
41
  SecureSession,
29
42
  decodeClientHello,
30
- decodeRelayToHost,
31
43
  encodeHostHello,
32
- encodeRelayFrame,
33
- relayHostId,
34
44
  respondIK,
35
- signRelayChallenge,
36
45
  toBase64Url
37
- } from "./chunk-z4x88s0q.js";
46
+ } from "./chunk-7gmkgyaz.js";
38
47
  import {
39
48
  DEFAULT_DATA_DIR
40
49
  } from "./chunk-cjfmfyjb.js";
41
50
  import"./chunk-df32avw6.js";
42
51
 
43
52
  // apps/host/src/main.ts
44
- import { homedir as homedir2 } from "node:os";
45
- import { join as join6, resolve as resolve3 } from "node:path";
53
+ import { homedir as homedir3 } from "node:os";
54
+ import { join as join8, resolve as resolve3 } from "node:path";
46
55
  import { parseArgs } from "node:util";
47
56
  import { createModels } from "@earendil-works/pi-ai/models";
48
57
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
49
- import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
58
+ import { getAgentDir, ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
59
+ import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
50
60
 
51
61
  // apps/host/src/extensions/question.ts
52
62
  import {
@@ -93,6 +103,7 @@ var QuestionRequestSchema = Type.Object({
93
103
  var QuestionSchema = Type.Object({ requests: Type.Array(QuestionRequestSchema) });
94
104
  var DiffSchema = Type.Object({ patch: Type.String() });
95
105
  var CodemodeCallSchema = Type.Object({
106
+ id: Type.Optional(Type.Number()),
96
107
  name: Type.String(),
97
108
  args: Type.String(),
98
109
  status: Type.Union([Type.Literal("running"), Type.Literal("ok"), Type.Literal("error"), Type.Literal("cancelled")]),
@@ -100,7 +111,7 @@ var CodemodeCallSchema = Type.Object({
100
111
  error: Type.Optional(Type.String()),
101
112
  details: Type.Optional(Type.Unknown())
102
113
  });
103
- var CodemodeSchema = Type.Object({ code: Type.String(), calls: Type.Array(CodemodeCallSchema) });
114
+ var CodemodeSchema = Type.Object({ code: Type.Optional(Type.String()), calls: Type.Array(CodemodeCallSchema) });
104
115
 
105
116
  // apps/host/src/extensions/question.ts
106
117
  var QuestionDoc = defineDoc({
@@ -169,10 +180,11 @@ function result(questions, resolution) {
169
180
  } else {
170
181
  text = "Question cancelled.";
171
182
  }
172
- return { content: [{ type: "text", text }], details: { resolution } };
183
+ return { output: [{ type: "text", text }], details: { resolution } };
173
184
  }
174
185
  var askUserQuestion = defineTool({
175
186
  name: "ask_user_question",
187
+ callers: ["model"],
176
188
  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
189
  parameters: Type2.Object({
178
190
  questions: Type2.Array(QuestionItemSchema, { minItems: 1, maxItems: 4 })
@@ -199,13 +211,12 @@ var askUserQuestion = defineTool({
199
211
  });
200
212
  var question = {
201
213
  extension: defineExtension({ name: "question", tools: [askUserQuestion], tasks: [QuestionWait] }),
202
- modelOnly: ["ask_user_question"],
203
214
  docs: [{ token: QuestionDoc, presentation: "pinomad.question" }]
204
215
  };
205
216
 
206
217
  // apps/host/src/extensions/coding.ts
207
218
  import { wrapTool } from "@earendil-works/pi-durable";
208
- import { CodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
219
+ import { createCodingTools, createWriteTool } from "@earendil-works/pi-durable/tools";
209
220
  import { createPatch } from "diff";
210
221
  var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
211
222
  ...tool,
@@ -221,403 +232,32 @@ var writeWithDiff = wrapTool(createWriteTool(), (tool) => ({
221
232
  return { ...result2, details: { patch: createPatch(args.path, oldContent, args.content) } };
222
233
  }
223
234
  }));
224
- var coding = {
225
- extension: { ...CodingTools, wraps: [writeWithDiff] },
226
- tools: { edit: "pinomad.diff", write: "pinomad.diff" }
227
- };
235
+ function createCoding(options) {
236
+ return {
237
+ extension: { ...createCodingTools(options), wraps: [writeWithDiff] },
238
+ tools: { edit: "pinomad.diff", write: "pinomad.diff" }
239
+ };
240
+ }
241
+ var coding = createCoding();
228
242
 
229
243
  // 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";
244
+ import { CodemodeSandbox, loadQuickJSWasm } from "@earendil-works/pi-codemode";
245
+ import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
245
246
  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
- });
247
+ function createCodemode() {
248
+ const sandbox = new CodemodeSandbox({ wasm, memoryLimitBytes: 256 * 1024 * 1024 });
249
+ const namespaced = (namespaces) => createCodemodeExtension({ sandbox, namespaces });
495
250
  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
- }),
251
+ extension: namespaced([]),
531
252
  tools: { codemode: "pinomad.codemode" },
532
- modelOnly: ["codemode"]
253
+ namespaced,
254
+ close: () => sandbox.close()
533
255
  };
534
256
  }
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
257
 
618
258
  // apps/host/src/extensions/context.ts
619
259
  import { dirname, join, sep } from "node:path";
620
- import { defineExtension as defineExtension3, section as section2 } from "@earendil-works/pi-durable";
260
+ import { defineExtension as defineExtension2, section } from "@earendil-works/pi-durable";
621
261
  import { formatSkillsForPrompt, loadProjectContextFiles, loadSkills } from "@earendil-works/pi-coding-agent";
622
262
  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
263
  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 +265,11 @@ function createContext(options) {
625
265
  const { agentsHome } = options;
626
266
  const resolveCheckout = (input, context) => options.checkout?.(input, context);
627
267
  return {
628
- extension: defineExtension3({
268
+ extension: defineExtension2({
629
269
  name: "context",
630
270
  sections: [
631
- section2("preamble", () => PREAMBLE, { tag: false }),
632
- section2("environment", async (input, context) => {
271
+ section("preamble", () => PREAMBLE, { tag: false }),
272
+ section("environment", async (input, context) => {
633
273
  const cwd = input.agent.cwd;
634
274
  if (cwd === undefined)
635
275
  return;
@@ -649,7 +289,7 @@ function createContext(options) {
649
289
  ].join(`
650
290
  `);
651
291
  }),
652
- section2("project_context", async (input, context) => {
292
+ section("project_context", async (input, context) => {
653
293
  const cwd = input.agent.cwd;
654
294
  if (cwd === undefined)
655
295
  return;
@@ -675,7 +315,7 @@ ${file.content}`).join(`
675
315
 
676
316
  `);
677
317
  }),
678
- section2("skills", (input) => {
318
+ section("skills", (input) => {
679
319
  const cwd = input.agent.cwd;
680
320
  if (cwd === undefined)
681
321
  return;
@@ -699,10 +339,11 @@ import {
699
339
  AgentDoc,
700
340
  AssistantEntry,
701
341
  configure,
702
- defineExtension as defineExtension4,
703
- defineTool as defineTool3
342
+ defineExtension as defineExtension3,
343
+ defineTool as defineTool2,
344
+ section as section2
704
345
  } from "@earendil-works/pi-durable";
705
- import { Type as Type4 } from "typebox";
346
+ import { Type as Type3 } from "typebox";
706
347
 
707
348
  // apps/host/src/subagent-doc.ts
708
349
  import { defineDoc as defineDoc2 } from "@earendil-works/pi-durable";
@@ -717,20 +358,32 @@ var SubagentDoc = defineDoc2({
717
358
 
718
359
  // apps/host/src/extensions/subagent.ts
719
360
  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"];
361
+ var THINKING_LEVELS2 = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
721
362
  function createSubagent(options) {
722
363
  const available = () => options.modelSummaries().map((model) => `${model.provider}/${model.modelId}`).join(", ");
723
- const tool = defineTool3({
364
+ const roles = options.roles ?? (() => []);
365
+ const roleNamed = (name) => {
366
+ const found = roles().find((each) => each.name === name);
367
+ if (found !== undefined)
368
+ return found;
369
+ const defined = roles();
370
+ throw new Error(defined.length === 0 ? "No named agents are defined" : `Unknown agent "${name}". Available agents: ${defined.map((each) => each.name).join(", ")}`);
371
+ };
372
+ const tool = defineTool2({
724
373
  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({
374
+ callers: ["model"],
375
+ 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()}.`,
376
+ parameters: Type3.Object({
377
+ task: Type3.String({
728
378
  minLength: 1,
729
379
  description: "Complete, self-contained instructions. The subagent sees none of this conversation."
730
380
  }),
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." }))
381
+ role: Type3.Optional(Type3.String({
382
+ 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."
383
+ })),
384
+ description: Type3.Optional(Type3.String({ description: 'A short 3-6 word label for the user, e.g. "Audit auth middleware".' })),
385
+ model: Type3.Optional(Type3.String({ description: "Optional `provider/modelId` to use instead of yours." })),
386
+ thinkingLevel: Type3.Optional(Type3.Union(THINKING_LEVELS2.map((level) => Type3.Literal(level)), { description: "Optional thinking level instead of yours." }))
734
387
  }),
735
388
  replay: "safe",
736
389
  execute: async (args, api, context) => {
@@ -744,31 +397,43 @@ function createSubagent(options) {
744
397
  ...doc2?.startedAt === undefined ? {} : { startedAt: doc2.startedAt }
745
398
  };
746
399
  }
400
+ const role = args.role === undefined ? undefined : roleNamed(args.role);
401
+ if (role?.model !== undefined && args.model !== undefined) {
402
+ throw new Error(`Agent "${role.name}" pins model ${role.model}; omit \`model\``);
403
+ }
404
+ if (role?.thinkingLevel !== undefined && args.thinkingLevel !== undefined) {
405
+ throw new Error(`Agent "${role.name}" pins thinking level ${role.thinkingLevel}; omit \`thinkingLevel\``);
406
+ }
407
+ const modelString = role?.model ?? args.model;
408
+ const levelString = role?.thinkingLevel ?? args.thinkingLevel;
747
409
  let model;
748
410
  let thinkingLevel;
749
411
  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);
412
+ if (modelString !== undefined) {
413
+ const slash = modelString.indexOf("/");
414
+ const provider = slash === -1 ? "" : modelString.slice(0, slash);
415
+ const modelId = slash === -1 ? "" : modelString.slice(slash + 1);
754
416
  override = provider === "" || modelId === "" ? undefined : options.models.getModel(provider, modelId);
755
417
  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()}`);
418
+ if (override === undefined || !listed) {
419
+ throw new Error(role === undefined ? `Unknown model "${modelString}". Available models: ${available()}` : `Agent "${role.name}" uses model "${modelString}", which is not available. Available models: ${available()}`);
420
+ }
758
421
  model = { provider, modelId };
759
422
  }
760
- if (args.thinkingLevel !== undefined || override !== undefined) {
423
+ if (levelString !== undefined || override !== undefined) {
761
424
  const parent = await tx.doc(AgentDoc, api.conversationId);
762
425
  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);
426
+ if (levelString !== undefined) {
427
+ thinkingLevel = effective === undefined ? levelString : clampThinkingLevel(effective, levelString);
765
428
  } else if (parent.thinkingLevel !== undefined) {
766
429
  thinkingLevel = clampThinkingLevel(override, parent.thinkingLevel);
767
430
  }
768
431
  }
769
432
  const change = {
770
433
  extensions: { remove: [extension, ...options.exclude] },
771
- instructions: INSTRUCTIONS,
434
+ instructions: role === undefined || role.prompt === "" ? INSTRUCTIONS : `${INSTRUCTIONS}
435
+
436
+ ${role.prompt}`,
772
437
  ...model === undefined ? {} : { model },
773
438
  ...thinkingLevel === undefined ? {} : { thinkingLevel }
774
439
  };
@@ -781,6 +446,8 @@ function createSubagent(options) {
781
446
  doc.startedAt = startedAt;
782
447
  if (ref !== undefined)
783
448
  doc.model = `${ref.provider}/${ref.modelId}`;
449
+ if (role !== undefined)
450
+ doc.role = role.name;
784
451
  const label = args.description?.trim();
785
452
  if (label !== undefined && label !== "")
786
453
  doc.label = label;
@@ -798,7 +465,7 @@ function createSubagent(options) {
798
465
  const message = answer?.model?.[0];
799
466
  const text = message?.role === "assistant" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("") : "";
800
467
  const result2 = {
801
- content: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
468
+ output: [{ type: "text", text: text === "" ? "(The subagent returned no text.)" : text }],
802
469
  details: {
803
470
  conversationId: child.id,
804
471
  ...child.model === undefined ? {} : { model: child.model },
@@ -808,13 +475,28 @@ function createSubagent(options) {
808
475
  return result2;
809
476
  }
810
477
  });
811
- const extension = defineExtension4({ name: "subagent", tools: [tool] });
812
- return { extension, modelOnly: ["subagent"] };
478
+ const extension = defineExtension3({
479
+ name: "subagent",
480
+ tools: [tool],
481
+ sections: [
482
+ section2("agent_roles", () => {
483
+ const defined = roles();
484
+ if (defined.length === 0)
485
+ return;
486
+ return [
487
+ "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`.",
488
+ ...defined.map((each) => `- ${each.name}: ${each.description.replace(/\s+/g, " ")}`)
489
+ ].join(`
490
+ `);
491
+ })
492
+ ]
493
+ });
494
+ return { extension };
813
495
  }
814
496
 
815
497
  // 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";
498
+ import { defineDoc as defineDoc3, defineExtension as defineExtension4, defineTool as defineTool3 } from "@earendil-works/pi-durable";
499
+ import { Type as Type4 } from "typebox";
818
500
  var TodoDoc = defineDoc3({
819
501
  kind: "todo.list",
820
502
  version: 1,
@@ -823,33 +505,30 @@ var TodoDoc = defineDoc3({
823
505
  fork: "asOf",
824
506
  initial: () => ({ items: [] })
825
507
  });
826
- var todoWrite = defineTool4({
508
+ var todoWrite = defineTool3({
827
509
  name: "todo_write",
828
510
  description: "Replace the todo list shown to the user with the given items.",
829
- parameters: Type5.Object({ items: Type5.Array(TodoItemSchema) }),
511
+ parameters: Type4.Object({ items: Type4.Array(TodoItemSchema) }),
830
512
  replay: "safe",
831
513
  execute: async (args, api, context) => {
832
514
  await api.commit(async (tx) => {
833
515
  const doc = await tx.doc(TodoDoc, api.conversationId);
834
516
  doc.items = args.items.map((item) => ({ ...item }));
835
517
  }, context);
836
- return { content: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
518
+ return { output: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
837
519
  }
838
520
  });
839
521
  var todo = {
840
- extension: defineExtension5({ name: "todo", tools: [todoWrite] }),
522
+ extension: defineExtension4({ name: "todo", tools: [todoWrite] }),
841
523
  docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
842
524
  };
843
525
 
844
526
  // 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";
527
+ import { randomBytes as randomBytes3 } from "node:crypto";
528
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
529
+ import { basename as basename2, join as join7, sep as sep4 } from "node:path";
530
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
531
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
853
532
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
854
533
 
855
534
  // apps/host/src/organization.ts
@@ -939,7 +618,7 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
939
618
  try {
940
619
  await git(dir, ["read-tree", "HEAD"], env);
941
620
  await git(dir, ["add", "-A"], env);
942
- const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", base], env);
621
+ const { stdout } = await git(dir, ["diff", "--cached", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", base], env);
943
622
  if (Buffer.byteLength(stdout) <= maxBytes)
944
623
  return { base, patch: stdout, truncated: false };
945
624
  const cut = stdout.slice(0, maxBytes);
@@ -951,6 +630,16 @@ async function changesOf(dir, base, maxBytes = 1024 * 1024) {
951
630
  }
952
631
  }
953
632
 
633
+ // apps/host/src/message-content.ts
634
+ function messageContent(text, images) {
635
+ if (images === undefined || images.length === 0)
636
+ return text;
637
+ return [
638
+ ...text === "" ? [] : [{ type: "text", text }],
639
+ ...images.map(({ data, mimeType }) => ({ type: "image", data, mimeType }))
640
+ ];
641
+ }
642
+
954
643
  // apps/host/src/organization.ts
955
644
  var IndexDoc = defineDoc4({
956
645
  kind: "pinomad.index",
@@ -1003,7 +692,7 @@ async function createConversation(harness, dataDir, input, defaults, models, con
1003
692
  if (prior !== undefined) {
1004
693
  const existing = await harness.conversation(prior.id, context);
1005
694
  if (existing !== undefined) {
1006
- await existing.submit({ type: "input", content: input.text, whenBusy: "followUp", requestId: input.requestId }, context);
695
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
1007
696
  }
1008
697
  return prior.id;
1009
698
  }
@@ -1055,14 +744,14 @@ async function createConversation(harness, dataDir, input, defaults, models, con
1055
744
  }, context);
1056
745
  if (home.kind === "chat")
1057
746
  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);
747
+ await conversation.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
1059
748
  return conversation.id;
1060
749
  } catch (error) {
1061
750
  if (!(error instanceof DuplicateRequest))
1062
751
  throw error;
1063
752
  const existing = await harness.conversation(error.conversationId, context);
1064
753
  if (existing !== undefined) {
1065
- await existing.submit({ type: "input", content: input.text, whenBusy: "followUp", requestId: input.requestId }, context);
754
+ await existing.submit({ type: "input", content: messageContent(input.text, input.images), whenBusy: "followUp", requestId: input.requestId }, context);
1066
755
  }
1067
756
  return error.conversationId;
1068
757
  }
@@ -1166,8 +855,8 @@ import {
1166
855
  StreamableHttpTransport,
1167
856
  toLlmContent
1168
857
  } 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";
858
+ import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
859
+ import { Type as Type5 } from "typebox";
1171
860
  var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1172
861
  var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1173
862
  function expand(value) {
@@ -1289,32 +978,23 @@ var callToolResultSchema = (tool) => ({
1289
978
  },
1290
979
  required: ["content"]
1291
980
  });
1292
- function scriptTool(client, timeoutMs, server, description, name, tool) {
1293
- return {
981
+ function mcpTool(client, timeoutMs, exposure, name, tool) {
982
+ return defineTool4({
1294
983
  name,
1295
984
  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({
985
+ parameters: Type5.Unsafe({
1311
986
  ...tool.inputSchema,
1312
987
  type: "object",
1313
988
  properties: tool.inputSchema.properties ?? {}
1314
989
  }),
990
+ ...exposure === "direct" ? {} : { callers: ["tools"] },
991
+ structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
1315
992
  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 };
993
+ const result2 = await client.callTool(tool.name, args, {
994
+ signal: context.abortSignal ?? NEVER_ABORT,
995
+ timeoutMs
996
+ });
997
+ return { output: toLlmContent(result2), structuredOutput: result2, isError: result2.isError === true };
1318
998
  }
1319
999
  });
1320
1000
  }
@@ -1323,6 +1003,7 @@ function startMcp(options) {
1323
1003
  const listeners = new Set;
1324
1004
  const servers = new Map;
1325
1005
  const toolsets = new Map;
1006
+ const namespaces = new Map;
1326
1007
  const exposures = new Map;
1327
1008
  const live = new Set;
1328
1009
  let errors = [];
@@ -1333,8 +1014,8 @@ function startMcp(options) {
1333
1014
  listener(value);
1334
1015
  };
1335
1016
  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 }));
1017
+ options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
1018
+ options.onNamespaces?.([...namespaces.values()]);
1338
1019
  };
1339
1020
  const set = (name, patch) => {
1340
1021
  servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
@@ -1351,16 +1032,23 @@ function startMcp(options) {
1351
1032
  continue;
1352
1033
  }
1353
1034
  taken.add(wire);
1354
- tools.push(scriptTool(client, timeoutMs, name, description, wire, tool));
1035
+ tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
1355
1036
  }
1356
1037
  if (closed)
1357
1038
  return;
1358
1039
  toolsets.set(name, tools);
1040
+ namespaces.set(name, {
1041
+ name,
1042
+ ...description === undefined ? {} : { description },
1043
+ ...client.instructions === undefined ? {} : { instructions: client.instructions },
1044
+ prefix: callName(name, "")
1045
+ });
1359
1046
  set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
1360
1047
  install();
1361
1048
  };
1362
1049
  const fail = (name, error) => {
1363
1050
  toolsets.delete(name);
1051
+ namespaces.delete(name);
1364
1052
  set(name, { state: "failed", error, tools: 0 });
1365
1053
  install();
1366
1054
  };
@@ -1435,7 +1123,6 @@ ${stderr}`;
1435
1123
  });
1436
1124
  return {
1437
1125
  status,
1438
- scriptTools: () => [...toolsets.values()].flat(),
1439
1126
  async close() {
1440
1127
  closed = true;
1441
1128
  await Promise.all([...live].map((client) => client.close().catch(() => {})));
@@ -1443,23 +1130,882 @@ ${stderr}`;
1443
1130
  };
1444
1131
  }
1445
1132
 
1133
+ // apps/host/src/agent-roles.ts
1134
+ import { randomBytes as randomBytes2 } from "node:crypto";
1135
+ import { existsSync, watch } from "node:fs";
1136
+ import { mkdir as mkdir2, readdir, readFile as readFile2, rename, rm as rm2, writeFile } from "node:fs/promises";
1137
+ import { join as join4 } from "node:path";
1138
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1139
+
1140
+ // packages/protocol/src/agents.ts
1141
+ var MAX_AGENT_NAME = 32;
1142
+ var MAX_AGENT_DESCRIPTION = 1024;
1143
+ var NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1144
+ function agentNameProblem(name) {
1145
+ return name.length > MAX_AGENT_NAME || !NAME_PATTERN.test(name) ? `Name must be lowercase letters, digits and hyphens (max ${MAX_AGENT_NAME})` : undefined;
1146
+ }
1147
+ function agentRoleProblem(role) {
1148
+ const name = agentNameProblem(role.name);
1149
+ if (name !== undefined)
1150
+ return name;
1151
+ const description = role.description.trim();
1152
+ if (description === "")
1153
+ return "description is required";
1154
+ if (description.length > MAX_AGENT_DESCRIPTION)
1155
+ return `description is too long (max ${MAX_AGENT_DESCRIPTION})`;
1156
+ if (role.model !== undefined) {
1157
+ const slash = role.model.indexOf("/");
1158
+ if (slash <= 0 || slash === role.model.length - 1)
1159
+ return "model must be provider/modelId";
1160
+ }
1161
+ if (role.thinkingLevel !== undefined && !isThinkingLevel(role.thinkingLevel)) {
1162
+ return `thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
1163
+ }
1164
+ return;
1165
+ }
1166
+
1167
+ // apps/host/src/host-value.ts
1168
+ function constantValue(value) {
1169
+ return {
1170
+ value,
1171
+ subscribe: () => () => {}
1172
+ };
1173
+ }
1174
+
1175
+ class Published {
1176
+ #listeners = new Set;
1177
+ #equal;
1178
+ #value;
1179
+ constructor(value, equal) {
1180
+ this.#value = value;
1181
+ this.#equal = equal;
1182
+ }
1183
+ get value() {
1184
+ return this.#value;
1185
+ }
1186
+ subscribe(listener) {
1187
+ this.#listeners.add(listener);
1188
+ return () => this.#listeners.delete(listener);
1189
+ }
1190
+ set(next) {
1191
+ if (this.#equal(this.#value, next))
1192
+ return;
1193
+ this.#value = next;
1194
+ for (const listener of this.#listeners)
1195
+ listener(next);
1196
+ }
1197
+ }
1198
+ var sameList = (a, b, same) => a.length === b.length && a.every((entry, index) => same(entry, b[index]));
1199
+
1200
+ // apps/host/src/agent-roles.ts
1201
+ var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
1202
+ async function load(dir) {
1203
+ const roles = [];
1204
+ const problems = [];
1205
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
1206
+ for (const entry of entries) {
1207
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
1208
+ continue;
1209
+ const file = entry.name;
1210
+ const name = file.slice(0, -".md".length);
1211
+ const problem = (reason2) => void problems.push({ file, reason: reason2 });
1212
+ let parsed;
1213
+ try {
1214
+ parsed = parseFrontmatter(await readFile2(join4(dir, file), "utf8"));
1215
+ } catch (error) {
1216
+ problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
1217
+ continue;
1218
+ }
1219
+ if (typeof parsed.frontmatter !== "object" || parsed.frontmatter === null || Array.isArray(parsed.frontmatter)) {
1220
+ problem("Frontmatter must be a record of fields");
1221
+ continue;
1222
+ }
1223
+ const unknown = Object.keys(parsed.frontmatter).find((key) => !KNOWN_FIELDS.has(key));
1224
+ if (unknown !== undefined) {
1225
+ problem(`Unknown field "${unknown}"`);
1226
+ continue;
1227
+ }
1228
+ const bad = Object.entries(parsed.frontmatter).find(([, value]) => typeof value !== "string");
1229
+ if (bad !== undefined) {
1230
+ problem(`Field "${bad[0]}" must be a string`);
1231
+ continue;
1232
+ }
1233
+ const frontmatter = parsed.frontmatter;
1234
+ const role = {
1235
+ name,
1236
+ description: frontmatter.description ?? "",
1237
+ ...frontmatter.model === undefined ? {} : { model: frontmatter.model },
1238
+ ...frontmatter.thinkingLevel === undefined ? {} : { thinkingLevel: frontmatter.thinkingLevel },
1239
+ prompt: parsed.body.trim()
1240
+ };
1241
+ const reason = agentRoleProblem(role);
1242
+ if (reason !== undefined) {
1243
+ problem(reason);
1244
+ continue;
1245
+ }
1246
+ roles.push(role);
1247
+ }
1248
+ roles.sort((a, b) => a.name.localeCompare(b.name));
1249
+ problems.sort((a, b) => a.file.localeCompare(b.file));
1250
+ return { roles, problems };
1251
+ }
1252
+ function serialize(role) {
1253
+ const fields = [
1254
+ `description: ${JSON.stringify(role.description)}`,
1255
+ ...role.model === undefined ? [] : [`model: ${JSON.stringify(role.model)}`],
1256
+ ...role.thinkingLevel === undefined ? [] : [`thinkingLevel: ${JSON.stringify(role.thinkingLevel)}`]
1257
+ ];
1258
+ return `---
1259
+ ${fields.join(`
1260
+ `)}
1261
+ ---
1262
+
1263
+ ${role.prompt}
1264
+ `;
1265
+ }
1266
+ var sameRole = (a, b) => a.name === b.name && a.description === b.description && a.model === b.model && a.thinkingLevel === b.thinkingLevel && a.prompt === b.prompt;
1267
+ var sameProblem = (a, b) => a.file === b.file && a.reason === b.reason;
1268
+ async function openAgentRoles(options) {
1269
+ const dir = options.dir;
1270
+ await mkdir2(dir, { recursive: true });
1271
+ const debounceMs = options.debounceMs ?? 150;
1272
+ const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
1273
+ let closed = false;
1274
+ let reported = false;
1275
+ let timer;
1276
+ const reload = async () => {
1277
+ try {
1278
+ published.set(await load(dir));
1279
+ reported = false;
1280
+ } catch (error) {
1281
+ if (!reported)
1282
+ console.error(`agent-roles reload failed: ${error instanceof Error ? error.message : String(error)}`);
1283
+ reported = true;
1284
+ }
1285
+ };
1286
+ await reload();
1287
+ const watcher = watch(dir, () => {
1288
+ if (closed)
1289
+ return;
1290
+ clearTimeout(timer);
1291
+ timer = setTimeout(() => void reload(), debounceMs);
1292
+ });
1293
+ watcher.on("error", (error) => {
1294
+ if (!reported)
1295
+ console.error(`agent-roles watch failed: ${error.message}`);
1296
+ reported = true;
1297
+ });
1298
+ let pending = Promise.resolve();
1299
+ const enqueue = (operation) => {
1300
+ const run2 = pending.then(operation, operation);
1301
+ pending = run2.then(() => {}, () => {});
1302
+ return run2;
1303
+ };
1304
+ const write = async (role, mustExist) => {
1305
+ const problem = agentRoleProblem(role);
1306
+ if (problem !== undefined)
1307
+ throw new Error(problem);
1308
+ await enqueue(async () => {
1309
+ const target = join4(dir, `${role.name}.md`);
1310
+ const exists = existsSync(target);
1311
+ if (mustExist && !exists)
1312
+ throw new Error(`No such agent: "${role.name}"`);
1313
+ if (!mustExist && exists)
1314
+ throw new Error(`Agent "${role.name}" already exists`);
1315
+ const temp = join4(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
1316
+ try {
1317
+ await writeFile(temp, serialize(role));
1318
+ await rename(temp, target);
1319
+ } catch (error) {
1320
+ await rm2(temp, { force: true }).catch(() => {});
1321
+ throw error;
1322
+ }
1323
+ await reload();
1324
+ });
1325
+ };
1326
+ return {
1327
+ get value() {
1328
+ return published.value;
1329
+ },
1330
+ subscribe: (listener) => published.subscribe(listener),
1331
+ create: (role) => write(role, false),
1332
+ update: (role) => write(role, true),
1333
+ async remove(name) {
1334
+ const problem = agentNameProblem(name);
1335
+ if (problem !== undefined)
1336
+ throw new Error(problem);
1337
+ await enqueue(async () => {
1338
+ const target = join4(dir, `${name}.md`);
1339
+ if (!existsSync(target))
1340
+ throw new Error(`No such agent: "${name}"`);
1341
+ await rm2(target);
1342
+ await reload();
1343
+ });
1344
+ },
1345
+ close() {
1346
+ closed = true;
1347
+ clearTimeout(timer);
1348
+ watcher.close();
1349
+ }
1350
+ };
1351
+ }
1352
+
1353
+ // apps/host/src/host-settings.ts
1354
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1355
+ import { dirname as dirname2 } from "node:path";
1356
+ function settingsDefaults(settings) {
1357
+ return {
1358
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
1359
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
1360
+ };
1361
+ }
1362
+ var isModelRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.provider === "string" && value.provider !== "" && typeof value.modelId === "string" && value.modelId !== "";
1363
+ var fileValueError = (key, value, models) => {
1364
+ switch (key) {
1365
+ case "defaultModel":
1366
+ if (!isModelRef(value))
1367
+ return "malformed";
1368
+ return models.some((each) => each.provider === value.provider && each.modelId === value.modelId) ? undefined : "unavailable";
1369
+ case "defaultThinkingLevel":
1370
+ return isThinkingLevel(value) ? undefined : "malformed";
1371
+ case "steeringMode":
1372
+ case "followUpMode":
1373
+ return isQueueMode(value) ? undefined : "malformed";
1374
+ case "acceptAccountDevices":
1375
+ return typeof value === "boolean" ? undefined : "malformed";
1376
+ }
1377
+ };
1378
+ var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
1379
+ var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.acceptAccountDevices === b.acceptAccountDevices && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && sameList(a.custom, b.custom, (x, y) => x === y);
1380
+ var parse = (path, text) => {
1381
+ let parsed;
1382
+ try {
1383
+ parsed = JSON.parse(text);
1384
+ } catch (error) {
1385
+ throw new Error(`Invalid settings file ${path}: ${error instanceof Error ? error.message : String(error)}`);
1386
+ }
1387
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1388
+ throw new Error(`Invalid settings file ${path}: expected a JSON object`);
1389
+ }
1390
+ return parsed;
1391
+ };
1392
+ async function openHostSettings(options) {
1393
+ let file = await readFile3(options.path, "utf8").then((text) => parse(options.path, text), (error) => {
1394
+ if (error.code === "ENOENT")
1395
+ return {};
1396
+ throw new Error(`Cannot read settings file ${options.path}: ${error instanceof Error ? error.message : String(error)}`);
1397
+ });
1398
+ const warned = new Set;
1399
+ const compute = () => {
1400
+ const fileValues = {};
1401
+ const custom = [];
1402
+ for (const key of HOST_SETTING_KEYS) {
1403
+ if (!Object.hasOwn(file, key))
1404
+ continue;
1405
+ const value = file[key];
1406
+ const invalid = fileValueError(key, value, options.models.value);
1407
+ if (invalid === "malformed" && !warned.has(key)) {
1408
+ warned.add(key);
1409
+ console.error(`${options.path}: ignoring invalid value for settings key "${key}": ${JSON.stringify(value)}`);
1410
+ }
1411
+ if (invalid !== undefined)
1412
+ continue;
1413
+ fileValues[key] = value;
1414
+ custom.push(key);
1415
+ }
1416
+ const { fallback } = options;
1417
+ return {
1418
+ defaultModel: fileValues.defaultModel ?? fallback.defaultModel(),
1419
+ defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
1420
+ steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
1421
+ followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
1422
+ acceptAccountDevices: fileValues.acceptAccountDevices ?? true,
1423
+ custom
1424
+ };
1425
+ };
1426
+ const published = new Published(compute(), sameSettings);
1427
+ options.models.subscribe(() => published.set(compute()));
1428
+ let tail = Promise.resolve();
1429
+ const write = async (patch) => {
1430
+ const model = patch.defaultModel;
1431
+ if (model !== undefined && model !== null && !options.models.value.some((each) => each.provider === model.provider && each.modelId === model.modelId)) {
1432
+ throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
1433
+ }
1434
+ const next = { ...file };
1435
+ for (const key of HOST_SETTING_KEYS) {
1436
+ const value = patch[key];
1437
+ if (value === undefined)
1438
+ continue;
1439
+ if (value === null)
1440
+ delete next[key];
1441
+ else
1442
+ next[key] = value;
1443
+ }
1444
+ const tmp = `${options.path}.tmp`;
1445
+ await mkdir3(dirname2(options.path), { recursive: true });
1446
+ await writeFile2(tmp, `${JSON.stringify(next, null, 2)}
1447
+ `, { mode: 384 });
1448
+ await rename2(tmp, options.path);
1449
+ file = next;
1450
+ published.set(compute());
1451
+ };
1452
+ return {
1453
+ get value() {
1454
+ return published.value;
1455
+ },
1456
+ subscribe: (listener) => published.subscribe(listener),
1457
+ update: (patch) => tail = tail.then(() => write(patch), () => write(patch))
1458
+ };
1459
+ }
1460
+
1446
1461
  // apps/host/src/host.ts
1447
1462
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1448
1463
  import lockfile from "proper-lockfile";
1449
1464
 
1450
1465
  // apps/host/src/gateway.ts
1451
1466
  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";
1467
+ import { homedir as homedir2, networkInterfaces } from "node:os";
1468
+ import { join as join6, sep as sep3 } from "node:path";
1469
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1455
1470
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1456
1471
  import {
1457
1472
  AgentDoc as AgentDoc3,
1458
1473
  LiveDoc
1459
1474
  } from "@earendil-works/pi-durable";
1460
- import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1475
+ import { WebSocket, WebSocketServer } from "ws";
1461
1476
  import { Value as Value2 } from "typebox/value";
1462
1477
 
1478
+ // apps/host/src/folders.ts
1479
+ import { readdir as readdir2, realpath as realpath3, stat as stat3 } from "node:fs/promises";
1480
+ import { isAbsolute as isAbsolute2, join as join5 } from "node:path";
1481
+ var denied = (error) => {
1482
+ const code = error?.code;
1483
+ return code === "EACCES" || code === "EPERM";
1484
+ };
1485
+ async function listFolders(path, home) {
1486
+ if (path !== undefined && !isAbsolute2(path))
1487
+ throw new Error(`Not an absolute path: ${path}`);
1488
+ const given = path ?? home;
1489
+ const dir = await realpath3(given).catch((error) => {
1490
+ if (denied(error))
1491
+ throw new Error(`Permission denied: ${given}`);
1492
+ throw new Error(`No such directory: ${given}`);
1493
+ });
1494
+ if (!(await stat3(dir)).isDirectory())
1495
+ throw new Error(`Not a directory: ${given}`);
1496
+ let entries;
1497
+ try {
1498
+ entries = await readdir2(dir, { withFileTypes: true });
1499
+ } catch (error) {
1500
+ if (denied(error))
1501
+ throw new Error(`Permission denied: ${given}`);
1502
+ throw error;
1503
+ }
1504
+ const folders = [];
1505
+ for (const entry of entries) {
1506
+ if (entry.name.startsWith("."))
1507
+ continue;
1508
+ let isDirectory = entry.isDirectory();
1509
+ if (!isDirectory && entry.isSymbolicLink()) {
1510
+ isDirectory = await stat3(join5(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1511
+ }
1512
+ if (isDirectory)
1513
+ folders.push({ name: entry.name, path: join5(dir, entry.name) });
1514
+ }
1515
+ folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1516
+ return { path: dir, home: await realpath3(home).catch(() => home), folders };
1517
+ }
1518
+
1519
+ // packages/protocol/src/images.ts
1520
+ var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1521
+ var MAX_IMAGES_PER_MESSAGE = 4;
1522
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1523
+ var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
1524
+ function imageProblem(images) {
1525
+ if (images.length > MAX_IMAGES_PER_MESSAGE)
1526
+ return `At most ${MAX_IMAGES_PER_MESSAGE} images per message`;
1527
+ for (let i = 0;i < images.length; i++) {
1528
+ const image = images[i];
1529
+ if (!IMAGE_MIME_TYPES.includes(image.mimeType)) {
1530
+ return `Unsupported image type: ${image.mimeType} (use JPEG, PNG, GIF or WebP)`;
1531
+ }
1532
+ if (image.data === "" || image.data.length % 4 !== 0 || !BASE64.test(image.data)) {
1533
+ return `Image ${i + 1} is not valid base64`;
1534
+ }
1535
+ const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
1536
+ if (image.data.length * 3 / 4 - padding > MAX_IMAGE_BYTES)
1537
+ return `Image ${i + 1} is larger than 5 MB`;
1538
+ }
1539
+ return;
1540
+ }
1541
+
1542
+ // apps/host/src/auto-title.ts
1543
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1544
+
1545
+ // apps/host/src/title-doc.ts
1546
+ import { defineDoc as defineDoc5 } from "@earendil-works/pi-durable";
1547
+ var TitleDoc = defineDoc5({
1548
+ kind: "pinomad.title",
1549
+ version: 1,
1550
+ scope: "conversation",
1551
+ history: "latest",
1552
+ fork: "initial",
1553
+ initial: () => ({})
1554
+ });
1555
+
1556
+ // apps/host/src/auto-title.ts
1557
+ var context = BACKGROUND_CONTEXT;
1558
+ 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.";
1559
+ var TITLE_INPUT_LIMIT = 2000;
1560
+ var TITLE_MAX_LENGTH = 80;
1561
+ var textOf = (content) => {
1562
+ if (typeof content === "string")
1563
+ return content;
1564
+ if (!Array.isArray(content))
1565
+ return "";
1566
+ return content.flatMap((block) => typeof block === "object" && block !== null && block.type === "text" ? [block.text ?? ""] : []).join("");
1567
+ };
1568
+ function cleanTitle(raw) {
1569
+ const line = raw.split(`
1570
+ `).map((each) => each.trim()).find((each) => each !== "");
1571
+ if (line === undefined)
1572
+ return;
1573
+ let text = line.replace(/^\s*title\s*:\s*/i, "");
1574
+ let before = "";
1575
+ while (text !== before) {
1576
+ before = text;
1577
+ text = text.replace(/^["'“”‘’「」`]+/, "").replace(/["'“”‘’「」`]+$/, "").replace(/[.。!!??]+$/, "");
1578
+ }
1579
+ text = text.replace(/\s+/g, " ").trim();
1580
+ if (text === "")
1581
+ return;
1582
+ return text.length > TITLE_MAX_LENGTH ? text.slice(0, TITLE_MAX_LENGTH).trimEnd() : text;
1583
+ }
1584
+ function attachAutoTitle(runs, deps) {
1585
+ const inFlight = new Set;
1586
+ const pending = new Set;
1587
+ const start = (id) => {
1588
+ inFlight.add(id);
1589
+ setImmediate(() => void title(id));
1590
+ };
1591
+ const title = async (id) => {
1592
+ try {
1593
+ const existing = await deps.harness.snapshot(TitleDoc, id, context);
1594
+ if (typeof existing?.title === "string" && existing.title !== "")
1595
+ return;
1596
+ const conversation = await deps.harness.conversation(id, context);
1597
+ if (conversation === undefined)
1598
+ return;
1599
+ let first;
1600
+ let newest;
1601
+ let cursor;
1602
+ do {
1603
+ const page = await conversation.entries({}, 256, cursor, context);
1604
+ first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id) ?? first;
1605
+ newest ??= page.items.find((entry) => entry.conversationId === id && entry.kind === "pi.assistant");
1606
+ cursor = page.next;
1607
+ } while (cursor !== undefined);
1608
+ const last = newest?.model?.[0];
1609
+ if (first === undefined || last?.role !== "assistant" || last.stopReason === "error" || last.stopReason === "aborted") {
1610
+ return;
1611
+ }
1612
+ const ref = deps.settings.value.defaultModel;
1613
+ const model = ref === null ? undefined : deps.models.getModel(ref.provider, ref.modelId);
1614
+ if (model === undefined)
1615
+ return;
1616
+ const firstText = (textOf(first.model?.[0] !== undefined ? first.model[0].content : "") || "(images only)").slice(0, TITLE_INPUT_LIMIT);
1617
+ const answer = textOf(last.content).slice(0, TITLE_INPUT_LIMIT);
1618
+ const request = {
1619
+ systemPrompt: TITLE_PROMPT,
1620
+ messages: [
1621
+ {
1622
+ role: "user",
1623
+ content: `User's first message:
1624
+ ${firstText}${answer === "" ? "" : `
1625
+
1626
+ Agent's reply:
1627
+ ${answer}`}`,
1628
+ timestamp: Date.now()
1629
+ }
1630
+ ]
1631
+ };
1632
+ const generated = await deps.models.completeSimple(model, request, { maxTokens: 256 });
1633
+ const cleaned = cleanTitle(generated.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(""));
1634
+ if (generated.stopReason === "error" || cleaned === undefined) {
1635
+ console.error(`auto-title: conversation ${id}: ${generated.stopReason === "error" ? generated.errorMessage ?? "provider error" : "unusable model output"}`);
1636
+ return;
1637
+ }
1638
+ await deps.harness.commit(async (tx) => {
1639
+ const doc = await tx.doc(TitleDoc, id);
1640
+ if (typeof doc.title === "string" && doc.title !== "")
1641
+ return;
1642
+ doc.title = cleaned;
1643
+ doc.source = "auto";
1644
+ }, context);
1645
+ } catch (error) {
1646
+ console.error(`auto-title: conversation ${id}: ${error instanceof Error ? error.message : String(error)}`);
1647
+ } finally {
1648
+ inFlight.delete(id);
1649
+ if (pending.delete(id))
1650
+ start(id);
1651
+ }
1652
+ };
1653
+ return runs.onRunEnd((id) => {
1654
+ if (inFlight.has(id)) {
1655
+ pending.add(id);
1656
+ return;
1657
+ }
1658
+ start(id);
1659
+ });
1660
+ }
1661
+
1662
+ // apps/host/src/push.ts
1663
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1664
+ import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
1665
+
1666
+ // packages/client/src/family.ts
1667
+ var SUBAGENT_TASK_PREFIX = /^\s*in the working directory\s+\S+,\s*/i;
1668
+ function displayName(summary) {
1669
+ if (summary === undefined)
1670
+ return "Conversation";
1671
+ if (summary.kind !== "subagent")
1672
+ return summary.title ?? "New conversation";
1673
+ if (summary.label !== undefined)
1674
+ return summary.label;
1675
+ const title = summary.title?.replace(SUBAGENT_TASK_PREFIX, "").trim();
1676
+ return title === undefined || title === "" ? "Subagent" : title;
1677
+ }
1678
+
1679
+ // packages/client/src/run-notifications.ts
1680
+ var everySummary = (organized) => {
1681
+ const summaries = [];
1682
+ const visit = (node) => {
1683
+ summaries.push(node.summary);
1684
+ for (const child of node.children)
1685
+ visit(child);
1686
+ };
1687
+ for (const root of [...organized.chats, ...organized.projects.flatMap((entry) => entry.conversations)])
1688
+ visit(root);
1689
+ return summaries;
1690
+ };
1691
+ function diffRunNotices(previous, organized) {
1692
+ const notices = [];
1693
+ const changed = [];
1694
+ const baseline = new Map;
1695
+ for (const summary of everySummary(organized)) {
1696
+ baseline.set(summary.id, summary.status);
1697
+ if (previous === undefined)
1698
+ continue;
1699
+ const before = previous.get(summary.id);
1700
+ if (before !== summary.status)
1701
+ changed.push(summary.id);
1702
+ if (summary.status === "needs-answer" && before !== "needs-answer") {
1703
+ notices.push({ id: summary.id, title: displayName(summary), body: "Waiting for your answer" });
1704
+ continue;
1705
+ }
1706
+ const runEnded = before === "running" || before === "needs-answer";
1707
+ if (runEnded && (summary.status === undefined || summary.status === "failed") && summary.kind !== "subagent") {
1708
+ notices.push({ id: summary.id, title: displayName(summary), body: summary.status === "failed" ? "Failed" : "Finished" });
1709
+ }
1710
+ }
1711
+ if (previous !== undefined) {
1712
+ for (const gone of previous.keys()) {
1713
+ if (!baseline.has(gone))
1714
+ changed.push(gone);
1715
+ }
1716
+ }
1717
+ return { notices, changed, baseline };
1718
+ }
1719
+
1720
+ // apps/host/src/push.ts
1721
+ var context2 = BACKGROUND_CONTEXT2;
1722
+ var PushDoc = defineDoc6({
1723
+ kind: "pinomad.push",
1724
+ version: 1,
1725
+ scope: "session",
1726
+ initial: () => ({ registrations: [] })
1727
+ });
1728
+ var PUSH_TOKEN_PATTERN = /^Expo(?:nent)?PushToken\[[^\]]+\]$/;
1729
+ var PUSH_TOKEN_MAX_LENGTH = 256;
1730
+ function pushTokenProblem(token) {
1731
+ if (token === null)
1732
+ return;
1733
+ if (token.length > PUSH_TOKEN_MAX_LENGTH) {
1734
+ return `Push token is too long (${PUSH_TOKEN_MAX_LENGTH} characters at most)`;
1735
+ }
1736
+ if (!PUSH_TOKEN_PATTERN.test(token))
1737
+ return "Not an Expo push token";
1738
+ return;
1739
+ }
1740
+ async function setPushToken(harness, publicKey, token, context3) {
1741
+ const problem = pushTokenProblem(token);
1742
+ if (problem !== undefined)
1743
+ throw new Error(problem);
1744
+ await harness.commit(async (tx) => {
1745
+ const devices = await tx.doc(DevicesDoc);
1746
+ if (!devices.devices.some((entry) => entry.publicKey === publicKey)) {
1747
+ throw new Error("Device is not paired");
1748
+ }
1749
+ const push = await tx.doc(PushDoc);
1750
+ push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
1751
+ if (token !== null)
1752
+ push.registrations.push({ publicKey, token, updatedAt: Date.now() });
1753
+ }, context3);
1754
+ }
1755
+ async function revokeDevice(harness, publicKey, context3) {
1756
+ await harness.commit(async (tx) => {
1757
+ const devices = await tx.doc(DevicesDoc);
1758
+ devices.devices = devices.devices.filter((entry) => entry.publicKey !== publicKey);
1759
+ const push = await tx.doc(PushDoc);
1760
+ push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
1761
+ const revocations = await tx.doc(RevocationsDoc);
1762
+ if (!revocations.revoked.includes(publicKey))
1763
+ revocations.revoked.push(publicKey);
1764
+ }, context3);
1765
+ }
1766
+ async function dropTokenIfCurrent(harness, token, context3) {
1767
+ await harness.commit(async (tx) => {
1768
+ const push = await tx.doc(PushDoc);
1769
+ push.registrations = push.registrations.filter((entry) => entry.token !== token);
1770
+ }, context3);
1771
+ }
1772
+ var EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
1773
+ var EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
1774
+ var FALLBACK_TITLE = "PiNomad conversation";
1775
+ var TITLE_WAIT_MS = 15000;
1776
+ var TTL_SECONDS = 3600;
1777
+ var REQUEST_TIMEOUT_MS = 1e4;
1778
+ var MAX_ATTEMPTS = 4;
1779
+ var RETRY_BASE_MS = 5000;
1780
+ var RECEIPT_DELAY_MS = 15 * 60 * 1000;
1781
+ var MAX_PENDING = 128;
1782
+ var realTimers = {
1783
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
1784
+ clearTimeout: (handle) => clearTimeout(handle)
1785
+ };
1786
+ var tokenHint = (token) => `…${token.slice(-6)}`;
1787
+ async function attachPushSender(options) {
1788
+ const { harness, conversations } = options;
1789
+ const fetcher = options.fetch ?? fetch;
1790
+ const timers = options.timers ?? realTimers;
1791
+ const indexWatch = await harness.watchDoc(IndexDoc, context2);
1792
+ let index = indexWatch?.value ?? undefined;
1793
+ let baseline;
1794
+ let disposed = false;
1795
+ const pending = new Set;
1796
+ const titleWaiters = new Map;
1797
+ const receiptTimers = new Set;
1798
+ const generation = new Map;
1799
+ const update = () => {
1800
+ if (index === undefined)
1801
+ return;
1802
+ const organized = organize(index, conversations.value);
1803
+ if (baseline === undefined) {
1804
+ baseline = diffRunNotices(undefined, organized).baseline;
1805
+ for (const id of baseline.keys())
1806
+ generation.set(id, 0);
1807
+ return;
1808
+ }
1809
+ const diff = diffRunNotices(baseline, organized);
1810
+ baseline = diff.baseline;
1811
+ for (const id of diff.changed)
1812
+ generation.set(id, (generation.get(id) ?? 0) + 1);
1813
+ for (const notice of diff.notices)
1814
+ enqueue(notice);
1815
+ };
1816
+ const fresh = (item) => baseline?.has(item.notice.id) === true && generation.get(item.notice.id) === item.generation;
1817
+ const registrations = async () => {
1818
+ const doc = await harness.snapshot(PushDoc, context2);
1819
+ return new Set((doc?.registrations ?? []).map((entry) => entry.token));
1820
+ };
1821
+ const post = async (url, payload) => {
1822
+ const response = await fetcher(url, {
1823
+ method: "POST",
1824
+ headers: { accept: "application/json", "content-type": "application/json" },
1825
+ body: JSON.stringify(payload),
1826
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1827
+ });
1828
+ return { status: response.status, body: await response.json() };
1829
+ };
1830
+ const ticketOf = (body) => {
1831
+ const data = body?.data;
1832
+ const ticket = Array.isArray(data) ? data[0] : data;
1833
+ return ticket !== null && typeof ticket === "object" ? ticket : undefined;
1834
+ };
1835
+ const message = (item, title) => ({
1836
+ to: item.token,
1837
+ title,
1838
+ body: item.notice.body,
1839
+ sound: "default",
1840
+ ttl: TTL_SECONDS,
1841
+ data: { hostKey: options.hostKey, conversationId: item.notice.id },
1842
+ threadId: `${options.hostKey}:${item.notice.id}`,
1843
+ collapseId: `${options.hostKey}:${item.notice.id}`
1844
+ });
1845
+ const namedTitle = async (id) => {
1846
+ const doc = await harness.snapshot(TitleDoc, id, context2);
1847
+ return typeof doc?.title === "string" && doc.title !== "" ? doc.title : undefined;
1848
+ };
1849
+ const titleFor = async (notice) => {
1850
+ const named = await namedTitle(notice.id);
1851
+ if (named !== undefined)
1852
+ return named;
1853
+ if (notice.body !== "Finished" || disposed)
1854
+ return FALLBACK_TITLE;
1855
+ return await new Promise((resolve3) => {
1856
+ const waiters = titleWaiters.get(notice.id) ?? new Set;
1857
+ titleWaiters.set(notice.id, waiters);
1858
+ const settle = (title) => {
1859
+ waiters.delete(settle);
1860
+ if (waiters.size === 0)
1861
+ titleWaiters.delete(notice.id);
1862
+ timers.clearTimeout(timer);
1863
+ resolve3(title);
1864
+ };
1865
+ const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
1866
+ waiters.add(settle);
1867
+ namedTitle(notice.id).then((title) => {
1868
+ if (title !== undefined)
1869
+ settle(title);
1870
+ });
1871
+ });
1872
+ };
1873
+ const drop = (item) => {
1874
+ pending.delete(item);
1875
+ if (item.timer !== undefined)
1876
+ timers.clearTimeout(item.timer);
1877
+ };
1878
+ const attempt = async (item) => {
1879
+ if (disposed || !pending.has(item))
1880
+ return;
1881
+ try {
1882
+ if (!(await registrations()).has(item.token) || !fresh(item))
1883
+ return drop(item);
1884
+ if (item.title === undefined) {
1885
+ item.title = await titleFor(item.notice);
1886
+ if (disposed || !pending.has(item) || !(await registrations()).has(item.token) || !fresh(item))
1887
+ return drop(item);
1888
+ }
1889
+ const { status, body } = await post(EXPO_SEND_URL, message(item, item.title));
1890
+ const ticket = ticketOf(body);
1891
+ if (ticket?.details?.error === "DeviceNotRegistered") {
1892
+ drop(item);
1893
+ console.error(`push: token ${tokenHint(item.token)} is not registered (ticket)`);
1894
+ await dropTokenIfCurrent(harness, item.token, context2);
1895
+ return;
1896
+ }
1897
+ if (status >= 200 && status < 300 && ticket?.status === "ok") {
1898
+ drop(item);
1899
+ if (typeof ticket.id === "string")
1900
+ scheduleReceiptCheck(item.token, ticket.id);
1901
+ return;
1902
+ }
1903
+ if ((status === 429 || status >= 500) && item.attempts + 1 < MAX_ATTEMPTS) {
1904
+ item.attempts += 1;
1905
+ item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
1906
+ return;
1907
+ }
1908
+ console.error(`push: send to ${tokenHint(item.token)} failed permanently (HTTP ${status})`);
1909
+ drop(item);
1910
+ } catch (error) {
1911
+ if (item.attempts + 1 < MAX_ATTEMPTS) {
1912
+ item.attempts += 1;
1913
+ item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
1914
+ return;
1915
+ }
1916
+ console.error(`push: send to ${tokenHint(item.token)} gave up: ${error instanceof Error ? error.message : String(error)}`);
1917
+ drop(item);
1918
+ }
1919
+ };
1920
+ const scheduleReceiptCheck = (token, ticketId) => {
1921
+ const timer = timers.setTimeout(() => {
1922
+ receiptTimers.delete(timer);
1923
+ checkReceipt(token, ticketId);
1924
+ }, RECEIPT_DELAY_MS);
1925
+ receiptTimers.add(timer);
1926
+ while (receiptTimers.size > MAX_PENDING) {
1927
+ const oldest = receiptTimers.values().next().value;
1928
+ receiptTimers.delete(oldest);
1929
+ timers.clearTimeout(oldest);
1930
+ }
1931
+ };
1932
+ const checkReceipt = async (token, ticketId) => {
1933
+ if (disposed)
1934
+ return;
1935
+ try {
1936
+ const { status, body } = await post(EXPO_RECEIPTS_URL, { ids: [ticketId] });
1937
+ const receipt = status >= 200 && status < 300 ? body?.data?.[ticketId] : undefined;
1938
+ if (receipt === undefined || disposed)
1939
+ return;
1940
+ if (receipt.status === "error" && receipt.details?.error === "DeviceNotRegistered") {
1941
+ console.error(`push: token ${tokenHint(token)} is not registered (receipt)`);
1942
+ await dropTokenIfCurrent(harness, token, context2);
1943
+ return;
1944
+ }
1945
+ if (receipt.status === "error")
1946
+ console.error(`push: receipt ${ticketId} reports ${receipt.details?.error ?? "error"}`);
1947
+ } catch (error) {
1948
+ console.error(`push: receipt check ${ticketId} failed: ${error instanceof Error ? error.message : String(error)}`);
1949
+ }
1950
+ };
1951
+ const enqueue = async (notice) => {
1952
+ if (disposed)
1953
+ return;
1954
+ const itemGeneration = generation.get(notice.id) ?? 0;
1955
+ try {
1956
+ const tokens = await registrations();
1957
+ for (const token of tokens) {
1958
+ if (disposed)
1959
+ return;
1960
+ if (pending.size >= MAX_PENDING) {
1961
+ const [oldest] = pending;
1962
+ drop(oldest);
1963
+ }
1964
+ const item = { token, notice, generation: itemGeneration, attempts: 0 };
1965
+ pending.add(item);
1966
+ attempt(item);
1967
+ }
1968
+ } catch (error) {
1969
+ console.error(`push: could not read registrations: ${error instanceof Error ? error.message : String(error)}`);
1970
+ }
1971
+ };
1972
+ const unsubscribeConversations = conversations.subscribe(() => update());
1973
+ const unsubscribeCommits = harness.subscribeCommits((publication) => {
1974
+ for (const change of publication.changes) {
1975
+ if (change.type !== "document" || change.record.kind !== TitleDoc.definition.kind)
1976
+ continue;
1977
+ const id = change.conversationId;
1978
+ if (id === undefined)
1979
+ continue;
1980
+ const title = change.value?.title;
1981
+ if (typeof title !== "string" || title === "")
1982
+ continue;
1983
+ const waiters = titleWaiters.get(id);
1984
+ titleWaiters.delete(id);
1985
+ for (const settle of waiters ?? [])
1986
+ settle(title);
1987
+ }
1988
+ });
1989
+ indexWatch?.start(async (value) => {
1990
+ index = value ?? undefined;
1991
+ update();
1992
+ });
1993
+ update();
1994
+ return async () => {
1995
+ disposed = true;
1996
+ unsubscribeConversations();
1997
+ unsubscribeCommits();
1998
+ await indexWatch?.stop();
1999
+ for (const item of pending)
2000
+ drop(item);
2001
+ pending.clear();
2002
+ titleWaiters.clear();
2003
+ for (const timer of receiptTimers)
2004
+ timers.clearTimeout(timer);
2005
+ receiptTimers.clear();
2006
+ };
2007
+ }
2008
+
1463
2009
  // packages/protocol/src/tool-target.ts
1464
2010
  var TARGET_KEYS = [
1465
2011
  "path",
@@ -1487,154 +2033,8 @@ function toolTarget(args) {
1487
2033
  return;
1488
2034
  }
1489
2035
 
1490
- // apps/host/src/relay-link.ts
1491
- import { WebSocket } from "ws";
1492
- function relayWsBase(origin) {
1493
- const normalized = origin.replace(/\/+$/, "");
1494
- if (normalized.startsWith("https://"))
1495
- return `wss://${normalized.slice("https://".length)}`;
1496
- if (normalized.startsWith("http://"))
1497
- return `ws://${normalized.slice("http://".length)}`;
1498
- throw new Error(`relay origin must be an http(s) URL: ${origin}`);
1499
- }
1500
- var REJECTION_REASONS = {
1501
- [RELAY_CLOSE.badSignature]: () => `the relay rejected this host's registration signature — a version mismatch?`,
1502
- [RELAY_CLOSE.hostNotAllowed]: (hostId) => `the relay does not allow this host; add --allow-host ${hostId} to the relay's arguments`,
1503
- [RELAY_CLOSE.replaced]: () => `the relay replaced this control connection — another process registered the same hostId`
1504
- };
1505
- function startRelayLink(options) {
1506
- const origin = options.origin.replace(/\/+$/, "");
1507
- const wsBase = relayWsBase(origin);
1508
- const hostId = relayHostId(options.signingKey.publicKey);
1509
- const minDelay = options.reconnectDelayMs?.min ?? 1000;
1510
- const maxDelay = options.reconnectDelayMs?.max ?? 30000;
1511
- const maxPendingAccepts = options.maxPendingAccepts ?? 16;
1512
- let state = { status: "connecting" };
1513
- let stopped = false;
1514
- let control;
1515
- let delay = minDelay;
1516
- let retryTimer;
1517
- let pendingAccepts = 0;
1518
- function setState(next) {
1519
- const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
1520
- state = next;
1521
- if (changed)
1522
- options.onStateChange?.(next);
1523
- }
1524
- const watched = new Set;
1525
- const ponged = new WeakMap;
1526
- const pingIntervalMs = options.pingIntervalMs ?? 30000;
1527
- const pingTimer = setInterval(() => {
1528
- for (const socket of watched) {
1529
- if (socket.readyState !== WebSocket.OPEN)
1530
- continue;
1531
- if (ponged.get(socket) === true) {
1532
- ponged.set(socket, false);
1533
- socket.ping();
1534
- } else {
1535
- socket.terminate();
1536
- }
1537
- }
1538
- }, pingIntervalMs);
1539
- function watch(socket) {
1540
- watched.add(socket);
1541
- ponged.set(socket, true);
1542
- socket.on("pong", () => ponged.set(socket, true));
1543
- socket.once("close", () => watched.delete(socket));
1544
- socket.on("error", () => socket.terminate());
1545
- }
1546
- function connectControl() {
1547
- if (stopped)
1548
- return;
1549
- const socket = new WebSocket(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
1550
- control = socket;
1551
- watch(socket);
1552
- socket.on("message", onControlMessage);
1553
- socket.once("close", onControlClosed);
1554
- }
1555
- function reconnect(at) {
1556
- if (stopped || retryTimer !== undefined)
1557
- return;
1558
- retryTimer = setTimeout(() => {
1559
- retryTimer = undefined;
1560
- connectControl();
1561
- }, at);
1562
- }
1563
- function onControlMessage(data, isBinary) {
1564
- let frame;
1565
- try {
1566
- if (isBinary)
1567
- throw new Error("binary control frame");
1568
- frame = decodeRelayToHost(data.toString());
1569
- } catch {
1570
- control?.close();
1571
- return;
1572
- }
1573
- switch (frame.t) {
1574
- case "challenge":
1575
- control?.send(encodeRelayFrame({
1576
- t: "register",
1577
- hostId,
1578
- signature: signRelayChallenge(options.signingKey.secretKey, { relayOrigin: origin, hostId, nonce: frame.nonce })
1579
- }));
1580
- break;
1581
- case "registered":
1582
- delay = minDelay;
1583
- setState({ status: "registered" });
1584
- break;
1585
- case "incoming": {
1586
- if (pendingAccepts >= maxPendingAccepts)
1587
- break;
1588
- pendingAccepts += 1;
1589
- const data2 = new WebSocket(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
1590
- watch(data2);
1591
- const release = () => {
1592
- pendingAccepts -= 1;
1593
- clearTimeout(ageOut);
1594
- data2.off("close", release);
1595
- };
1596
- const ageOut = setTimeout(release, 1e4);
1597
- data2.once("close", release);
1598
- options.accept(data2);
1599
- break;
1600
- }
1601
- }
1602
- }
1603
- function onControlClosed(code) {
1604
- control = undefined;
1605
- if (stopped)
1606
- return;
1607
- const rejection = REJECTION_REASONS[code];
1608
- if (rejection !== undefined) {
1609
- const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
1610
- setState({ status: "rejected", reason });
1611
- delay = maxDelay;
1612
- reconnect(maxDelay);
1613
- return;
1614
- }
1615
- setState({ status: "connecting" });
1616
- reconnect(delay);
1617
- delay = Math.min(maxDelay, delay * 2);
1618
- }
1619
- connectControl();
1620
- return {
1621
- hostId,
1622
- origin,
1623
- state: () => state,
1624
- close() {
1625
- stopped = true;
1626
- if (retryTimer !== undefined)
1627
- clearTimeout(retryTimer);
1628
- clearInterval(pingTimer);
1629
- for (const socket of watched)
1630
- socket.terminate();
1631
- return Promise.resolve();
1632
- }
1633
- };
1634
- }
1635
-
1636
2036
  // apps/host/src/gateway.ts
1637
- var context = BACKGROUND_CONTEXT;
2037
+ var context3 = BACKGROUND_CONTEXT3;
1638
2038
  var forkTails = new WeakMap;
1639
2039
  function serializedFork(harness, work) {
1640
2040
  const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
@@ -1649,6 +2049,18 @@ function serializedFork(harness, work) {
1649
2049
  var LOOPBACK_SECURE_PATH = "/secure";
1650
2050
  async function startGateway(options) {
1651
2051
  const conversations = await ConversationList.open(options.harness, options.docs);
2052
+ const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
2053
+ harness: options.harness,
2054
+ models: options.models,
2055
+ settings: options.settings
2056
+ });
2057
+ const detachPush = options.push === undefined ? undefined : await attachPushSender({
2058
+ harness: options.harness,
2059
+ conversations,
2060
+ hostKey: options.push.hostKey,
2061
+ ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
2062
+ ...options.push.timers === undefined ? {} : { timers: options.push.timers }
2063
+ });
1652
2064
  const http = createServer((request, response) => {
1653
2065
  serveWebClient(request, response, options.webRoot).catch(() => {
1654
2066
  if (!response.headersSent)
@@ -1671,7 +2083,10 @@ async function startGateway(options) {
1671
2083
  };
1672
2084
  let remote;
1673
2085
  let relay;
1674
- const register = (connection, devicePublicKey) => {
2086
+ const register = (connection, devicePublicKey, joined) => {
2087
+ if (joined?.source === "account") {
2088
+ broadcast("info", `New device "${joined.name}" joined through your account`);
2089
+ }
1675
2090
  const client = new GatewayClient(connection, options, conversations, {
1676
2091
  devicePublicKey,
1677
2092
  closed: (self) => {
@@ -1688,20 +2103,30 @@ async function startGateway(options) {
1688
2103
  const spec = options.secure;
1689
2104
  if (spec === undefined)
1690
2105
  return;
1691
- const { secret, expiresAt } = spec.offers.create();
1692
- const pair = `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret}`;
2106
+ const pair = (secret2) => `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret2}`;
1693
2107
  if (relay !== undefined) {
2108
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
1694
2109
  const target2 = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1695
- return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target2)}`, expiresAt };
2110
+ return { url: `${relay.origin}/#${pair(secret2)}&url=${encodeURIComponent(target2)}`, expiresAt: expiresAt2 };
1696
2111
  }
1697
- if (remote !== undefined)
1698
- return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
2112
+ if (remote !== undefined) {
2113
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
2114
+ return { url: `${remote.advertiseUrl}/#${pair(secret2)}`, expiresAt: expiresAt2 };
2115
+ }
2116
+ const { secret, expiresAt } = spec.offers.create("local");
1699
2117
  const target = `ws://127.0.0.1:${port}${LOOPBACK_SECURE_PATH}`;
1700
- return { url: `http://127.0.0.1:${port}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
2118
+ return { url: `http://127.0.0.1:${port}/#${pair(secret)}&url=${encodeURIComponent(target)}`, expiresAt };
1701
2119
  }
1702
2120
  });
1703
2121
  clients.add(client);
1704
2122
  };
2123
+ const admission = {
2124
+ harness: options.harness,
2125
+ acceptAccountDevices: () => options.settings.value.acceptAccountDevices,
2126
+ serverBinding: async () => await readServerBinding(options.dataDir).catch(() => {
2127
+ return;
2128
+ })
2129
+ };
1705
2130
  server.on("connection", (socket, request) => {
1706
2131
  socket.on("error", () => socket.terminate());
1707
2132
  const url = new URL(request.url ?? "/", "ws://127.0.0.1");
@@ -1711,7 +2136,7 @@ async function startGateway(options) {
1711
2136
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1712
2137
  return;
1713
2138
  }
1714
- secureHandshake(socket, secure2, options.harness, register);
2139
+ secureHandshake(socket, secure2, admission, register);
1715
2140
  return;
1716
2141
  }
1717
2142
  const token = url.searchParams.get("token");
@@ -1722,14 +2147,34 @@ async function startGateway(options) {
1722
2147
  }
1723
2148
  register(tokenConnection(socket));
1724
2149
  });
1725
- remote = await startRemote(options, register);
2150
+ remote = await startRemote(options, admission, register);
1726
2151
  const secure = options.secure;
2152
+ const detachSettingsWatch = options.settings.subscribe((settings) => {
2153
+ if (!settings.acceptAccountDevices)
2154
+ secure?.offers.revokeAccount();
2155
+ });
1727
2156
  if (secure?.relay !== undefined) {
2157
+ const relayOrigin = secure.relay.origin;
1728
2158
  let link;
1729
2159
  link = startRelayLink({
1730
- origin: secure.relay.origin,
2160
+ origin: relayOrigin,
1731
2161
  signingKey: secure.relay.signingKey,
1732
- accept: (socket) => secureHandshake(socket, secure, options.harness, register),
2162
+ noisePublicKey: secure.hostKey.publicKey,
2163
+ accept: (socket) => secureHandshake(socket, secure, admission, register),
2164
+ accountOffer: async () => {
2165
+ if (!options.settings.value.acceptAccountDevices)
2166
+ return { ok: false, reason: "disabled" };
2167
+ const config = await readServerConfig(options.dataDir).catch(() => {
2168
+ return;
2169
+ });
2170
+ if (config?.binding === undefined)
2171
+ return { ok: false, reason: "not_joined" };
2172
+ if (normalizeOrigin(config.origin) !== normalizeOrigin(relayOrigin)) {
2173
+ return { ok: false, reason: "rebinding" };
2174
+ }
2175
+ const { secret, expiresAt } = secure.offers.createAccount(config.binding);
2176
+ return { ok: true, secret, expiresAt };
2177
+ },
1733
2178
  onStateChange: (state) => {
1734
2179
  if (state.status === "registered") {
1735
2180
  console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId }));
@@ -1752,6 +2197,9 @@ async function startGateway(options) {
1752
2197
  broadcast,
1753
2198
  async close() {
1754
2199
  await relay?.close();
2200
+ detachAutoTitle?.();
2201
+ detachSettingsWatch();
2202
+ await detachPush?.();
1755
2203
  conversations.dispose();
1756
2204
  await Promise.all([...clients].map((client) => client.dispose()));
1757
2205
  for (const socket of server.clients)
@@ -1765,9 +2213,9 @@ async function startGateway(options) {
1765
2213
  function tokenConnection(socket) {
1766
2214
  return {
1767
2215
  get open() {
1768
- return socket.readyState === WebSocket2.OPEN;
2216
+ return socket.readyState === WebSocket.OPEN;
1769
2217
  },
1770
- send: (text) => socket.readyState === WebSocket2.OPEN ? new Promise((resolve3) => socket.send(text, () => resolve3())) : Promise.resolve(),
2218
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => socket.send(text, () => resolve3())) : Promise.resolve(),
1771
2219
  close: (code, reason) => socket.close(code, reason),
1772
2220
  handle(handlers) {
1773
2221
  socket.on("message", (data) => handlers.message(String(data)));
@@ -1778,9 +2226,9 @@ function tokenConnection(socket) {
1778
2226
  function secureConnection(socket, session) {
1779
2227
  return {
1780
2228
  get open() {
1781
- return socket.readyState === WebSocket2.OPEN;
2229
+ return socket.readyState === WebSocket.OPEN;
1782
2230
  },
1783
- send: (text) => socket.readyState === WebSocket2.OPEN ? new Promise((resolve3) => {
2231
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => {
1784
2232
  const chunks = session.seal(text);
1785
2233
  chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve3() : undefined));
1786
2234
  }) : Promise.resolve(),
@@ -1815,7 +2263,7 @@ function pickLanAddress(interfaces) {
1815
2263
  }
1816
2264
  return fallback;
1817
2265
  }
1818
- function secureHandshake(socket, secure, harness, register) {
2266
+ function secureHandshake(socket, secure, policy, register) {
1819
2267
  socket.on("error", () => socket.terminate());
1820
2268
  const responder = respondIK({ prologue: SECURE_PROLOGUE, static: secure.hostKey });
1821
2269
  let settled = false;
@@ -1837,7 +2285,7 @@ function secureHandshake(socket, secure, harness, register) {
1837
2285
  clearTimeout(timeout);
1838
2286
  const session = new SecureSession(admitted.result);
1839
2287
  socket.send(admitted.message);
1840
- register(secureConnection(socket, session), admitted.deviceKey);
2288
+ register(secureConnection(socket, session), admitted.deviceKey, admitted.joined);
1841
2289
  }).catch(() => socket.close(1011, "handshake failed"));
1842
2290
  async function admit() {
1843
2291
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
@@ -1852,19 +2300,28 @@ function secureHandshake(socket, secure, harness, register) {
1852
2300
  return;
1853
2301
  }
1854
2302
  const deviceKey = toBase64Url(remoteStatic);
1855
- if (!await isRegistered(harness, deviceKey, context)) {
1856
- if (hello.pair === undefined || !secure.offers.consume(hello.pair.secret)) {
1857
- socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1858
- return;
1859
- }
1860
- await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context);
2303
+ const decision = await admitDevice(policy.harness, {
2304
+ publicKey: deviceKey,
2305
+ ...hello.pair === undefined ? {} : { pair: hello.pair },
2306
+ offers: secure.offers,
2307
+ acceptAccountDevices: policy.acceptAccountDevices,
2308
+ serverBinding: policy.serverBinding
2309
+ }, context3);
2310
+ if (decision.outcome === "refused") {
2311
+ socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
2312
+ return;
1861
2313
  }
1862
2314
  const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
1863
- return { result: result2, message, deviceKey };
2315
+ return {
2316
+ result: result2,
2317
+ message,
2318
+ deviceKey,
2319
+ ...decision.outcome === "admitted" ? { joined: decision.entry } : {}
2320
+ };
1864
2321
  }
1865
2322
  });
1866
2323
  }
1867
- async function startRemote(options, register) {
2324
+ async function startRemote(options, admission, register) {
1868
2325
  const spec = options.secure;
1869
2326
  const remote = spec?.lan;
1870
2327
  if (spec === undefined || remote === undefined)
@@ -1877,7 +2334,7 @@ async function startRemote(options, register) {
1877
2334
  });
1878
2335
  });
1879
2336
  const secure = new WebSocketServer({ server: http });
1880
- secure.on("connection", (socket) => secureHandshake(socket, spec, options.harness, register));
2337
+ secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
1881
2338
  await new Promise((resolveListen, reject) => {
1882
2339
  http.once("listening", resolveListen);
1883
2340
  http.once("error", reject);
@@ -1905,6 +2362,7 @@ class ConversationList {
1905
2362
  #records = new Map;
1906
2363
  #facts = new Map;
1907
2364
  #index = new Map;
2365
+ #runEnd = new Set;
1908
2366
  #list = [];
1909
2367
  #unsubscribe = () => {};
1910
2368
  #notifying = false;
@@ -1917,7 +2375,7 @@ class ConversationList {
1917
2375
  const summaries = [];
1918
2376
  let cursor;
1919
2377
  do {
1920
- const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context);
2378
+ const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context3);
1921
2379
  for (const record of page.items) {
1922
2380
  list.#records.set(record.id, record);
1923
2381
  const facts = await list.#load(harness, record);
@@ -1934,24 +2392,30 @@ class ConversationList {
1934
2392
  async#load(harness, record) {
1935
2393
  const history = await historyFacts(harness, record.id);
1936
2394
  const facts = { pending: new Set, ...history };
1937
- const live = await harness.snapshot(LiveDoc, record.id, context);
2395
+ const live = await harness.snapshot(LiveDoc, record.id, context3);
1938
2396
  facts.running = live?.run !== undefined;
1939
2397
  facts.tools = live?.tools;
1940
2398
  for (const token of this.#questionTokens) {
1941
- const value = await harness.snapshot(token, record.id, context);
2399
+ const value = await harness.snapshot(token, record.id, context3);
1942
2400
  if (Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined)) {
1943
2401
  facts.pending.add(token.definition.kind);
1944
2402
  }
1945
2403
  }
1946
2404
  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;
2405
+ const subagent = await harness.snapshot(SubagentDoc, record.id, context3);
2406
+ if (typeof subagent?.label === "string" && subagent.label !== "")
2407
+ facts.label = subagent.label;
2408
+ if (typeof subagent?.role === "string" && subagent.role !== "")
2409
+ facts.role = subagent.role;
1950
2410
  }
2411
+ const named = (await harness.snapshot(TitleDoc, record.id, context3))?.title;
2412
+ if (typeof named === "string" && named !== "")
2413
+ facts.named = named;
1951
2414
  return facts;
1952
2415
  }
1953
2416
  #observe(publication) {
1954
2417
  const touched = new Set;
2418
+ const ended = new Set;
1955
2419
  for (const change of publication.changes) {
1956
2420
  if (change.type === "conversation") {
1957
2421
  this.#records.set(change.value.id, change.value);
@@ -1981,9 +2445,12 @@ class ConversationList {
1981
2445
  const facts = this.#fact(id);
1982
2446
  if (change.record.kind === "pi.live") {
1983
2447
  const live = change.value;
2448
+ const wasRunning = facts.running === true;
1984
2449
  facts.running = live?.run !== undefined;
1985
2450
  facts.tools = live?.tools;
1986
2451
  touched.add(id);
2452
+ if (wasRunning && !facts.running && this.#records.get(id)?.owner === undefined)
2453
+ ended.add(id);
1987
2454
  } else if (this.#questionKinds.has(change.record.kind)) {
1988
2455
  const value = change.value;
1989
2456
  const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
@@ -1995,8 +2462,13 @@ class ConversationList {
1995
2462
  facts.pending = next2;
1996
2463
  touched.add(id);
1997
2464
  } else if (change.record.kind === SubagentDoc.definition.kind) {
1998
- const label = change.value?.label;
1999
- facts.label = typeof label === "string" && label !== "" ? label : undefined;
2465
+ const value = change.value;
2466
+ facts.label = typeof value?.label === "string" && value.label !== "" ? value.label : undefined;
2467
+ facts.role = typeof value?.role === "string" && value.role !== "" ? value.role : undefined;
2468
+ touched.add(id);
2469
+ } else if (change.record.kind === TitleDoc.definition.kind) {
2470
+ const named = change.value?.title;
2471
+ facts.named = typeof named === "string" && named !== "" ? named : undefined;
2000
2472
  touched.add(id);
2001
2473
  }
2002
2474
  }
@@ -2019,6 +2491,13 @@ class ConversationList {
2019
2491
  }
2020
2492
  if (next !== undefined)
2021
2493
  this.#set(next);
2494
+ for (const id of ended)
2495
+ for (const listener of this.#runEnd)
2496
+ listener(id);
2497
+ }
2498
+ onRunEnd(listener) {
2499
+ this.#runEnd.add(listener);
2500
+ return () => this.#runEnd.delete(listener);
2022
2501
  }
2023
2502
  #fact(id) {
2024
2503
  let facts = this.#facts.get(id);
@@ -2062,31 +2541,33 @@ function summaryOf(record, facts) {
2062
2541
  kind: record.owner !== undefined ? "subagent" : record.parent !== undefined ? "fork" : "conversation",
2063
2542
  ...parent === undefined ? {} : { parent },
2064
2543
  ...record.parent === undefined ? {} : { forkedAt: String(record.parent.at) },
2065
- ...facts.title === undefined ? {} : { title: facts.title },
2544
+ ...(facts.named ?? facts.title) === undefined ? {} : { title: facts.named ?? facts.title },
2066
2545
  ...status === undefined ? {} : { status },
2067
2546
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2068
2547
  ...facts.label === undefined ? {} : { label: facts.label },
2548
+ ...facts.role === undefined ? {} : { role: facts.role },
2069
2549
  ...activity === undefined ? {} : { activity }
2070
2550
  };
2071
2551
  }
2072
2552
  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;
2553
+ 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
2554
  }
2075
2555
  function titleOf(entry) {
2076
2556
  const message = entry?.model?.[0];
2077
2557
  if (message?.role !== "user")
2078
2558
  return {};
2079
2559
  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() };
2560
+ const title = text.replace(/\s+/g, " ").trim();
2561
+ return title === "" ? {} : { title };
2081
2562
  }
2082
2563
  async function historyFacts(harness, id) {
2083
- const conversation = await harness.conversation(id, context);
2564
+ const conversation = await harness.conversation(id, context3);
2084
2565
  let first;
2085
2566
  let newest;
2086
2567
  let cursor;
2087
2568
  do {
2088
- const page = await conversation.entries({}, 256, cursor, context);
2089
- first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id) ?? first;
2569
+ const page = await conversation.entries({}, 256, cursor, context3);
2570
+ first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id && titleOf(entry).title !== undefined) ?? first;
2090
2571
  newest ??= page.items.find((entry) => entry.conversationId === id && (entry.kind === "pi.user" || entry.kind === "pi.assistant"));
2091
2572
  cursor = page.next;
2092
2573
  } while (cursor !== undefined);
@@ -2120,13 +2601,20 @@ class GatewayClient {
2120
2601
  #connection;
2121
2602
  #hooks;
2122
2603
  #options;
2123
- #conversations;
2604
+ #hostValues;
2124
2605
  #subscriptions = new Map;
2125
2606
  #disposed = false;
2126
2607
  constructor(connection, options, conversations, hooks) {
2127
2608
  this.#connection = connection;
2128
2609
  this.#options = options;
2129
- this.#conversations = conversations;
2610
+ this.#hostValues = {
2611
+ conversations,
2612
+ mcp: options.mcp ?? constantValue(null),
2613
+ models: options.modelAuth?.models ?? constantValue(options.modelSummaries()),
2614
+ auth: options.modelAuth?.auth ?? constantValue({ logins: [] }),
2615
+ settings: options.settings,
2616
+ agents: options.agents ?? constantValue({ roles: [], problems: [] })
2617
+ };
2130
2618
  this.#hooks = hooks;
2131
2619
  this.devicePublicKey = hooks.devicePublicKey;
2132
2620
  connection.handle({
@@ -2165,7 +2653,7 @@ class GatewayClient {
2165
2653
  ...packagedVersion === undefined ? {} : { hostVersion: packagedVersion },
2166
2654
  session: options.session,
2167
2655
  models: options.modelSummaries(),
2168
- defaults: options.defaults,
2656
+ defaults: settingsDefaults(options.settings.value),
2169
2657
  docs: (options.docs ?? []).map((doc) => ({
2170
2658
  kind: doc.token.definition.kind,
2171
2659
  ...doc.presentation === undefined ? {} : { presentation: doc.presentation }
@@ -2216,36 +2704,34 @@ class GatewayClient {
2216
2704
  state.subscription = subscription;
2217
2705
  }
2218
2706
  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 };
2707
+ const source = this.#hostValue(stream);
2708
+ if (source !== undefined) {
2709
+ send({ type: "snapshot", stream, value: source.value });
2710
+ return { stop: source.subscribe((value) => void send({ type: "snapshot", stream, value })) };
2223
2711
  }
2224
2712
  if (stream === "index") {
2225
- const watch = await this.#options.harness.watchDoc(IndexDoc, context);
2226
- if (watch === undefined)
2713
+ const watch2 = await this.#options.harness.watchDoc(IndexDoc, context3);
2714
+ if (watch2 === undefined)
2227
2715
  throw new Error("Index document is missing");
2228
- return this.#forward(stream, watch, send);
2716
+ return this.#forward(stream, watch2, send);
2229
2717
  }
2230
2718
  if (stream === "devices") {
2231
- const watch = await this.#options.harness.watchDoc(DevicesDoc, context);
2232
- if (watch === undefined)
2719
+ const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context3);
2720
+ if (watch2 === undefined)
2233
2721
  throw new Error("Devices document is missing");
2234
- return this.#forward(stream, watch, send);
2722
+ return this.#forward(stream, watch2, send);
2235
2723
  }
2236
2724
  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
- }
2725
+ return this.#forward(stream, await this.#options.harness.watchTaskGraph(context3), send);
2243
2726
  if (stream.startsWith("doc:")) {
2244
2727
  const at = stream.lastIndexOf(":");
2245
2728
  return this.#openDoc(stream, stream.slice("doc:".length, at), Number(stream.slice(at + 1)), send);
2246
2729
  }
2247
2730
  const conversation = await this.#conversation(Number(stream.slice("conversation:".length)));
2248
- return this.#forward(stream, await conversation.watch(context), send);
2731
+ return this.#forward(stream, await conversation.watch(context3), send);
2732
+ }
2733
+ #hostValue(stream) {
2734
+ return Object.hasOwn(this.#hostValues, stream) ? this.#hostValues[stream] : undefined;
2249
2735
  }
2250
2736
  async#openDoc(stream, kind, id, send) {
2251
2737
  const doc = this.#options.docs?.find((candidate) => candidate.token.definition.kind === kind);
@@ -2268,7 +2754,7 @@ class GatewayClient {
2268
2754
  setImmediate(() => signalCreation());
2269
2755
  });
2270
2756
  try {
2271
- const existing = await harness.watchDoc(token, id, context);
2757
+ const existing = await harness.watchDoc(token, id, context3);
2272
2758
  if (existing !== undefined) {
2273
2759
  unsubscribe();
2274
2760
  return this.#forward(stream, existing, send);
@@ -2281,13 +2767,13 @@ class GatewayClient {
2281
2767
  created.then(async () => {
2282
2768
  if (stopped)
2283
2769
  return;
2284
- const watch = await harness.watchDoc(token, id, context);
2285
- if (watch === undefined)
2770
+ const watch2 = await harness.watchDoc(token, id, context3);
2771
+ if (watch2 === undefined)
2286
2772
  return;
2287
2773
  if (stopped)
2288
- await watch.stop();
2774
+ await watch2.stop();
2289
2775
  else {
2290
- inner = await this.#forward(stream, watch, send);
2776
+ inner = await this.#forward(stream, watch2, send);
2291
2777
  if (stopped)
2292
2778
  await inner.stop();
2293
2779
  }
@@ -2304,10 +2790,10 @@ class GatewayClient {
2304
2790
  }
2305
2791
  };
2306
2792
  }
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;
2793
+ async#forward(stream, watch2, send) {
2794
+ await send({ type: "snapshot", stream, value: watch2.value });
2795
+ watch2.start((_value, ops) => send({ type: "ops", stream, ops }));
2796
+ return watch2;
2311
2797
  }
2312
2798
  async#unsubscribe(stream) {
2313
2799
  const subscription = this.#subscriptions.get(stream);
@@ -2315,7 +2801,7 @@ class GatewayClient {
2315
2801
  await subscription?.subscription?.stop();
2316
2802
  }
2317
2803
  async#conversation(id) {
2318
- const conversation = await this.#options.harness.conversation(id, context);
2804
+ const conversation = await this.#options.harness.conversation(id, context3);
2319
2805
  if (conversation === undefined)
2320
2806
  throw new Error(`Conversation ${id} does not exist`);
2321
2807
  return conversation;
@@ -2331,27 +2817,31 @@ class GatewayClient {
2331
2817
  async#call(method, args) {
2332
2818
  switch (method) {
2333
2819
  case "addProject":
2334
- return addProject(this.#options.harness, args.path, context);
2820
+ return addProject(this.#options.harness, args.path, context3);
2335
2821
  case "removeProject":
2336
- await removeProject(this.#options.harness, args.path, context);
2822
+ await removeProject(this.#options.harness, args.path, context3);
2337
2823
  return null;
2338
2824
  case "createConversation": {
2339
2825
  const create = args;
2826
+ const problem = imageProblem(create.images ?? []);
2827
+ if (problem !== undefined)
2828
+ throw new Error(problem);
2340
2829
  const conversationId = await createConversation(this.#options.harness, this.#options.dataDir, {
2341
2830
  home: create.home,
2342
2831
  text: create.text,
2343
2832
  requestId: create.requestId,
2833
+ ...create.images === undefined ? {} : { images: create.images },
2344
2834
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2345
2835
  ...create.model === undefined ? {} : { model: create.model },
2346
2836
  ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2347
- }, this.#options.defaults, this.#options.models, context);
2837
+ }, settingsDefaults(this.#options.settings.value), this.#options.models, context3);
2348
2838
  return { conversationId };
2349
2839
  }
2350
2840
  case "archive": {
2351
2841
  const input = args;
2352
- await archive(this.#options.harness, input.conversationId, input.archived, context);
2842
+ await archive(this.#options.harness, input.conversationId, input.archived, context3);
2353
2843
  if (input.archived) {
2354
- const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context);
2844
+ const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context3);
2355
2845
  for (const { record, reason } of kept) {
2356
2846
  await this.send({ type: "notice", level: "warning", message: `Kept worktree ${record.path} (branch ${record.branch}): ${reason}` });
2357
2847
  }
@@ -2366,31 +2856,95 @@ class GatewayClient {
2366
2856
  }
2367
2857
  case "revokeDevice": {
2368
2858
  const { publicKey } = args;
2369
- await revokeDevice(this.#options.harness, publicKey, context);
2859
+ await revokeDevice(this.#options.harness, publicKey, context3);
2370
2860
  this.#hooks.closeDevice(publicKey);
2371
2861
  return null;
2372
2862
  }
2863
+ case "setPushToken": {
2864
+ const device = this.devicePublicKey;
2865
+ if (device === undefined)
2866
+ throw new Error("Push tokens register over the secure channel only");
2867
+ await setPushToken(this.#options.harness, device, args.token, context3);
2868
+ return null;
2869
+ }
2870
+ case "logout": {
2871
+ const modelAuth = this.#options.modelAuth;
2872
+ if (modelAuth === undefined)
2873
+ throw new Error("Logging out is not available on this host");
2874
+ await modelAuth.logout(args.providerId);
2875
+ return null;
2876
+ }
2877
+ case "updateSettings": {
2878
+ await this.#options.settings.update(args.patch);
2879
+ return null;
2880
+ }
2881
+ case "createAgent": {
2882
+ const store = this.#options.agents;
2883
+ if (store === undefined)
2884
+ throw new Error("Named agents are not available on this host");
2885
+ await store.create(args.role);
2886
+ return null;
2887
+ }
2888
+ case "updateAgent": {
2889
+ const store = this.#options.agents;
2890
+ if (store === undefined)
2891
+ throw new Error("Named agents are not available on this host");
2892
+ await store.update(args.role);
2893
+ return null;
2894
+ }
2895
+ case "deleteAgent": {
2896
+ const store = this.#options.agents;
2897
+ if (store === undefined)
2898
+ throw new Error("Named agents are not available on this host");
2899
+ await store.remove(args.name);
2900
+ return null;
2901
+ }
2902
+ case "listFolders":
2903
+ return listFolders(args.path, homedir2());
2373
2904
  }
2374
2905
  const conversation = await this.#conversation(args.conversationId);
2375
2906
  switch (method) {
2376
2907
  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) => {
2908
+ const { text, whenBusy, requestId, images } = args;
2909
+ const problem = imageProblem(images ?? []);
2910
+ if (problem !== undefined)
2911
+ throw new Error(problem);
2912
+ const dismissed = await this.#dismissPendingQuestions(conversation.id, context3);
2913
+ const submission = await conversation.submit({ type: "input", content: messageContent(text, images), whenBusy: dismissed ? "steer" : whenBusy, requestId }, context3);
2914
+ submission.wait(context3).then((settled) => {
2381
2915
  if (settled.status === "unanswered" && settled.reason !== "aborted") {
2382
2916
  this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
2383
2917
  }
2384
2918
  }, () => {});
2385
2919
  return { submissionId: submission.id };
2386
2920
  }
2921
+ case "renameConversation": {
2922
+ const { title } = args;
2923
+ const trimmed = title.trim();
2924
+ if (trimmed === "")
2925
+ throw new Error("Title cannot be empty");
2926
+ if (trimmed.length > 200)
2927
+ throw new Error("Title is too long (200 characters at most)");
2928
+ await this.#options.harness.commit(async (tx) => {
2929
+ const record = await tx.conversation(conversation.id);
2930
+ if (record === undefined)
2931
+ throw new Error(`Conversation ${conversation.id} does not exist`);
2932
+ if (record.owner !== undefined) {
2933
+ throw new Error("Subagent conversations are named by the call that started them");
2934
+ }
2935
+ const doc = await tx.doc(TitleDoc, conversation.id);
2936
+ doc.title = trimmed;
2937
+ doc.source = "manual";
2938
+ }, context3);
2939
+ return null;
2940
+ }
2387
2941
  case "abort":
2388
- await conversation.abort(context);
2942
+ await conversation.abort(context3);
2389
2943
  return null;
2390
2944
  case "compact": {
2391
2945
  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}.` }), () => {});
2946
+ const taskId = await conversation.compact(instructions, context3);
2947
+ this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
2394
2948
  return { taskId };
2395
2949
  }
2396
2950
  case "setModel": {
@@ -2399,7 +2953,7 @@ class GatewayClient {
2399
2953
  if (model === undefined)
2400
2954
  throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
2401
2955
  const thinking = (await this.#agent(conversation.id)).thinkingLevel ?? "off";
2402
- await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context);
2956
+ await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context3);
2403
2957
  return null;
2404
2958
  }
2405
2959
  case "setThinkingLevel": {
@@ -2411,7 +2965,7 @@ class GatewayClient {
2411
2965
  if (!getSupportedThinkingLevels(model).includes(level)) {
2412
2966
  throw new Error(`Thinking level ${level} is not supported by ${agent.model.provider}/${agent.model.modelId}`);
2413
2967
  }
2414
- await conversation.configure({ thinkingLevel: level }, context);
2968
+ await conversation.configure({ thinkingLevel: level }, context3);
2415
2969
  return null;
2416
2970
  }
2417
2971
  case "fork": {
@@ -2435,13 +2989,13 @@ class GatewayClient {
2435
2989
  }
2436
2990
  cursor = page.next;
2437
2991
  } while (cursor !== undefined);
2438
- }, context);
2439
- const agent = await conversation.agent(context);
2992
+ }, context3);
2993
+ const agent = await conversation.agent(context3);
2440
2994
  const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2441
2995
  const fork = await conversation.fork(at, {
2442
2996
  ownership: { kind: "ownerless" },
2443
2997
  ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
2444
- }, context);
2998
+ }, context3);
2445
2999
  return { conversationId: fork.id };
2446
3000
  });
2447
3001
  }
@@ -2477,16 +3031,16 @@ class GatewayClient {
2477
3031
  }
2478
3032
  request.resolution = { outcome: "answered", answers, at: Date.now() };
2479
3033
  return { first: true };
2480
- }, context);
3034
+ }, context3);
2481
3035
  }
2482
3036
  case "changes": {
2483
- return await this.#changes(conversation, context);
3037
+ return await this.#changes(conversation, context3);
2484
3038
  }
2485
3039
  }
2486
3040
  }
2487
- async#dismissPendingQuestions(conversationId, context2) {
3041
+ async#dismissPendingQuestions(conversationId, context4) {
2488
3042
  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)));
3043
+ const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
2490
3044
  const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
2491
3045
  if (!pending)
2492
3046
  return false;
@@ -2504,18 +3058,18 @@ class GatewayClient {
2504
3058
  }
2505
3059
  }
2506
3060
  return dismissed;
2507
- }, context2);
3061
+ }, context4);
2508
3062
  }
2509
3063
  get #chatsPrefix() {
2510
- return join4(this.#options.dataDir, "chats") + sep3;
3064
+ return join6(this.#options.dataDir, "chats") + sep3;
2511
3065
  }
2512
- async#changes(conversation, context2) {
2513
- const cwd = (await conversation.agent(context2)).cwd;
3066
+ async#changes(conversation, context4) {
3067
+ const cwd = (await conversation.agent(context4)).cwd;
2514
3068
  if (cwd === undefined)
2515
3069
  return { available: false, reason: "No working directory" };
2516
3070
  if (cwd.startsWith(this.#chatsPrefix))
2517
3071
  return { available: false, reason: "Chat conversations have no repository" };
2518
- const index = await this.#options.harness.snapshot(IndexDoc, context2);
3072
+ const index = await this.#options.harness.snapshot(IndexDoc, context4);
2519
3073
  const record = checkoutAt(index?.checkouts, cwd);
2520
3074
  if (record !== undefined) {
2521
3075
  if (!await worktreeExists(record.path))
@@ -2528,7 +3082,7 @@ class GatewayClient {
2528
3082
  return { available: true, ...await changesOf(base.repo, base.base) };
2529
3083
  }
2530
3084
  async#agent(id) {
2531
- return await this.#options.harness.snapshot(AgentDoc3, id, context) ?? {};
3085
+ return await this.#options.harness.snapshot(AgentDoc3, id, context3) ?? {};
2532
3086
  }
2533
3087
  async dispose() {
2534
3088
  if (this.#disposed)
@@ -2542,20 +3096,20 @@ class GatewayClient {
2542
3096
  }
2543
3097
 
2544
3098
  // apps/host/src/host.ts
2545
- var context2 = BACKGROUND_CONTEXT2;
3099
+ var context4 = BACKGROUND_CONTEXT4;
2546
3100
  async function hostToken(dataDir) {
2547
- const path = join5(dataDir, "token");
3101
+ const path = join7(dataDir, "token");
2548
3102
  try {
2549
- return (await readFile2(path, "utf8")).trim();
3103
+ return (await readFile4(path, "utf8")).trim();
2550
3104
  } catch {
2551
- const token = randomBytes2(24).toString("base64url");
2552
- await writeFile(path, `${token}
3105
+ const token = randomBytes3(24).toString("base64url");
3106
+ await writeFile3(path, `${token}
2553
3107
  `, { mode: 384 });
2554
3108
  return token;
2555
3109
  }
2556
3110
  }
2557
3111
  async function openHost(options) {
2558
- await mkdir2(options.dataDir, { recursive: true });
3112
+ await mkdir4(options.dataDir, { recursive: true });
2559
3113
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
2560
3114
  const release = await lockfile.lock(options.dataDir, {
2561
3115
  realpath: false,
@@ -2566,25 +3120,43 @@ async function openHost(options) {
2566
3120
  });
2567
3121
  let harness;
2568
3122
  let mcp;
3123
+ let agentRoles;
3124
+ let extensions = [];
2569
3125
  try {
2570
3126
  const token = await hostToken(options.dataDir);
2571
3127
  const reports = [];
2572
3128
  let report = (error) => void reports.push(error);
2573
3129
  const registry = createRegistry();
2574
- const extensions = typeof options.extensions === "function" ? options.extensions({
2575
- scriptTools: () => mcp?.scriptTools() ?? [],
3130
+ agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
3131
+ extensions = typeof options.extensions === "function" ? options.extensions({
2576
3132
  harness: () => {
2577
3133
  if (harness === undefined)
2578
3134
  throw new Error("harness is not open yet");
2579
3135
  return harness;
2580
- }
3136
+ },
3137
+ agentRoles
2581
3138
  }) : options.extensions ?? [];
2582
3139
  for (const { extension } of extensions)
2583
3140
  registry.install(extension);
2584
- mcp = options.mcpConfig === undefined ? undefined : startMcp({ configPath: options.mcpConfig, registry });
3141
+ mcp = options.mcpConfig === undefined ? undefined : startMcp({
3142
+ configPath: options.mcpConfig,
3143
+ registry,
3144
+ onNamespaces: (namespaces) => {
3145
+ for (const each of extensions) {
3146
+ const rebuilt = each.namespaced?.(namespaces);
3147
+ if (rebuilt !== undefined)
3148
+ registry.install(rebuilt);
3149
+ }
3150
+ }
3151
+ });
3152
+ const hostSettings = await openHostSettings({
3153
+ path: join7(options.dataDir, "settings.json"),
3154
+ fallback: options.settingsFallback,
3155
+ models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
3156
+ });
2585
3157
  const environments = new Map;
2586
- const chatsDir = join5(options.dataDir, "chats");
2587
- harness = await Harness.open(await openNodeSqliteStorage(join5(options.dataDir, "session.sqlite")), {
3158
+ const chatsDir = join7(options.dataDir, "chats");
3159
+ harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join7(options.dataDir, "session.sqlite")), {
2588
3160
  models: options.models,
2589
3161
  registry,
2590
3162
  env: async (target) => {
@@ -2593,15 +3165,15 @@ async function openHost(options) {
2593
3165
  return;
2594
3166
  const worktrees = worktreeRoot(options.dataDir);
2595
3167
  if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
2596
- const index = await target.read.snapshot(IndexDoc, context2);
3168
+ const index = await target.read.snapshot(IndexDoc, context4);
2597
3169
  const record = checkoutAt(index?.checkouts, cwd);
2598
3170
  if (record !== undefined) {
2599
3171
  await ensureWorktree(record);
2600
- await mkdir2(cwd, { recursive: true });
3172
+ await mkdir4(cwd, { recursive: true });
2601
3173
  }
2602
3174
  }
2603
3175
  if (cwd.startsWith(`${chatsDir}/`))
2604
- await mkdir2(cwd, { recursive: true });
3176
+ await mkdir4(cwd, { recursive: true });
2605
3177
  let env = environments.get(cwd);
2606
3178
  if (env === undefined) {
2607
3179
  env = new NodeExecutionEnv({ cwd });
@@ -2609,35 +3181,42 @@ async function openHost(options) {
2609
3181
  }
2610
3182
  return env;
2611
3183
  },
2612
- ...options.settings === undefined ? {} : { settings: options.settings },
3184
+ ...options.settings === undefined ? {} : {
3185
+ settings: typeof options.settings === "function" ? options.settings(hostSettings) : options.settings
3186
+ },
2613
3187
  onReport: (error) => report(error)
2614
- }, context2);
2615
- const { initialModel } = options;
2616
- await ensureIndex(harness, context2);
2617
- await ensureDevices(harness, context2);
3188
+ }, context4);
3189
+ await ensureIndex(harness, context4);
3190
+ await ensureDevices(harness, context4);
2618
3191
  for (const path of options.projects ?? [])
2619
- await addProject(harness, path, context2);
3192
+ await addProject(harness, path, context4);
2620
3193
  const hostKey = await loadHostKey(options.dataDir);
2621
3194
  const offers = pairingOffers(options.remote?.pairingTtlMs);
2622
3195
  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
3196
  const gateway = await startGateway({
2628
3197
  harness,
2629
3198
  models: options.models,
2630
3199
  modelSummaries: options.modelSummaries,
2631
3200
  session: { id: basename2(options.dataDir), directory: options.dataDir },
2632
3201
  dataDir: options.dataDir,
2633
- defaults,
3202
+ settings: hostSettings,
2634
3203
  token,
2635
3204
  port: options.port,
3205
+ agents: agentRoles,
2636
3206
  ...options.browserOrigins === undefined ? {} : { browserOrigins: options.browserOrigins },
2637
3207
  docs: extensions.flatMap((extension) => extension.docs ?? []),
2638
3208
  toolPresentations: Object.assign({}, ...extensions.map((extension) => extension.tools ?? {})),
2639
3209
  ...mcp === undefined ? {} : { mcp: mcp.status },
3210
+ ...options.modelAuth === undefined ? {} : { modelAuth: options.modelAuth },
2640
3211
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
3212
+ ...options.autoTitle === undefined ? {} : { autoTitle: options.autoTitle },
3213
+ ...options.push === undefined ? {} : {
3214
+ push: {
3215
+ hostKey: toBase64Url(hostKey.publicKey),
3216
+ ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
3217
+ ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3218
+ }
3219
+ },
2641
3220
  secure: {
2642
3221
  hostKey,
2643
3222
  offers,
@@ -2668,15 +3247,27 @@ async function openHost(options) {
2668
3247
  url: gateway.url,
2669
3248
  token,
2670
3249
  harness,
3250
+ closed: opened.closed,
3251
+ settings: hostSettings,
3252
+ createAccountOffer: async () => {
3253
+ const binding = await readServerBinding(options.dataDir).catch(() => {
3254
+ return;
3255
+ });
3256
+ return binding === undefined ? undefined : offers.createAccount(binding);
3257
+ },
2671
3258
  ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
2672
3259
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
2673
3260
  close() {
2674
3261
  closing ??= (async () => {
2675
3262
  try {
2676
3263
  await gateway.close();
2677
- await opened.close(context2);
3264
+ await opened.close(context4);
2678
3265
  await mcp?.close();
2679
- await Promise.all([...environments.values()].map((env) => env.cleanup(context2)));
3266
+ agentRoles?.close();
3267
+ for (const each of extensions)
3268
+ await each.close?.();
3269
+ options.modelAuth?.close();
3270
+ await Promise.all([...environments.values()].map((env) => env.cleanup(context4)));
2680
3271
  } finally {
2681
3272
  await release();
2682
3273
  }
@@ -2685,15 +3276,20 @@ async function openHost(options) {
2685
3276
  }
2686
3277
  };
2687
3278
  } catch (error) {
3279
+ options.modelAuth?.close();
3280
+ agentRoles?.close();
3281
+ for (const each of extensions)
3282
+ await each.close?.().catch(() => {});
2688
3283
  await mcp?.close().catch(() => {});
2689
- await harness?.close(context2).catch(() => {});
3284
+ await harness?.close(context4).catch(() => {});
2690
3285
  await release().catch(() => {});
2691
3286
  throw error;
2692
3287
  }
2693
3288
  }
2694
3289
 
2695
- // apps/host/src/main.ts
2696
- import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
3290
+ // apps/host/src/model-auth.ts
3291
+ import { existsSync as existsSync2, watch as watch2 } from "node:fs";
3292
+ import { basename as basename3, dirname as dirname3 } from "node:path";
2697
3293
 
2698
3294
  // apps/host/src/pi-setup.ts
2699
3295
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -2761,7 +3357,7 @@ function configureHarnessHttp(settingsManager) {
2761
3357
  applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
2762
3358
  configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
2763
3359
  }
2764
- function createHarnessSettings(settingsManager) {
3360
+ function createHarnessSettings(settingsManager, modes) {
2765
3361
  return {
2766
3362
  get stream() {
2767
3363
  const provider = settingsManager.getProviderRetrySettings();
@@ -2779,10 +3375,10 @@ function createHarnessSettings(settingsManager) {
2779
3375
  return settingsManager.getRetrySettings();
2780
3376
  },
2781
3377
  get steeringMode() {
2782
- return settingsManager.getSteeringMode();
3378
+ return modes.value.steeringMode;
2783
3379
  },
2784
3380
  get followUpMode() {
2785
- return settingsManager.getFollowUpMode();
3381
+ return modes.value.followUpMode;
2786
3382
  }
2787
3383
  };
2788
3384
  }
@@ -2802,11 +3398,81 @@ function modelSummaries(modelRuntime) {
2802
3398
  modelId: model.id,
2803
3399
  name: model.name,
2804
3400
  contextWindow: model.contextWindow,
2805
- thinkingLevels: getSupportedThinkingLevels2(model)
3401
+ thinkingLevels: getSupportedThinkingLevels2(model),
3402
+ images: model.input.includes("image")
2806
3403
  }));
2807
3404
  }
2808
3405
 
3406
+ // apps/host/src/model-auth.ts
3407
+ 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);
3408
+ var sameLogin = (a, b) => a.providerId === b.providerId && a.name === b.name && a.kind === b.kind && a.subscription === b.subscription;
3409
+ async function storedLogins(modelRuntime) {
3410
+ const credentials = await modelRuntime.listCredentials();
3411
+ const logins = credentials.map((credential) => ({
3412
+ providerId: credential.providerId,
3413
+ name: modelRuntime.getProvider(credential.providerId)?.name ?? credential.providerId,
3414
+ kind: credential.type,
3415
+ subscription: modelRuntime.isUsingSubscription(credential.providerId)
3416
+ }));
3417
+ logins.sort((a, b) => a.providerId.localeCompare(b.providerId));
3418
+ return { logins };
3419
+ }
3420
+ async function watchModelAuth(modelRuntime, options) {
3421
+ const debounceMs = options.debounceMs ?? 150;
3422
+ const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel2));
3423
+ const auth = new Published(await storedLogins(modelRuntime), (a, b) => sameList(a.logins, b.logins, sameLogin));
3424
+ const fileName = basename3(options.authPath);
3425
+ let watcher;
3426
+ let timer;
3427
+ let reported = false;
3428
+ let closed = false;
3429
+ const refresh = async () => {
3430
+ try {
3431
+ await modelRuntime.getAvailable();
3432
+ models.set(modelSummaries(modelRuntime));
3433
+ auth.set(await storedLogins(modelRuntime));
3434
+ reported = false;
3435
+ } catch (error) {
3436
+ if (!reported)
3437
+ console.error(`model-auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
3438
+ reported = true;
3439
+ }
3440
+ };
3441
+ const directory = dirname3(options.authPath);
3442
+ if (existsSync2(directory)) {
3443
+ watcher = watch2(directory, (_event, changed) => {
3444
+ if (closed || changed !== null && changed !== fileName)
3445
+ return;
3446
+ clearTimeout(timer);
3447
+ timer = setTimeout(() => void refresh(), debounceMs);
3448
+ });
3449
+ watcher.on("error", (error) => {
3450
+ if (!reported)
3451
+ console.error(`model-auth watch failed: ${error.message}`);
3452
+ reported = true;
3453
+ });
3454
+ }
3455
+ return {
3456
+ models,
3457
+ auth,
3458
+ async logout(providerId) {
3459
+ if (!auth.value.logins.some((login) => login.providerId === providerId)) {
3460
+ throw new Error(`No stored credential for provider "${providerId}"`);
3461
+ }
3462
+ await modelRuntime.logout(providerId);
3463
+ models.set(modelSummaries(modelRuntime));
3464
+ auth.set(await storedLogins(modelRuntime));
3465
+ },
3466
+ close() {
3467
+ closed = true;
3468
+ clearTimeout(timer);
3469
+ watcher?.close();
3470
+ }
3471
+ };
3472
+ }
3473
+
2809
3474
  // apps/host/src/main.ts
3475
+ import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
2810
3476
  async function main(argv) {
2811
3477
  const { values } = parseArgs({
2812
3478
  args: [...argv],
@@ -2824,31 +3490,34 @@ async function main(argv) {
2824
3490
  }
2825
3491
  });
2826
3492
  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
- };
3493
+ let relayOrigin;
3494
+ try {
3495
+ relayOrigin = resolveRelayOrigin(values.relay, await readServerConfig(dataDir));
3496
+ } catch (error) {
3497
+ console.error(error instanceof Error ? error.message : String(error));
3498
+ process.exit(1);
3499
+ }
3500
+ const extensionsFor = (models, summaries, options2) => (host2) => [
3501
+ createContext({
3502
+ agentsHome: join8(homedir3(), ".agents"),
3503
+ checkout: (input, context5) => checkoutInfo(host2.harness(), input, context5)
3504
+ }),
3505
+ createCoding(options2),
3506
+ todo,
3507
+ question,
3508
+ createSubagent({
3509
+ models,
3510
+ modelSummaries: summaries,
3511
+ exclude: [question.extension],
3512
+ roles: () => host2.agentRoles.value.roles
3513
+ }),
3514
+ createCodemode()
3515
+ ];
2847
3516
  const common = {
2848
3517
  dataDir,
2849
3518
  projects: values.project ?? [],
2850
3519
  port: Number(values.port),
2851
- mcpConfig: join6(homedir2(), ".agents", "mcp.json"),
3520
+ mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
2852
3521
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
2853
3522
  webRoot,
2854
3523
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -2858,7 +3527,7 @@ async function main(argv) {
2858
3527
  ...values["public-url"] === undefined ? {} : { publicUrl: values["public-url"] }
2859
3528
  }
2860
3529
  },
2861
- ...values.relay === undefined ? {} : { relay: { origin: values.relay } }
3530
+ ...relayOrigin === undefined ? {} : { relay: { origin: relayOrigin } }
2862
3531
  };
2863
3532
  function fauxOptions(responses) {
2864
3533
  const faux = fauxProvider({ tokensPerSecond: Number(values["faux-tps"]), tokenSize: { min: 1, max: 1 } });
@@ -2872,7 +3541,8 @@ async function main(argv) {
2872
3541
  modelId: model2.id,
2873
3542
  name: model2.name,
2874
3543
  contextWindow: model2.contextWindow,
2875
- thinkingLevels: getSupportedThinkingLevels3(model2)
3544
+ thinkingLevels: getSupportedThinkingLevels3(model2),
3545
+ images: model2.input.includes("image")
2876
3546
  }
2877
3547
  ];
2878
3548
  return {
@@ -2880,32 +3550,50 @@ async function main(argv) {
2880
3550
  models,
2881
3551
  modelSummaries: summaries,
2882
3552
  extensions: extensionsFor(models, summaries),
2883
- initialModel: { provider: model2.provider, modelId: model2.id }
3553
+ autoTitle: false,
3554
+ settingsFallback: {
3555
+ defaultModel: () => ({ provider: model2.provider, modelId: model2.id }),
3556
+ defaultThinkingLevel: () => null,
3557
+ steeringMode: () => "one-at-a-time",
3558
+ followUpMode: () => "one-at-a-time"
3559
+ }
2884
3560
  };
2885
3561
  }
2886
3562
  async function piOptions() {
2887
3563
  const modelRuntime = await ModelRuntime.create();
3564
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
2888
3565
  const settingsManager = SettingsManager.create(process.cwd());
2889
3566
  configureHarnessHttp(settingsManager);
2890
- const initialModel = defaultModel(settingsManager, modelRuntime);
2891
- const summaries = () => modelSummaries(modelRuntime);
3567
+ const summaries = () => modelAuth.models.value;
3568
+ const images = await createNodePhotonImages();
2892
3569
  return {
2893
3570
  ...common,
2894
3571
  models: modelRuntime,
2895
3572
  modelSummaries: summaries,
2896
- extensions: extensionsFor(modelRuntime, summaries),
2897
- settings: createHarnessSettings(settingsManager),
2898
- ...initialModel === undefined ? {} : { initialModel }
3573
+ modelAuth,
3574
+ extensions: extensionsFor(modelRuntime, summaries, { images }),
3575
+ settings: (store) => createHarnessSettings(settingsManager, store),
3576
+ push: {},
3577
+ settingsFallback: {
3578
+ defaultModel: () => {
3579
+ const model2 = defaultModel(settingsManager, modelRuntime);
3580
+ return model2 === undefined ? null : { provider: model2.provider, modelId: model2.modelId };
3581
+ },
3582
+ defaultThinkingLevel: () => settingsManager.getDefaultThinkingLevel() ?? null,
3583
+ steeringMode: () => settingsManager.getSteeringMode(),
3584
+ followUpMode: () => settingsManager.getFollowUpMode()
3585
+ }
2899
3586
  };
2900
3587
  }
2901
3588
  const options = values.faux !== undefined ? fauxOptions(() => () => fauxAssistantMessage(values.faux)) : await piOptions();
2902
3589
  const host = await openHost(options);
2903
- const model = options.initialModel === undefined ? null : `${options.initialModel.provider}/${options.initialModel.modelId}`;
3590
+ const { defaultModel: effectiveModel } = host.settings.value;
3591
+ const model = effectiveModel === null ? null : `${effectiveModel.provider}/${effectiveModel.modelId}`;
2904
3592
  const web = packagedVersion === undefined ? "http://127.0.0.1:5199/" : new URL(host.url.replace(/^ws/, "http")).href;
2905
3593
  console.log(JSON.stringify({
2906
3594
  event: "ready",
2907
3595
  url: host.url,
2908
- tokenFile: join6(dataDir, "token"),
3596
+ tokenFile: join8(dataDir, "token"),
2909
3597
  dataDir,
2910
3598
  model,
2911
3599
  web,
@@ -2921,6 +3609,15 @@ async function main(argv) {
2921
3609
  };
2922
3610
  process.on("SIGINT", stop);
2923
3611
  process.on("SIGTERM", stop);
3612
+ host.closed.then((end) => {
3613
+ if (end.reason !== "failed" || stopping)
3614
+ return;
3615
+ stopping = true;
3616
+ const error = end.error instanceof Error ? end.error.message : String(end.error);
3617
+ process.stderr.write(`${JSON.stringify({ event: "fatal", error })}
3618
+ `);
3619
+ host.close().finally(() => process.exit(1));
3620
+ });
2924
3621
  }
2925
3622
  if (false)
2926
3623
  ;