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.
- package/README.md +60 -182
- package/dist/{chunk-n4bvmcy8.js → chunk-17p6bfvy.js} +4 -4
- package/dist/chunk-302y6by9.js +50 -0
- package/dist/{chunk-5h97zxag.js → chunk-374zqx5h.js} +150 -121
- package/dist/chunk-5785kwej.js +128 -0
- package/dist/{chunk-z4x88s0q.js → chunk-7gmkgyaz.js} +76 -3
- package/dist/chunk-837gh8nr.js +138 -0
- package/dist/{chunk-d9q0dz8s.js → chunk-akgxwg8f.js} +98 -6
- package/dist/chunk-b3f0ced1.js +147 -0
- package/dist/{chunk-q26n39mf.js → chunk-d4y99pfy.js} +2 -2
- package/dist/{chunk-rkfdsacq.js → chunk-fgx2sh7d.js} +1 -1
- package/dist/chunk-knaw1a9g.js +182 -0
- package/dist/chunk-myjp910s.js +253 -0
- package/dist/{chunk-haycqqp1.js → chunk-n73t5xzw.js} +1 -1
- package/dist/{chunk-5712fvyp.js → chunk-prhvksh3.js} +4 -4
- package/dist/chunk-vw5yb5pd.js +1123 -0
- package/dist/chunk-xhwkx9bd.js +139 -0
- package/dist/{chunk-7fqn69ys.js → chunk-xrvmnymc.js} +2 -2
- package/dist/{chunk-fhtkkcgt.js → chunk-z5pmcscx.js} +1491 -794
- package/dist/pinomad.js +14 -9
- package/package.json +7 -7
- package/web/assets/index-8mNNPXPT.js +383 -0
- package/web/assets/{index-KwICnYux.css → index-BFJJgGGo.css} +1 -1
- package/web/index.html +2 -2
- package/dist/chunk-fxaewke2.js +0 -113
- package/dist/chunk-h35fermn.js +0 -92
- package/dist/chunk-qdxhqva8.js +0 -284
- package/web/assets/index-4p91UVoc.js +0 -383
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
|
45
|
-
import { join as
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 {
|
|
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
|
-
|
|
247
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
268
|
+
extension: defineExtension2({
|
|
629
269
|
name: "context",
|
|
630
270
|
sections: [
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
703
|
-
defineTool as
|
|
342
|
+
defineExtension as defineExtension3,
|
|
343
|
+
defineTool as defineTool2,
|
|
344
|
+
section as section2
|
|
704
345
|
} from "@earendil-works/pi-durable";
|
|
705
|
-
import { Type as
|
|
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
|
|
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
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 (
|
|
751
|
-
const slash =
|
|
752
|
-
const provider = slash === -1 ? "" :
|
|
753
|
-
const modelId = 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 "${
|
|
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 (
|
|
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 (
|
|
764
|
-
thinkingLevel = effective === undefined ?
|
|
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
|
-
|
|
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 =
|
|
812
|
-
|
|
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
|
|
817
|
-
import { Type as
|
|
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 =
|
|
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:
|
|
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 {
|
|
518
|
+
return { output: [{ type: "text", text: `Todo list updated: ${args.items.length} item(s).` }] };
|
|
837
519
|
}
|
|
838
520
|
});
|
|
839
521
|
var todo = {
|
|
840
|
-
extension:
|
|
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
|
|
846
|
-
import { mkdir as
|
|
847
|
-
import { basename as basename2, join as
|
|
848
|
-
import { BACKGROUND_CONTEXT as
|
|
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
|
|
1170
|
-
import { Type as
|
|
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
|
|
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
|
-
|
|
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.
|
|
1317
|
-
|
|
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
|
-
|
|
1337
|
-
options.
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
2160
|
+
origin: relayOrigin,
|
|
1731
2161
|
signingKey: secure.relay.signingKey,
|
|
1732
|
-
|
|
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 ===
|
|
2216
|
+
return socket.readyState === WebSocket.OPEN;
|
|
1769
2217
|
},
|
|
1770
|
-
send: (text) => socket.readyState ===
|
|
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 ===
|
|
2229
|
+
return socket.readyState === WebSocket.OPEN;
|
|
1782
2230
|
},
|
|
1783
|
-
send: (text) => socket.readyState ===
|
|
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,
|
|
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
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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 {
|
|
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,
|
|
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),
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
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.#
|
|
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.
|
|
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
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
return { stop:
|
|
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
|
|
2226
|
-
if (
|
|
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,
|
|
2716
|
+
return this.#forward(stream, watch2, send);
|
|
2229
2717
|
}
|
|
2230
2718
|
if (stream === "devices") {
|
|
2231
|
-
const
|
|
2232
|
-
if (
|
|
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,
|
|
2722
|
+
return this.#forward(stream, watch2, send);
|
|
2235
2723
|
}
|
|
2236
2724
|
if (stream === "tasks")
|
|
2237
|
-
return this.#forward(stream, await this.#options.harness.watchTaskGraph(
|
|
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(
|
|
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,
|
|
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
|
|
2285
|
-
if (
|
|
2770
|
+
const watch2 = await harness.watchDoc(token, id, context3);
|
|
2771
|
+
if (watch2 === undefined)
|
|
2286
2772
|
return;
|
|
2287
2773
|
if (stopped)
|
|
2288
|
-
await
|
|
2774
|
+
await watch2.stop();
|
|
2289
2775
|
else {
|
|
2290
|
-
inner = await this.#forward(stream,
|
|
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,
|
|
2308
|
-
await send({ type: "snapshot", stream, value:
|
|
2309
|
-
|
|
2310
|
-
return
|
|
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,
|
|
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,
|
|
2820
|
+
return addProject(this.#options.harness, args.path, context3);
|
|
2335
2821
|
case "removeProject":
|
|
2336
|
-
await removeProject(this.#options.harness, args.path,
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
2379
|
-
|
|
2380
|
-
|
|
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(
|
|
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,
|
|
2393
|
-
this.#options.harness.waitForTask(taskId,
|
|
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) },
|
|
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 },
|
|
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
|
-
},
|
|
2439
|
-
const agent = await conversation.agent(
|
|
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
|
-
},
|
|
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
|
-
},
|
|
3034
|
+
}, context3);
|
|
2481
3035
|
}
|
|
2482
3036
|
case "changes": {
|
|
2483
|
-
return await this.#changes(conversation,
|
|
3037
|
+
return await this.#changes(conversation, context3);
|
|
2484
3038
|
}
|
|
2485
3039
|
}
|
|
2486
3040
|
}
|
|
2487
|
-
async#dismissPendingQuestions(conversationId,
|
|
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,
|
|
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
|
-
},
|
|
3061
|
+
}, context4);
|
|
2508
3062
|
}
|
|
2509
3063
|
get #chatsPrefix() {
|
|
2510
|
-
return
|
|
3064
|
+
return join6(this.#options.dataDir, "chats") + sep3;
|
|
2511
3065
|
}
|
|
2512
|
-
async#changes(conversation,
|
|
2513
|
-
const cwd = (await conversation.agent(
|
|
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,
|
|
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,
|
|
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
|
|
3099
|
+
var context4 = BACKGROUND_CONTEXT4;
|
|
2546
3100
|
async function hostToken(dataDir) {
|
|
2547
|
-
const path =
|
|
3101
|
+
const path = join7(dataDir, "token");
|
|
2548
3102
|
try {
|
|
2549
|
-
return (await
|
|
3103
|
+
return (await readFile4(path, "utf8")).trim();
|
|
2550
3104
|
} catch {
|
|
2551
|
-
const token =
|
|
2552
|
-
await
|
|
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
|
|
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
|
-
|
|
2575
|
-
|
|
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({
|
|
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 =
|
|
2587
|
-
harness = await Harness.open(await openNodeSqliteStorage(
|
|
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,
|
|
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
|
|
3172
|
+
await mkdir4(cwd, { recursive: true });
|
|
2601
3173
|
}
|
|
2602
3174
|
}
|
|
2603
3175
|
if (cwd.startsWith(`${chatsDir}/`))
|
|
2604
|
-
await
|
|
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 ? {} : {
|
|
3184
|
+
...options.settings === undefined ? {} : {
|
|
3185
|
+
settings: typeof options.settings === "function" ? options.settings(hostSettings) : options.settings
|
|
3186
|
+
},
|
|
2613
3187
|
onReport: (error) => report(error)
|
|
2614
|
-
},
|
|
2615
|
-
|
|
2616
|
-
await
|
|
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,
|
|
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
|
-
|
|
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(
|
|
3264
|
+
await opened.close(context4);
|
|
2678
3265
|
await mcp?.close();
|
|
2679
|
-
|
|
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(
|
|
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/
|
|
2696
|
-
import {
|
|
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
|
|
3378
|
+
return modes.value.steeringMode;
|
|
2783
3379
|
},
|
|
2784
3380
|
get followUpMode() {
|
|
2785
|
-
return
|
|
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
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
2891
|
-
const
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
|
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:
|
|
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
|
;
|