@jyork0828/pi-pilot 0.0.5 → 0.0.7
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/dist/index.js +3023 -635
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/public/assets/ArtifactsPanel-DUiRwx7S.js +1 -0
- package/public/assets/ResourcesPanel-Cn_gw159.js +19 -0
- package/public/assets/SessionTree-CBIw_kzf.js +16 -0
- package/public/assets/SettingsPage-cULKjgtu.js +51 -0
- package/public/assets/index-CX2ohSDO.js +238 -0
- package/public/assets/index-CyoTMDCN.css +1 -0
- package/public/assets/markdown-CY-Rm0E5.js +45 -0
- package/public/assets/shiki-BZ0sbaMe.js +152 -0
- package/public/index.html +12 -3
- package/public/assets/index-CBa7EReb.js +0 -411
- package/public/assets/index-DeSNeuE1.css +0 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync } from "fs";
|
|
5
|
-
import { readFile as
|
|
6
|
-
import { dirname as dirname6, extname, join as
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
4
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
6
|
+
import { dirname as dirname6, extname, join as join16, resolve as resolve7, sep as sep3 } from "path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8
8
|
import { serve } from "@hono/node-server";
|
|
9
|
-
import { Hono as
|
|
9
|
+
import { Hono as Hono6 } from "hono";
|
|
10
10
|
import { cors } from "hono/cors";
|
|
11
11
|
|
|
12
12
|
// src/config.ts
|
|
@@ -41,9 +41,9 @@ function configureHttpProxy() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// src/api/workspaces.ts
|
|
44
|
-
import { stat as stat2 } from "fs/promises";
|
|
45
|
-
import { basename as basename2, isAbsolute as isAbsolute3, resolve as
|
|
46
|
-
import { Hono } from "hono";
|
|
44
|
+
import { readFile as readFile7, stat as stat2 } from "fs/promises";
|
|
45
|
+
import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve5 } from "path";
|
|
46
|
+
import { Hono as Hono2 } from "hono";
|
|
47
47
|
|
|
48
48
|
// src/storage/resource-writer.ts
|
|
49
49
|
import {
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
unlink,
|
|
55
55
|
writeFile
|
|
56
56
|
} from "fs/promises";
|
|
57
|
-
import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
57
|
+
import { basename, dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
58
58
|
var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
59
59
|
var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
60
60
|
function ensureSkillName(name) {
|
|
@@ -277,9 +277,9 @@ async function updatePrompt(opts) {
|
|
|
277
277
|
await writeFile(newPath, text, "utf8");
|
|
278
278
|
try {
|
|
279
279
|
await unlink(opts.filePath);
|
|
280
|
-
} catch (
|
|
280
|
+
} catch (err2) {
|
|
281
281
|
await unlink(newPath).catch(() => void 0);
|
|
282
|
-
throw
|
|
282
|
+
throw err2;
|
|
283
283
|
}
|
|
284
284
|
return newPath;
|
|
285
285
|
}
|
|
@@ -311,7 +311,7 @@ async function readPromptFile(filePath, roots) {
|
|
|
311
311
|
assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
|
|
312
312
|
const text = await readFile(filePath, "utf8");
|
|
313
313
|
const { frontmatter, body } = parseFile(text);
|
|
314
|
-
const stem = basename(filePath
|
|
314
|
+
const stem = basename(filePath, ".md");
|
|
315
315
|
return {
|
|
316
316
|
body,
|
|
317
317
|
name: stem,
|
|
@@ -319,10 +319,6 @@ async function readPromptFile(filePath, roots) {
|
|
|
319
319
|
argumentHint: stringOr(frontmatter["argument-hint"], void 0)
|
|
320
320
|
};
|
|
321
321
|
}
|
|
322
|
-
function basename(p) {
|
|
323
|
-
const parts = p.split(sep);
|
|
324
|
-
return parts.at(-1) || p;
|
|
325
|
-
}
|
|
326
322
|
function stringOr(value, fallback) {
|
|
327
323
|
return typeof value === "string" ? value : fallback;
|
|
328
324
|
}
|
|
@@ -338,38 +334,72 @@ async function exists(p) {
|
|
|
338
334
|
}
|
|
339
335
|
}
|
|
340
336
|
var HttpError = class extends Error {
|
|
341
|
-
constructor(
|
|
337
|
+
constructor(status2, message) {
|
|
342
338
|
super(message);
|
|
343
|
-
this.status =
|
|
339
|
+
this.status = status2;
|
|
344
340
|
}
|
|
345
341
|
status;
|
|
346
342
|
};
|
|
347
343
|
|
|
348
344
|
// src/storage/workspace-registry.ts
|
|
349
|
-
import {
|
|
350
|
-
import {
|
|
345
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
346
|
+
import { join as join3 } from "path";
|
|
347
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
348
|
+
|
|
349
|
+
// src/storage/atomic-json.ts
|
|
350
|
+
import { chmod, mkdir as mkdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
351
|
+
import { dirname as dirname2 } from "path";
|
|
351
352
|
import { randomUUID } from "crypto";
|
|
353
|
+
async function writeJsonAtomic(filePath, data, opts) {
|
|
354
|
+
await mkdir2(dirname2(filePath), { recursive: true });
|
|
355
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
356
|
+
const text = JSON.stringify(data, null, 2);
|
|
357
|
+
try {
|
|
358
|
+
if (opts?.mode !== void 0) {
|
|
359
|
+
await writeFile2(tmp, text, { encoding: "utf8", mode: opts.mode });
|
|
360
|
+
} else {
|
|
361
|
+
await writeFile2(tmp, text, "utf8");
|
|
362
|
+
}
|
|
363
|
+
await rename(tmp, filePath);
|
|
364
|
+
} catch (err2) {
|
|
365
|
+
await rm2(tmp, { force: true }).catch(() => {
|
|
366
|
+
});
|
|
367
|
+
throw err2;
|
|
368
|
+
}
|
|
369
|
+
if (opts?.mode !== void 0) {
|
|
370
|
+
try {
|
|
371
|
+
await chmod(filePath, opts.mode);
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/storage/workspace-registry.ts
|
|
352
378
|
var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
|
|
353
379
|
var cache;
|
|
380
|
+
var writeChain = Promise.resolve();
|
|
381
|
+
function serializedWrite(fn) {
|
|
382
|
+
writeChain = writeChain.then(fn, fn);
|
|
383
|
+
return writeChain;
|
|
384
|
+
}
|
|
354
385
|
async function load() {
|
|
355
386
|
if (cache) return cache;
|
|
356
387
|
try {
|
|
357
388
|
const raw = await readFile2(REGISTRY_PATH, "utf8");
|
|
358
389
|
cache = JSON.parse(raw);
|
|
359
390
|
if (!Array.isArray(cache.workspaces)) cache = { workspaces: [] };
|
|
360
|
-
} catch (
|
|
361
|
-
if (
|
|
391
|
+
} catch (err2) {
|
|
392
|
+
if (err2.code === "ENOENT") {
|
|
362
393
|
cache = { workspaces: [] };
|
|
363
394
|
} else {
|
|
364
|
-
throw
|
|
395
|
+
throw err2;
|
|
365
396
|
}
|
|
366
397
|
}
|
|
367
398
|
return cache;
|
|
368
399
|
}
|
|
369
400
|
async function save() {
|
|
370
401
|
if (!cache) return;
|
|
371
|
-
await
|
|
372
|
-
await writeFile2(REGISTRY_PATH, JSON.stringify(cache, null, 2), "utf8");
|
|
402
|
+
await writeJsonAtomic(REGISTRY_PATH, cache);
|
|
373
403
|
}
|
|
374
404
|
async function listWorkspaces() {
|
|
375
405
|
const r = await load();
|
|
@@ -380,26 +410,70 @@ async function getWorkspace(id) {
|
|
|
380
410
|
return r.workspaces.find((w) => w.id === id);
|
|
381
411
|
}
|
|
382
412
|
async function addWorkspace(input) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
let result;
|
|
414
|
+
await serializedWrite(async () => {
|
|
415
|
+
const r = await load();
|
|
416
|
+
const existing = r.workspaces.find((w) => w.path === input.path);
|
|
417
|
+
if (existing) {
|
|
418
|
+
result = existing;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const ws = {
|
|
422
|
+
id: randomUUID2(),
|
|
423
|
+
name: input.name,
|
|
424
|
+
path: input.path,
|
|
425
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
426
|
+
};
|
|
427
|
+
r.workspaces.push(ws);
|
|
428
|
+
await save();
|
|
429
|
+
result = ws;
|
|
430
|
+
});
|
|
431
|
+
return result;
|
|
395
432
|
}
|
|
396
433
|
async function removeWorkspace(id) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
434
|
+
let removed = false;
|
|
435
|
+
await serializedWrite(async () => {
|
|
436
|
+
const r = await load();
|
|
437
|
+
const before = r.workspaces.length;
|
|
438
|
+
r.workspaces = r.workspaces.filter((w) => w.id !== id);
|
|
439
|
+
if (r.workspaces.length === before) return;
|
|
440
|
+
removed = true;
|
|
441
|
+
await save();
|
|
442
|
+
});
|
|
443
|
+
return removed;
|
|
444
|
+
}
|
|
445
|
+
async function setWorkspaceTrustProjectAgents(id, trusted) {
|
|
446
|
+
let updated;
|
|
447
|
+
await serializedWrite(async () => {
|
|
448
|
+
const r = await load();
|
|
449
|
+
const ws = r.workspaces.find((w) => w.id === id);
|
|
450
|
+
if (!ws) return;
|
|
451
|
+
if (trusted) ws.trustProjectAgents = true;
|
|
452
|
+
else delete ws.trustProjectAgents;
|
|
453
|
+
await save();
|
|
454
|
+
updated = ws;
|
|
455
|
+
});
|
|
456
|
+
return updated;
|
|
457
|
+
}
|
|
458
|
+
async function reorderWorkspaces(ids) {
|
|
459
|
+
await serializedWrite(async () => {
|
|
460
|
+
const r = await load();
|
|
461
|
+
const byId = new Map(r.workspaces.map((w) => [w.id, w]));
|
|
462
|
+
const reordered = [];
|
|
463
|
+
const seen = /* @__PURE__ */ new Set();
|
|
464
|
+
for (const id of ids) {
|
|
465
|
+
const ws = byId.get(id);
|
|
466
|
+
if (ws && !seen.has(id)) {
|
|
467
|
+
reordered.push(ws);
|
|
468
|
+
seen.add(id);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const ws of r.workspaces) {
|
|
472
|
+
if (!seen.has(ws.id)) reordered.push(ws);
|
|
473
|
+
}
|
|
474
|
+
r.workspaces = reordered;
|
|
475
|
+
await save();
|
|
476
|
+
});
|
|
403
477
|
}
|
|
404
478
|
|
|
405
479
|
// src/storage/workspace-stats.ts
|
|
@@ -417,13 +491,14 @@ async function enrichWorkspace(ws) {
|
|
|
417
491
|
path: ws.path,
|
|
418
492
|
addedAt: ws.addedAt,
|
|
419
493
|
gitBranch: stats.gitBranch,
|
|
420
|
-
fileCount: stats.fileCount
|
|
494
|
+
fileCount: stats.fileCount,
|
|
495
|
+
trustProjectAgents: ws.trustProjectAgents === true
|
|
421
496
|
};
|
|
422
497
|
}
|
|
423
498
|
async function getStats(path) {
|
|
424
499
|
const now = Date.now();
|
|
425
|
-
const
|
|
426
|
-
if (
|
|
500
|
+
const cached2 = cache2.get(path);
|
|
501
|
+
if (cached2 && cached2.expiresAt > now) return cached2;
|
|
427
502
|
const pending2 = inflight.get(path);
|
|
428
503
|
if (pending2) return pending2;
|
|
429
504
|
const probe = probeStats(path).then((stats) => {
|
|
@@ -493,66 +568,332 @@ async function runGit(cwd, args) {
|
|
|
493
568
|
|
|
494
569
|
// src/workspace-manager.ts
|
|
495
570
|
import { unlink as unlink2 } from "fs/promises";
|
|
496
|
-
import { isAbsolute as isAbsolute2, resolve as
|
|
571
|
+
import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
|
|
497
572
|
import {
|
|
498
573
|
createAgentSessionFromServices,
|
|
499
574
|
createAgentSessionRuntime,
|
|
500
575
|
createAgentSessionServices,
|
|
501
|
-
getAgentDir,
|
|
576
|
+
getAgentDir as getAgentDir2,
|
|
502
577
|
SessionManager
|
|
503
578
|
} from "@earendil-works/pi-coding-agent";
|
|
504
579
|
|
|
505
|
-
// src/
|
|
580
|
+
// src/storage/session-tool-prefs.ts
|
|
581
|
+
import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
|
|
582
|
+
import { dirname as dirname3, join as join4, resolve as resolve2 } from "path";
|
|
583
|
+
var PREFS_PATH = join4(config.dataDir, "session-tools.json");
|
|
584
|
+
var cache3 = { sessions: {} };
|
|
585
|
+
async function loadSessionToolPrefs() {
|
|
586
|
+
try {
|
|
587
|
+
const raw = await readFile3(PREFS_PATH, "utf8");
|
|
588
|
+
const parsed = JSON.parse(raw);
|
|
589
|
+
cache3 = { sessions: normalizeSessions(parsed.sessions) };
|
|
590
|
+
} catch (err2) {
|
|
591
|
+
cache3 = { sessions: {} };
|
|
592
|
+
if (err2.code !== "ENOENT") {
|
|
593
|
+
console.warn(`[session-tool-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function keyOf(workspaceId, session) {
|
|
598
|
+
return session.sessionFile ? resolve2(session.sessionFile) : `${workspaceId}:${session.sessionId}`;
|
|
599
|
+
}
|
|
600
|
+
function storedDisabled(workspaceId, session) {
|
|
601
|
+
return cache3.sessions[keyOf(workspaceId, session)]?.disabled ?? [];
|
|
602
|
+
}
|
|
603
|
+
async function persistActiveTools(workspaceId, session, activeNames) {
|
|
604
|
+
const registered = session.getAllTools().map((t) => t.name);
|
|
605
|
+
const registeredSet = new Set(registered);
|
|
606
|
+
const active = new Set(activeNames);
|
|
607
|
+
const disabled = /* @__PURE__ */ new Set();
|
|
608
|
+
for (const name of registered) {
|
|
609
|
+
if (!active.has(name)) disabled.add(name);
|
|
610
|
+
}
|
|
611
|
+
for (const name of storedDisabled(workspaceId, session)) {
|
|
612
|
+
if (!registeredSet.has(name)) disabled.add(name);
|
|
613
|
+
}
|
|
614
|
+
cache3 = {
|
|
615
|
+
sessions: {
|
|
616
|
+
...cache3.sessions,
|
|
617
|
+
[keyOf(workspaceId, session)]: {
|
|
618
|
+
disabled: sortUnique(disabled),
|
|
619
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
await save2();
|
|
624
|
+
session.setActiveToolsByName(registered.filter((name) => !disabled.has(name)));
|
|
625
|
+
}
|
|
626
|
+
function reapplyToolPrefs(workspaceId, session) {
|
|
627
|
+
const disabled = storedDisabled(workspaceId, session);
|
|
628
|
+
if (disabled.length === 0) return;
|
|
629
|
+
const disabledSet = new Set(disabled);
|
|
630
|
+
const registered = session.getAllTools().map((t) => t.name);
|
|
631
|
+
session.setActiveToolsByName(registered.filter((name) => !disabledSet.has(name)));
|
|
632
|
+
}
|
|
633
|
+
async function forgetSessionToolPrefs(sessionPath) {
|
|
634
|
+
const key = resolve2(sessionPath);
|
|
635
|
+
if (!(key in cache3.sessions)) return;
|
|
636
|
+
const next = { ...cache3.sessions };
|
|
637
|
+
delete next[key];
|
|
638
|
+
cache3 = { sessions: next };
|
|
639
|
+
await save2();
|
|
640
|
+
}
|
|
641
|
+
function sortUnique(values) {
|
|
642
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
643
|
+
}
|
|
644
|
+
function normalizeSessions(value) {
|
|
645
|
+
if (!value || typeof value !== "object") return {};
|
|
646
|
+
const out = {};
|
|
647
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
648
|
+
if (!entry || typeof entry !== "object") continue;
|
|
649
|
+
const disabled = entry.disabled;
|
|
650
|
+
if (!Array.isArray(disabled)) continue;
|
|
651
|
+
const updatedAt = entry.updatedAt;
|
|
652
|
+
out[key] = {
|
|
653
|
+
disabled: sortUnique(disabled.filter((n) => typeof n === "string")),
|
|
654
|
+
updatedAt: typeof updatedAt === "string" ? updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
var writeChain2 = Promise.resolve();
|
|
660
|
+
function save2() {
|
|
661
|
+
writeChain2 = writeChain2.catch(() => {
|
|
662
|
+
}).then(async () => {
|
|
663
|
+
await mkdir3(dirname3(PREFS_PATH), { recursive: true });
|
|
664
|
+
const tmp = `${PREFS_PATH}.tmp`;
|
|
665
|
+
await writeFile3(tmp, JSON.stringify(cache3, null, 2), "utf8");
|
|
666
|
+
await rename2(tmp, PREFS_PATH);
|
|
667
|
+
});
|
|
668
|
+
return writeChain2;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/extensions/todo/schema.ts
|
|
506
672
|
import { Type } from "typebox";
|
|
507
|
-
var
|
|
673
|
+
var EMPTY_STATE = { tasks: [], nextId: 1 };
|
|
674
|
+
var VALID_TRANSITIONS = {
|
|
675
|
+
pending: /* @__PURE__ */ new Set(["in_progress", "completed", "deleted"]),
|
|
676
|
+
in_progress: /* @__PURE__ */ new Set(["pending", "completed", "deleted"]),
|
|
677
|
+
completed: /* @__PURE__ */ new Set(["deleted"]),
|
|
678
|
+
deleted: /* @__PURE__ */ new Set()
|
|
679
|
+
};
|
|
680
|
+
function isTransitionValid(from, to) {
|
|
681
|
+
if (from === to) return true;
|
|
682
|
+
return VALID_TRANSITIONS[from].has(to);
|
|
683
|
+
}
|
|
684
|
+
var ActionEnum = Type.Union([
|
|
685
|
+
Type.Literal("create"),
|
|
686
|
+
Type.Literal("update"),
|
|
687
|
+
Type.Literal("list"),
|
|
688
|
+
Type.Literal("get"),
|
|
689
|
+
Type.Literal("delete"),
|
|
690
|
+
Type.Literal("clear")
|
|
691
|
+
]);
|
|
692
|
+
var StatusEnum = Type.Union([
|
|
508
693
|
Type.Literal("pending"),
|
|
509
694
|
Type.Literal("in_progress"),
|
|
510
|
-
Type.Literal("completed")
|
|
695
|
+
Type.Literal("completed"),
|
|
696
|
+
Type.Literal("deleted")
|
|
511
697
|
]);
|
|
512
|
-
var
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
})
|
|
698
|
+
var todoParamsSchema = Type.Object({
|
|
699
|
+
action: ActionEnum,
|
|
700
|
+
subject: Type.Optional(Type.String({ description: "Task subject line (required for create)" })),
|
|
701
|
+
description: Type.Optional(Type.String({ description: "Long-form task description" })),
|
|
702
|
+
status: Type.Optional(StatusEnum),
|
|
703
|
+
id: Type.Optional(Type.Number({ description: "Task id (required for update, get, delete)" })),
|
|
704
|
+
includeDeleted: Type.Optional(Type.Boolean({
|
|
705
|
+
description: "If true, list action returns deleted (tombstoned) tasks as well. Default: false."
|
|
706
|
+
}))
|
|
522
707
|
});
|
|
523
708
|
|
|
524
|
-
// src/extensions/
|
|
525
|
-
|
|
709
|
+
// src/extensions/todo/reducer.ts
|
|
710
|
+
function err(state, message) {
|
|
711
|
+
return { state, text: `Error: ${message}`, error: message };
|
|
712
|
+
}
|
|
713
|
+
function formatListLine(t) {
|
|
714
|
+
return `[${t.status}] #${t.id} ${t.subject}`;
|
|
715
|
+
}
|
|
716
|
+
function applyTodoAction(state, action, params) {
|
|
717
|
+
switch (action) {
|
|
718
|
+
case "create": {
|
|
719
|
+
if (!params.subject?.trim()) {
|
|
720
|
+
return err(state, "subject required for create");
|
|
721
|
+
}
|
|
722
|
+
const task = {
|
|
723
|
+
id: state.nextId,
|
|
724
|
+
subject: params.subject,
|
|
725
|
+
status: "pending"
|
|
726
|
+
};
|
|
727
|
+
if (params.description) task.description = params.description;
|
|
728
|
+
const newTasks = [...state.tasks, task];
|
|
729
|
+
return {
|
|
730
|
+
state: { tasks: newTasks, nextId: state.nextId + 1 },
|
|
731
|
+
text: `Created #${task.id}: ${task.subject} (pending)`
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
case "update": {
|
|
735
|
+
if (params.id === void 0) return err(state, "id required for update");
|
|
736
|
+
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
737
|
+
if (idx === -1) return err(state, `#${params.id} not found`);
|
|
738
|
+
const current = state.tasks[idx];
|
|
739
|
+
const hasMutation = params.subject !== void 0 || params.description !== void 0 || params.status !== void 0;
|
|
740
|
+
if (!hasMutation) return err(state, "update requires at least one mutable field");
|
|
741
|
+
let newStatus = current.status;
|
|
742
|
+
if (params.status !== void 0) {
|
|
743
|
+
if (!isTransitionValid(current.status, params.status)) {
|
|
744
|
+
return err(state, `illegal transition ${current.status} \u2192 ${params.status}`);
|
|
745
|
+
}
|
|
746
|
+
newStatus = params.status;
|
|
747
|
+
}
|
|
748
|
+
const updated = { ...current, status: newStatus };
|
|
749
|
+
if (params.subject !== void 0) updated.subject = params.subject;
|
|
750
|
+
if (params.description !== void 0) updated.description = params.description;
|
|
751
|
+
const newTasks = [...state.tasks];
|
|
752
|
+
newTasks[idx] = updated;
|
|
753
|
+
const transition = current.status !== newStatus ? ` (${current.status} \u2192 ${newStatus})` : "";
|
|
754
|
+
return {
|
|
755
|
+
state: { tasks: newTasks, nextId: state.nextId },
|
|
756
|
+
text: `Updated #${updated.id}${transition}`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
case "list": {
|
|
760
|
+
let view = state.tasks;
|
|
761
|
+
if (!params.includeDeleted) view = view.filter((t) => t.status !== "deleted");
|
|
762
|
+
if (params.status) view = view.filter((t) => t.status === params.status);
|
|
763
|
+
return {
|
|
764
|
+
state,
|
|
765
|
+
text: view.length === 0 ? "No tasks" : view.map(formatListLine).join("\n")
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
case "get": {
|
|
769
|
+
if (params.id === void 0) return err(state, "id required for get");
|
|
770
|
+
const task = state.tasks.find((t) => t.id === params.id);
|
|
771
|
+
if (!task) return err(state, `#${params.id} not found`);
|
|
772
|
+
const lines = [`#${task.id} [${task.status}] ${task.subject}`];
|
|
773
|
+
if (task.description) lines.push(` description: ${task.description}`);
|
|
774
|
+
return { state, text: lines.join("\n") };
|
|
775
|
+
}
|
|
776
|
+
case "delete": {
|
|
777
|
+
if (params.id === void 0) return err(state, "id required for delete");
|
|
778
|
+
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
779
|
+
if (idx === -1) return err(state, `#${params.id} not found`);
|
|
780
|
+
const current = state.tasks[idx];
|
|
781
|
+
if (current.status === "deleted") return err(state, `#${current.id} is already deleted`);
|
|
782
|
+
const updated = { ...current, status: "deleted" };
|
|
783
|
+
const newTasks = [...state.tasks];
|
|
784
|
+
newTasks[idx] = updated;
|
|
785
|
+
return {
|
|
786
|
+
state: { tasks: newTasks, nextId: state.nextId },
|
|
787
|
+
text: `Deleted #${updated.id}: ${updated.subject}`
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
case "clear": {
|
|
791
|
+
const count = state.tasks.length;
|
|
792
|
+
return {
|
|
793
|
+
state: { tasks: [], nextId: 1 },
|
|
794
|
+
text: `Cleared ${count} tasks`
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/extensions/todo/factory.ts
|
|
801
|
+
var TOOL_NAME = "todo";
|
|
802
|
+
function replayFromBranch(ctx) {
|
|
803
|
+
let result = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
|
|
804
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
805
|
+
const e = entry;
|
|
806
|
+
if (e.type !== "message") continue;
|
|
807
|
+
const msg = e.message;
|
|
808
|
+
if (msg?.role !== "toolResult" || msg.toolName !== TOOL_NAME) continue;
|
|
809
|
+
if (msg.isError) continue;
|
|
810
|
+
const details = msg.details;
|
|
811
|
+
if (!details || !Array.isArray(details.tasks) || typeof details.nextId !== "number") continue;
|
|
812
|
+
if (details.error) continue;
|
|
813
|
+
result = {
|
|
814
|
+
tasks: details.tasks.map((t) => ({ ...t })),
|
|
815
|
+
nextId: details.nextId
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return result;
|
|
819
|
+
}
|
|
820
|
+
var todoExtensionFactory = (pi) => {
|
|
821
|
+
let state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
|
|
526
822
|
pi.registerTool({
|
|
527
|
-
name:
|
|
528
|
-
label: "
|
|
529
|
-
description: "
|
|
530
|
-
|
|
531
|
-
promptSnippet: "update_plan: maintain a live checklist for multi-step tasks; update statuses as you progress.",
|
|
823
|
+
name: TOOL_NAME,
|
|
824
|
+
label: "Todo",
|
|
825
|
+
description: "Manage a task list for tracking multi-step progress. Actions: create (new task), update (change status/fields), list (all tasks, optionally filtered), get (single task), delete (tombstone), clear (reset). Status: pending \u2192 in_progress \u2192 completed, plus deleted tombstone.",
|
|
826
|
+
promptSnippet: "Manage a task list to track multi-step progress.",
|
|
532
827
|
promptGuidelines: [
|
|
533
|
-
"
|
|
534
|
-
"
|
|
535
|
-
|
|
536
|
-
"Exactly one item should be in_progress at a time. Mark completed only when the work is actually done."
|
|
828
|
+
"Use `todo` for complex work with 3+ steps or when the user gives you an explicit list of tasks. Skip it for single trivial tasks and purely conversational requests.",
|
|
829
|
+
"When starting any task, mark it in_progress BEFORE beginning work. Mark it completed IMMEDIATELY when done \u2014 never batch completions. Exactly one task should be in_progress at a time.",
|
|
830
|
+
"Never mark a task completed if tests are failing, the implementation is partial, or you hit unresolved errors \u2014 keep it in_progress and address the issue first."
|
|
537
831
|
],
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
832
|
+
parameters: todoParamsSchema,
|
|
833
|
+
async execute(_toolCallId, params) {
|
|
834
|
+
const result = applyTodoAction(state, params.action, params);
|
|
835
|
+
state = result.state;
|
|
836
|
+
const details = {
|
|
837
|
+
action: params.action,
|
|
838
|
+
tasks: state.tasks,
|
|
839
|
+
nextId: state.nextId,
|
|
840
|
+
...result.error ? { error: result.error } : {}
|
|
841
|
+
};
|
|
842
|
+
return {
|
|
843
|
+
content: [{ type: "text", text: result.text }],
|
|
844
|
+
details
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
pi.registerCommand("todos", {
|
|
849
|
+
description: "Show current todo list grouped by status.",
|
|
850
|
+
handler: async () => {
|
|
851
|
+
const visible = state.tasks.filter((t) => t.status !== "deleted");
|
|
852
|
+
if (visible.length === 0) {
|
|
853
|
+
pi.sendUserMessage("Show the current todo list.");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const pending2 = visible.filter((t) => t.status === "pending");
|
|
857
|
+
const inProgress = visible.filter((t) => t.status === "in_progress");
|
|
858
|
+
const completed = visible.filter((t) => t.status === "completed");
|
|
859
|
+
const lines = [];
|
|
860
|
+
const total = visible.length;
|
|
861
|
+
const doneCount = completed.length;
|
|
862
|
+
lines.push(`Todos (${doneCount}/${total})`);
|
|
863
|
+
if (inProgress.length > 0) {
|
|
864
|
+
lines.push("\u2500\u2500 In Progress \u2500\u2500");
|
|
865
|
+
for (const t of inProgress) lines.push(` \u25D0 #${t.id} ${t.subject}`);
|
|
866
|
+
}
|
|
867
|
+
if (pending2.length > 0) {
|
|
868
|
+
lines.push("\u2500\u2500 Pending \u2500\u2500");
|
|
869
|
+
for (const t of pending2) lines.push(` \u25CB #${t.id} ${t.subject}`);
|
|
870
|
+
}
|
|
871
|
+
if (completed.length > 0) {
|
|
872
|
+
lines.push("\u2500\u2500 Completed \u2500\u2500");
|
|
873
|
+
for (const t of completed) lines.push(` \u2713 #${t.id} ${t.subject}`);
|
|
874
|
+
}
|
|
875
|
+
pi.sendUserMessage(`Current todos:
|
|
876
|
+
${lines.join("\n")}`);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
880
|
+
state = replayFromBranch(ctx);
|
|
881
|
+
});
|
|
882
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
883
|
+
try {
|
|
884
|
+
state = replayFromBranch(ctx);
|
|
885
|
+
} catch {
|
|
886
|
+
}
|
|
547
887
|
});
|
|
548
|
-
pi.
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const message = task ? `Draft a plan for: ${task}. Use the update_plan tool to publish it before starting any work.` : "Draft a plan for the current task. Use the update_plan tool to publish it before starting any work.";
|
|
553
|
-
pi.sendUserMessage(message);
|
|
888
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
889
|
+
try {
|
|
890
|
+
state = replayFromBranch(ctx);
|
|
891
|
+
} catch {
|
|
554
892
|
}
|
|
555
893
|
});
|
|
894
|
+
pi.on("session_shutdown", async () => {
|
|
895
|
+
state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
|
|
896
|
+
});
|
|
556
897
|
};
|
|
557
898
|
|
|
558
899
|
// src/extensions/ask_user/schema.ts
|
|
@@ -616,13 +957,6 @@ function resolveAnswer(toolCallId, answer, expectedSessionFile) {
|
|
|
616
957
|
entry.resolve(answer);
|
|
617
958
|
return true;
|
|
618
959
|
}
|
|
619
|
-
function cancelPendingExcept(keepSessionFile) {
|
|
620
|
-
for (const [id, entry] of pending) {
|
|
621
|
-
if (entry.sessionFile === keepSessionFile) continue;
|
|
622
|
-
pending.delete(id);
|
|
623
|
-
entry.reject(new Error("Session replaced before answer arrived"));
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
960
|
function cancelPendingForSession(sessionFile) {
|
|
627
961
|
for (const [id, entry] of pending) {
|
|
628
962
|
if (entry.sessionFile !== sessionFile) continue;
|
|
@@ -673,7 +1007,7 @@ function waitForAnswer({
|
|
|
673
1007
|
sessionFile,
|
|
674
1008
|
signal
|
|
675
1009
|
}) {
|
|
676
|
-
return new Promise((
|
|
1010
|
+
return new Promise((resolve8, reject) => {
|
|
677
1011
|
let settled = false;
|
|
678
1012
|
let timeoutHandle;
|
|
679
1013
|
const cleanup = () => {
|
|
@@ -685,13 +1019,13 @@ function waitForAnswer({
|
|
|
685
1019
|
if (settled) return;
|
|
686
1020
|
settled = true;
|
|
687
1021
|
cleanup();
|
|
688
|
-
|
|
1022
|
+
resolve8(a);
|
|
689
1023
|
};
|
|
690
|
-
const finishErr = (
|
|
1024
|
+
const finishErr = (err2) => {
|
|
691
1025
|
if (settled) return;
|
|
692
1026
|
settled = true;
|
|
693
1027
|
cleanup();
|
|
694
|
-
reject(
|
|
1028
|
+
reject(err2);
|
|
695
1029
|
};
|
|
696
1030
|
const onAbort = () => finishErr(new Error("Aborted by user"));
|
|
697
1031
|
if (signal?.aborted) {
|
|
@@ -709,7 +1043,7 @@ function waitForAnswer({
|
|
|
709
1043
|
args: params,
|
|
710
1044
|
sessionFile,
|
|
711
1045
|
resolve: (answer) => finishOk(answer),
|
|
712
|
-
reject: (
|
|
1046
|
+
reject: (err2) => finishErr(err2)
|
|
713
1047
|
});
|
|
714
1048
|
});
|
|
715
1049
|
}
|
|
@@ -759,102 +1093,1392 @@ function descriptionSuffix(params, index) {
|
|
|
759
1093
|
return desc ? ` \u2014 ${desc}` : "";
|
|
760
1094
|
}
|
|
761
1095
|
|
|
762
|
-
// src/
|
|
763
|
-
import {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1096
|
+
// src/extensions/artifact/schema.ts
|
|
1097
|
+
import { Type as Type3 } from "typebox";
|
|
1098
|
+
var TypeEnum = Type3.Union(
|
|
1099
|
+
[
|
|
1100
|
+
Type3.Literal("html"),
|
|
1101
|
+
Type3.Literal("svg"),
|
|
1102
|
+
Type3.Literal("markdown"),
|
|
1103
|
+
Type3.Literal("code")
|
|
1104
|
+
],
|
|
1105
|
+
{
|
|
1106
|
+
description: 'How to render the content: "html" (a self-contained HTML document or fragment, run in a sandboxed iframe), "svg" (SVG markup), "markdown" (rich text), or "code" (a source file shown with syntax highlighting).'
|
|
1107
|
+
}
|
|
1108
|
+
);
|
|
1109
|
+
var createArtifactParamsSchema = Type3.Object({
|
|
1110
|
+
id: Type3.Optional(
|
|
1111
|
+
Type3.String({
|
|
1112
|
+
description: 'Stable identifier. Omit on first creation. To REVISE an existing artifact, pass the same id you used before \u2014 that records a new version instead of a separate artifact. Use a short slug like "landing-page".'
|
|
1113
|
+
})
|
|
1114
|
+
),
|
|
1115
|
+
type: TypeEnum,
|
|
1116
|
+
title: Type3.String({
|
|
1117
|
+
description: "Short human-readable title shown in the artifact panel."
|
|
1118
|
+
}),
|
|
1119
|
+
content: Type3.String({
|
|
1120
|
+
description: "The full artifact content \u2014 the complete document, markup, or source."
|
|
1121
|
+
}),
|
|
1122
|
+
language: Type3.Optional(
|
|
1123
|
+
Type3.String({
|
|
1124
|
+
description: 'For type="code", the language id for syntax highlighting (e.g. "python", "typescript").'
|
|
1125
|
+
})
|
|
1126
|
+
)
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// src/extensions/artifact/factory.ts
|
|
1130
|
+
var TOOL_NAME2 = "create_artifact";
|
|
1131
|
+
var artifactExtensionFactory = (pi) => {
|
|
1132
|
+
pi.registerTool({
|
|
1133
|
+
name: TOOL_NAME2,
|
|
1134
|
+
label: "Create artifact",
|
|
1135
|
+
description: 'Publish a substantial, self-contained piece of content as an "artifact" the user can view and iterate on in a dedicated side panel: a web page (html), an SVG diagram (svg), a document (markdown), or a source file (code). Reuse the same `id` to revise an existing artifact (records a new version).',
|
|
1136
|
+
promptSnippet: "create_artifact: render substantial, self-contained content (web page / SVG / document / code file) in a side panel the user can view and iterate on.",
|
|
1137
|
+
promptGuidelines: [
|
|
1138
|
+
"Use create_artifact for substantial, self-contained, reusable content the user will want to view, keep, or iterate on \u2014 a runnable HTML page, an SVG diagram, a full document, or a standalone code file. Do NOT use it for short snippets, command output, or your normal conversational answer; a fenced code block in your reply is better for those.",
|
|
1139
|
+
"To revise an artifact, call create_artifact again with the SAME id and the full updated content \u2014 this records a new version the user can step through. Don't spawn a near-duplicate artifact under a new id.",
|
|
1140
|
+
'Put the entire content in `content`, give it a concise `title`, and pick the `type` that matches how it should render. For type="code", set `language`.',
|
|
1141
|
+
"After creating an artifact, keep your chat reply short \u2014 the content lives in the panel, so don't paste it again in prose."
|
|
1142
|
+
],
|
|
1143
|
+
parameters: createArtifactParamsSchema,
|
|
1144
|
+
execute: async (toolCallId, params) => {
|
|
1145
|
+
const id = params.id?.trim() || toolCallId;
|
|
1146
|
+
const details = {
|
|
1147
|
+
id,
|
|
1148
|
+
type: params.type,
|
|
1149
|
+
title: params.title
|
|
1150
|
+
};
|
|
1151
|
+
const text = `Artifact "${params.title}" (${params.type}) is now shown to the user in the Artifacts panel. Its id is "${id}" \u2014 pass that same id to create_artifact to revise it.`;
|
|
1152
|
+
return {
|
|
1153
|
+
content: [{ type: "text", text }],
|
|
1154
|
+
details
|
|
1155
|
+
};
|
|
776
1156
|
}
|
|
1157
|
+
});
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
// src/extensions/subagent/agents.ts
|
|
1161
|
+
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
1162
|
+
import { join as join6 } from "path";
|
|
1163
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
1164
|
+
|
|
1165
|
+
// src/extensions/subagent/builtin-agents.ts
|
|
1166
|
+
var COMMON_RULES = `Hard rules:
|
|
1167
|
+
- You run headless as a subagent: there is NO user to talk to. Never ask questions, never wait for confirmation \u2014 decide and proceed.
|
|
1168
|
+
- NEVER run the \`pi\` CLI or spawn any other agent. No nesting.
|
|
1169
|
+
- End with ONE final message that is a complete, self-contained report \u2014 it is the ONLY thing returned to the agent that delegated to you. Keep it under ~8000 characters and reference code as \`path:line\`.`;
|
|
1170
|
+
var scout = {
|
|
1171
|
+
name: "scout",
|
|
1172
|
+
source: "builtin",
|
|
1173
|
+
description: "Fast read-only codebase recon: locate files, symbols, flows, conventions, and report compressed findings. Cheap to use; cannot edit anything.",
|
|
1174
|
+
tools: ["read", "grep", "find", "ls"],
|
|
1175
|
+
systemPrompt: `You are "scout", a read-only reconnaissance subagent.
|
|
1176
|
+
|
|
1177
|
+
${COMMON_RULES}
|
|
1178
|
+
|
|
1179
|
+
Method: start broad (find/ls/grep), then read only the spans that matter. Prefer reading slices over whole files. Stop as soon as you can answer confidently.
|
|
1180
|
+
|
|
1181
|
+
Final report shape: a short answer first, then the supporting map \u2014 relevant files with one-line roles, key symbols as \`path:line\`, and any conventions or gotchas the delegator should know. Say explicitly what you did NOT verify.`
|
|
1182
|
+
};
|
|
1183
|
+
var worker = {
|
|
1184
|
+
name: "worker",
|
|
1185
|
+
source: "builtin",
|
|
1186
|
+
description: "General-purpose implementer with the full default toolset (bash/edit/write). Use for a self-contained change with a precise brief; verifies its own work.",
|
|
1187
|
+
systemPrompt: `You are "worker", an implementation subagent.
|
|
1188
|
+
|
|
1189
|
+
${COMMON_RULES}
|
|
1190
|
+
|
|
1191
|
+
Method: read the relevant code before changing it; follow the surrounding style exactly; make the smallest change that satisfies the brief. Verify with the project's own commands (typecheck / tests / build) when available \u2014 report what you ran and its outcome honestly.
|
|
1192
|
+
|
|
1193
|
+
Final report shape: what changed (file by file, one line each), how it was verified (commands + results), and any caveats or follow-ups. If you could not finish, say precisely how far you got and what is left.`
|
|
1194
|
+
};
|
|
1195
|
+
var reviewer = {
|
|
1196
|
+
name: "reviewer",
|
|
1197
|
+
source: "builtin",
|
|
1198
|
+
description: "Code review of specific files or diffs: correctness, edge cases, convention drift. Read-mostly (bash for git diff / running tests). Returns prioritized findings.",
|
|
1199
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
1200
|
+
systemPrompt: `You are "reviewer", a code-review subagent. Your job is to FIND problems, not to fix them \u2014 do not edit any file.
|
|
1201
|
+
|
|
1202
|
+
${COMMON_RULES}
|
|
1203
|
+
|
|
1204
|
+
Method: read the target code fully before judging; use bash only for read-only inspection (git diff/log, running existing tests). Hunt real defects first \u2014 correctness, edge cases, lifecycle/cleanup holes \u2014 then convention drift. Verify each suspicion against the actual code before reporting it.
|
|
1205
|
+
|
|
1206
|
+
Final report shape: findings ordered by severity, each with \`path:line\`, what's wrong, why it matters, and a concrete suggested fix. End with what you checked and found clean, so silence isn't ambiguous.`
|
|
1207
|
+
};
|
|
1208
|
+
var BUILTIN_AGENTS = [scout, worker, reviewer];
|
|
1209
|
+
|
|
1210
|
+
// src/extensions/subagent/trust.ts
|
|
1211
|
+
import { readFileSync } from "fs";
|
|
1212
|
+
import { homedir as homedir2 } from "os";
|
|
1213
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
1214
|
+
function isProjectDirTrusted(projectDir) {
|
|
1215
|
+
const registryPath = join5(
|
|
1216
|
+
process.env.PI_PILOT_DATA_DIR ?? join5(homedir2(), ".pi", "webui"),
|
|
1217
|
+
"workspaces.json"
|
|
1218
|
+
);
|
|
1219
|
+
try {
|
|
1220
|
+
const raw = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
1221
|
+
if (!Array.isArray(raw.workspaces)) return false;
|
|
1222
|
+
const wanted = resolve3(projectDir);
|
|
1223
|
+
return raw.workspaces.some(
|
|
1224
|
+
(w) => typeof w?.path === "string" && resolve3(w.path) === wanted && w.trustProjectAgents === true
|
|
1225
|
+
);
|
|
1226
|
+
} catch {
|
|
1227
|
+
return false;
|
|
777
1228
|
}
|
|
778
1229
|
}
|
|
779
|
-
|
|
780
|
-
|
|
1230
|
+
|
|
1231
|
+
// src/extensions/subagent/agents.ts
|
|
1232
|
+
function userAgentsDir() {
|
|
1233
|
+
return join6(getAgentDir(), "agents");
|
|
781
1234
|
}
|
|
782
|
-
function
|
|
783
|
-
return
|
|
1235
|
+
function projectAgentsDir(projectDir) {
|
|
1236
|
+
return join6(projectDir, ".pi", "agents");
|
|
784
1237
|
}
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1238
|
+
function discoverAgents(projectDir) {
|
|
1239
|
+
const roster = /* @__PURE__ */ new Map();
|
|
1240
|
+
for (const agent of BUILTIN_AGENTS) roster.set(agent.name, agent);
|
|
1241
|
+
mergeDir(roster, userAgentsDir(), "user");
|
|
1242
|
+
if (projectDir && isProjectDirTrusted(projectDir)) {
|
|
1243
|
+
mergeDir(roster, projectAgentsDir(projectDir), "project");
|
|
1244
|
+
}
|
|
1245
|
+
return roster;
|
|
1246
|
+
}
|
|
1247
|
+
function mergeDir(roster, dir, source) {
|
|
1248
|
+
let files;
|
|
1249
|
+
try {
|
|
1250
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
1251
|
+
} catch {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
for (const file of files) {
|
|
1255
|
+
const def = parseAgentFile(join6(dir, file), file, source);
|
|
1256
|
+
if (def) roster.set(def.name, def);
|
|
1257
|
+
}
|
|
791
1258
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
await writeFile3(PREFS_PATH, JSON.stringify(cache3, null, 2), "utf8");
|
|
1259
|
+
function rosterSummary(roster) {
|
|
1260
|
+
return [...roster.values()].map((a) => `- ${a.name}: ${a.description || "(no description)"}`).join("\n");
|
|
795
1261
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
name
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
commands: [],
|
|
813
|
-
factory: askUserExtensionFactory
|
|
1262
|
+
function parseAgentFile(path, filename, source) {
|
|
1263
|
+
try {
|
|
1264
|
+
const raw = readFileSync2(path, "utf8");
|
|
1265
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1266
|
+
const name = strField(frontmatter.name) ?? filename.replace(/\.md$/, "").trim();
|
|
1267
|
+
if (!name) return void 0;
|
|
1268
|
+
return {
|
|
1269
|
+
name,
|
|
1270
|
+
description: strField(frontmatter.description) ?? "",
|
|
1271
|
+
systemPrompt: body.trim(),
|
|
1272
|
+
tools: toolsField(frontmatter.tools),
|
|
1273
|
+
model: strField(frontmatter.model),
|
|
1274
|
+
source
|
|
1275
|
+
};
|
|
1276
|
+
} catch {
|
|
1277
|
+
return void 0;
|
|
814
1278
|
}
|
|
815
|
-
];
|
|
816
|
-
function gate(def) {
|
|
817
|
-
return (pi) => {
|
|
818
|
-
if (isBuiltinDisabled(def.id)) return;
|
|
819
|
-
return def.factory(pi);
|
|
820
|
-
};
|
|
821
1279
|
}
|
|
822
|
-
|
|
1280
|
+
function strField(value) {
|
|
1281
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1282
|
+
}
|
|
1283
|
+
function toolsField(value) {
|
|
1284
|
+
if (typeof value === "string") {
|
|
1285
|
+
const parts = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1286
|
+
return parts.length > 0 ? parts : void 0;
|
|
1287
|
+
}
|
|
1288
|
+
if (Array.isArray(value)) {
|
|
1289
|
+
const parts = value.filter((v) => typeof v === "string" && v.trim() !== "");
|
|
1290
|
+
return parts.length > 0 ? parts.map((s) => s.trim()) : void 0;
|
|
1291
|
+
}
|
|
1292
|
+
return void 0;
|
|
1293
|
+
}
|
|
823
1294
|
|
|
824
|
-
// src/extensions/
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1295
|
+
// src/extensions/subagent/child.ts
|
|
1296
|
+
import { spawn } from "child_process";
|
|
1297
|
+
import {
|
|
1298
|
+
createWriteStream,
|
|
1299
|
+
mkdirSync,
|
|
1300
|
+
mkdtempSync,
|
|
1301
|
+
writeFileSync
|
|
1302
|
+
} from "fs";
|
|
1303
|
+
import { rm as rm3 } from "fs/promises";
|
|
1304
|
+
import { tmpdir } from "os";
|
|
1305
|
+
import { join as join8 } from "path";
|
|
1306
|
+
|
|
1307
|
+
// src/extensions/subagent/schema.ts
|
|
1308
|
+
import { Type as Type4 } from "typebox";
|
|
1309
|
+
var MAX_TASKS_PER_CALL = 8;
|
|
1310
|
+
var taskBriefDescription = "Complete, self-contained task brief. The subagent sees NOTHING of this conversation \u2014 include all relevant paths, constraints, context, and the exact shape of the answer you want back.";
|
|
1311
|
+
var subagentParamsSchema = Type4.Object({
|
|
1312
|
+
agent: Type4.Optional(
|
|
1313
|
+
Type4.String({
|
|
1314
|
+
description: "Single mode: agent to delegate to, by name. The roster is listed in the tool description; an unknown name returns the available roster. Use together with `task`; omit when using `tasks`."
|
|
1315
|
+
})
|
|
1316
|
+
),
|
|
1317
|
+
task: Type4.Optional(Type4.String({ description: taskBriefDescription })),
|
|
1318
|
+
tasks: Type4.Optional(
|
|
1319
|
+
Type4.Array(
|
|
1320
|
+
Type4.Object({
|
|
1321
|
+
agent: Type4.String({ description: "Agent to delegate this task to, by name." }),
|
|
1322
|
+
task: Type4.String({ description: taskBriefDescription })
|
|
1323
|
+
}),
|
|
1324
|
+
{
|
|
1325
|
+
maxItems: MAX_TASKS_PER_CALL,
|
|
1326
|
+
description: `Parallel mode: up to ${MAX_TASKS_PER_CALL} INDEPENDENT task briefs, run concurrently. All results return together in one combined report. Only for tasks with no ordering dependency \u2014 sequence dependent steps as separate subagent calls instead. Omit when using \`agent\`/\`task\`.`
|
|
843
1327
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
});
|
|
1331
|
+
function emptyUsage() {
|
|
1332
|
+
return {
|
|
1333
|
+
input: 0,
|
|
1334
|
+
output: 0,
|
|
1335
|
+
cacheRead: 0,
|
|
1336
|
+
cacheWrite: 0,
|
|
1337
|
+
cost: 0,
|
|
1338
|
+
contextTokens: 0,
|
|
1339
|
+
turns: 0
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function isSubagentDetails(value) {
|
|
1343
|
+
if (!value || typeof value !== "object") return false;
|
|
1344
|
+
const v = value;
|
|
1345
|
+
return v.version === 1 && (v.mode === "single" || v.mode === "parallel") && Array.isArray(v.tasks);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// src/extensions/subagent/pi-bin.ts
|
|
1349
|
+
import { existsSync } from "fs";
|
|
1350
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1351
|
+
import { fileURLToPath } from "url";
|
|
1352
|
+
var cached;
|
|
1353
|
+
function resolvePinnedPiCli() {
|
|
1354
|
+
if (cached) return cached;
|
|
1355
|
+
try {
|
|
1356
|
+
const entry = fileURLToPath(
|
|
1357
|
+
import.meta.resolve("@earendil-works/pi-coding-agent")
|
|
1358
|
+
);
|
|
1359
|
+
const candidate = join7(dirname4(entry), "cli.js");
|
|
1360
|
+
if (existsSync(candidate)) {
|
|
1361
|
+
cached = candidate;
|
|
1362
|
+
return candidate;
|
|
851
1363
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1364
|
+
} catch {
|
|
1365
|
+
}
|
|
1366
|
+
const fallback = fileURLToPath(
|
|
1367
|
+
new URL(
|
|
1368
|
+
"../../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
|
|
1369
|
+
import.meta.url
|
|
1370
|
+
)
|
|
1371
|
+
);
|
|
1372
|
+
if (existsSync(fallback)) {
|
|
1373
|
+
cached = fallback;
|
|
1374
|
+
return fallback;
|
|
1375
|
+
}
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
"subagent: cannot locate the pinned @earendil-works/pi-coding-agent CLI (tried import.meta.resolve and the package-local node_modules symlink)"
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/extensions/subagent/registry.ts
|
|
1382
|
+
var children = /* @__PURE__ */ new Map();
|
|
1383
|
+
function registerChild(toolCallId, handle2) {
|
|
1384
|
+
children.set(toolCallId, handle2);
|
|
1385
|
+
}
|
|
1386
|
+
function unregisterChild(toolCallId) {
|
|
1387
|
+
children.delete(toolCallId);
|
|
1388
|
+
}
|
|
1389
|
+
function killChildrenForSession(sessionFile) {
|
|
1390
|
+
let killed = 0;
|
|
1391
|
+
for (const [id, handle2] of children) {
|
|
1392
|
+
if (handle2.sessionFile !== sessionFile) continue;
|
|
1393
|
+
children.delete(id);
|
|
1394
|
+
handle2.kill();
|
|
1395
|
+
killed++;
|
|
1396
|
+
}
|
|
1397
|
+
return killed;
|
|
1398
|
+
}
|
|
1399
|
+
function killAllChildren() {
|
|
1400
|
+
let killed = 0;
|
|
1401
|
+
for (const [id, handle2] of children) {
|
|
1402
|
+
children.delete(id);
|
|
1403
|
+
handle2.kill();
|
|
1404
|
+
killed++;
|
|
1405
|
+
}
|
|
1406
|
+
return killed;
|
|
1407
|
+
}
|
|
1408
|
+
var MAX_CONCURRENT_CHILDREN = 8;
|
|
1409
|
+
var MAX_CONCURRENT_PER_SESSION = 4;
|
|
1410
|
+
var running = 0;
|
|
1411
|
+
var runningPerSession = /* @__PURE__ */ new Map();
|
|
1412
|
+
var waiters = [];
|
|
1413
|
+
function keyOf2(sessionFile) {
|
|
1414
|
+
return sessionFile ?? "<unpersisted>";
|
|
1415
|
+
}
|
|
1416
|
+
function hasCapacity(sessionKey) {
|
|
1417
|
+
return running < MAX_CONCURRENT_CHILDREN && (runningPerSession.get(sessionKey) ?? 0) < MAX_CONCURRENT_PER_SESSION;
|
|
1418
|
+
}
|
|
1419
|
+
function take(sessionKey) {
|
|
1420
|
+
running++;
|
|
1421
|
+
runningPerSession.set(sessionKey, (runningPerSession.get(sessionKey) ?? 0) + 1);
|
|
1422
|
+
}
|
|
1423
|
+
function acquireChildSlot(signal, sessionFile) {
|
|
1424
|
+
const sessionKey = keyOf2(sessionFile);
|
|
1425
|
+
return new Promise((resolve8, reject) => {
|
|
1426
|
+
if (signal?.aborted) {
|
|
1427
|
+
reject(new Error("Aborted by user"));
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (hasCapacity(sessionKey)) {
|
|
1431
|
+
take(sessionKey);
|
|
1432
|
+
resolve8(makeRelease(sessionKey));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const waiter = {
|
|
1436
|
+
grant: () => resolve8(makeRelease(sessionKey)),
|
|
1437
|
+
sessionKey,
|
|
1438
|
+
signal,
|
|
1439
|
+
onAbort: void 0
|
|
1440
|
+
};
|
|
1441
|
+
if (signal) {
|
|
1442
|
+
const onAbort = () => {
|
|
1443
|
+
const i = waiters.indexOf(waiter);
|
|
1444
|
+
if (i >= 0) waiters.splice(i, 1);
|
|
1445
|
+
reject(new Error("Aborted by user"));
|
|
1446
|
+
};
|
|
1447
|
+
waiter.onAbort = onAbort;
|
|
1448
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1449
|
+
}
|
|
1450
|
+
waiters.push(waiter);
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
function makeRelease(sessionKey) {
|
|
1454
|
+
let released = false;
|
|
1455
|
+
return () => {
|
|
1456
|
+
if (released) return;
|
|
1457
|
+
released = true;
|
|
1458
|
+
running--;
|
|
1459
|
+
const n = (runningPerSession.get(sessionKey) ?? 1) - 1;
|
|
1460
|
+
if (n <= 0) runningPerSession.delete(sessionKey);
|
|
1461
|
+
else runningPerSession.set(sessionKey, n);
|
|
1462
|
+
pump();
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function pump() {
|
|
1466
|
+
for (let i = 0; i < waiters.length && running < MAX_CONCURRENT_CHILDREN; ) {
|
|
1467
|
+
const waiter = waiters[i];
|
|
1468
|
+
if (waiter.signal?.aborted) {
|
|
1469
|
+
waiters.splice(i, 1);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (!hasCapacity(waiter.sessionKey)) {
|
|
1473
|
+
i++;
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
waiters.splice(i, 1);
|
|
1477
|
+
if (waiter.signal && waiter.onAbort) {
|
|
1478
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
1479
|
+
}
|
|
1480
|
+
take(waiter.sessionKey);
|
|
1481
|
+
waiter.grant();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/extensions/subagent/child.ts
|
|
1486
|
+
var PROMPT_DIR_PREFIX = "pi-pilot-subagent-";
|
|
1487
|
+
var TRANSCRIPTS_DIR = join8(tmpdir(), "pi-pilot-subagents", "transcripts");
|
|
1488
|
+
var ACTIVITY_MAX = 30;
|
|
1489
|
+
var LABEL_MAX = 160;
|
|
1490
|
+
var STDERR_TAIL_MAX = 2048;
|
|
1491
|
+
var FINAL_TEXT_MAX = 2e5;
|
|
1492
|
+
var SIGKILL_DELAY_MS = 5e3;
|
|
1493
|
+
async function runChild(opts) {
|
|
1494
|
+
const startedAt = Date.now();
|
|
1495
|
+
const cli = opts.cliPath ?? process.env.PI_PILOT_SUBAGENT_CLI ?? resolvePinnedPiCli();
|
|
1496
|
+
const promptDir = mkdtempSync(join8(tmpdir(), PROMPT_DIR_PREFIX));
|
|
1497
|
+
const promptPath = join8(promptDir, "prompt.md");
|
|
1498
|
+
writeFileSync(promptPath, opts.appendSystemPrompt, { mode: 384 });
|
|
1499
|
+
let transcriptPath;
|
|
1500
|
+
let tee;
|
|
1501
|
+
try {
|
|
1502
|
+
mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
|
|
1503
|
+
transcriptPath = join8(TRANSCRIPTS_DIR, `${sanitizeId(opts.toolCallId)}.ndjson`);
|
|
1504
|
+
tee = createWriteStream(transcriptPath, { flags: "w" });
|
|
1505
|
+
tee.on("error", () => {
|
|
1506
|
+
});
|
|
1507
|
+
} catch {
|
|
1508
|
+
transcriptPath = void 0;
|
|
1509
|
+
}
|
|
1510
|
+
const args = [cli, "--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
|
|
1511
|
+
const model = opts.agent.model ?? opts.inheritModel;
|
|
1512
|
+
if (model) args.push("--model", model);
|
|
1513
|
+
if (opts.agent.tools && opts.agent.tools.length > 0) {
|
|
1514
|
+
args.push("--tools", opts.agent.tools.join(","));
|
|
1515
|
+
}
|
|
1516
|
+
args.push("--append-system-prompt", promptPath);
|
|
1517
|
+
args.push(opts.task);
|
|
1518
|
+
const usage = emptyUsage();
|
|
1519
|
+
const activity = [];
|
|
1520
|
+
let modelSeen;
|
|
1521
|
+
let finalText = "";
|
|
1522
|
+
let stopReason;
|
|
1523
|
+
let errorMessage;
|
|
1524
|
+
let stderrAccum = "";
|
|
1525
|
+
let aborted = false;
|
|
1526
|
+
let timedOut = false;
|
|
1527
|
+
let costKilled = false;
|
|
1528
|
+
const child = spawn(process.execPath, args, {
|
|
1529
|
+
cwd: opts.cwd,
|
|
1530
|
+
env: { ...process.env, PI_PILOT_SUBAGENT: opts.toolCallId },
|
|
1531
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1532
|
+
shell: false
|
|
1533
|
+
});
|
|
1534
|
+
if (child.pid !== void 0) {
|
|
1535
|
+
try {
|
|
1536
|
+
writeFileSync(join8(promptDir, "pid"), `${child.pid}
|
|
1537
|
+
${process.pid}`);
|
|
1538
|
+
} catch {
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
let killed = false;
|
|
1542
|
+
let killTimer;
|
|
1543
|
+
const killGracefully = () => {
|
|
1544
|
+
if (killed) return;
|
|
1545
|
+
killed = true;
|
|
1546
|
+
try {
|
|
1547
|
+
child.kill("SIGTERM");
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
killTimer = setTimeout(() => {
|
|
1551
|
+
try {
|
|
1552
|
+
child.kill("SIGKILL");
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
}, SIGKILL_DELAY_MS);
|
|
1556
|
+
};
|
|
1557
|
+
registerChild(opts.toolCallId, {
|
|
1558
|
+
sessionFile: opts.sessionFile,
|
|
1559
|
+
agent: opts.agent.name,
|
|
1560
|
+
kill: killGracefully
|
|
1561
|
+
});
|
|
1562
|
+
const onAbort = () => {
|
|
1563
|
+
aborted = true;
|
|
1564
|
+
killGracefully();
|
|
1565
|
+
};
|
|
1566
|
+
if (opts.signal?.aborted) onAbort();
|
|
1567
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1568
|
+
let stalled = false;
|
|
1569
|
+
const timeoutTimer = setTimeout(() => {
|
|
1570
|
+
timedOut = true;
|
|
1571
|
+
killGracefully();
|
|
1572
|
+
}, opts.timeoutMs);
|
|
1573
|
+
let stallTimer;
|
|
1574
|
+
const armStallTimer = () => {
|
|
1575
|
+
if (killed) return;
|
|
1576
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
1577
|
+
stallTimer = setTimeout(() => {
|
|
1578
|
+
stalled = true;
|
|
1579
|
+
timedOut = true;
|
|
1580
|
+
killGracefully();
|
|
1581
|
+
}, opts.stallTimeoutMs);
|
|
1582
|
+
};
|
|
1583
|
+
armStallTimer();
|
|
1584
|
+
const emitProgress = () => {
|
|
1585
|
+
opts.onProgress({
|
|
1586
|
+
usage: { ...usage },
|
|
1587
|
+
model: modelSeen,
|
|
1588
|
+
activity: [...activity],
|
|
1589
|
+
lastLabel: activity[activity.length - 1]?.label
|
|
1590
|
+
});
|
|
1591
|
+
};
|
|
1592
|
+
const handleLine = (line) => {
|
|
1593
|
+
if (!line.trim()) return;
|
|
1594
|
+
tee?.write(line + "\n");
|
|
1595
|
+
let event;
|
|
1596
|
+
try {
|
|
1597
|
+
event = JSON.parse(line);
|
|
1598
|
+
} catch {
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const ev = event;
|
|
1602
|
+
if (ev.type !== "message_end" || !ev.message || typeof ev.message !== "object") return;
|
|
1603
|
+
const msg = ev.message;
|
|
1604
|
+
if (msg.role !== "assistant") return;
|
|
1605
|
+
usage.turns++;
|
|
1606
|
+
const u = msg.usage;
|
|
1607
|
+
if (u) {
|
|
1608
|
+
usage.input += u.input ?? 0;
|
|
1609
|
+
usage.output += u.output ?? 0;
|
|
1610
|
+
usage.cacheRead += u.cacheRead ?? 0;
|
|
1611
|
+
usage.cacheWrite += u.cacheWrite ?? 0;
|
|
1612
|
+
usage.cost += u.cost?.total ?? 0;
|
|
1613
|
+
usage.contextTokens = u.totalTokens ?? usage.contextTokens;
|
|
1614
|
+
}
|
|
1615
|
+
if (!modelSeen && typeof msg.model === "string") modelSeen = msg.model;
|
|
1616
|
+
if (typeof msg.stopReason === "string") stopReason = msg.stopReason;
|
|
1617
|
+
if (typeof msg.errorMessage === "string") errorMessage = msg.errorMessage;
|
|
1618
|
+
if (Array.isArray(msg.content)) {
|
|
1619
|
+
const textParts = [];
|
|
1620
|
+
for (const block of msg.content) {
|
|
1621
|
+
if (!block || typeof block !== "object") continue;
|
|
1622
|
+
const b = block;
|
|
1623
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
1624
|
+
textParts.push(b.text);
|
|
1625
|
+
} else if (b.type === "toolCall" && typeof b.name === "string") {
|
|
1626
|
+
pushActivity(activity, b.name, b.arguments);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const text = textParts.join("").trim();
|
|
1630
|
+
if (text) finalText = text.slice(0, FINAL_TEXT_MAX);
|
|
1631
|
+
}
|
|
1632
|
+
if (usage.cost > opts.costCeilingUsd && !costKilled) {
|
|
1633
|
+
costKilled = true;
|
|
1634
|
+
killGracefully();
|
|
1635
|
+
}
|
|
1636
|
+
emitProgress();
|
|
1637
|
+
};
|
|
1638
|
+
let buf = "";
|
|
1639
|
+
child.stdout?.on("data", (chunk) => {
|
|
1640
|
+
armStallTimer();
|
|
1641
|
+
buf += chunk.toString("utf8");
|
|
1642
|
+
let nl;
|
|
1643
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1644
|
+
handleLine(buf.slice(0, nl));
|
|
1645
|
+
buf = buf.slice(nl + 1);
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
child.stderr?.on("data", (chunk) => {
|
|
1649
|
+
armStallTimer();
|
|
1650
|
+
stderrAccum = (stderrAccum + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX);
|
|
1651
|
+
});
|
|
1652
|
+
const exitCode = await new Promise((resolve8) => {
|
|
1653
|
+
child.on("error", (err2) => {
|
|
1654
|
+
errorMessage ??= err2 instanceof Error ? err2.message : String(err2);
|
|
1655
|
+
resolve8(-1);
|
|
1656
|
+
});
|
|
1657
|
+
child.on("close", (code) => resolve8(code ?? -1));
|
|
1658
|
+
});
|
|
1659
|
+
if (buf) handleLine(buf);
|
|
1660
|
+
clearTimeout(timeoutTimer);
|
|
1661
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
1662
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1663
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1664
|
+
unregisterChild(opts.toolCallId);
|
|
1665
|
+
tee?.end();
|
|
1666
|
+
await rm3(promptDir, { recursive: true, force: true }).catch(() => {
|
|
1667
|
+
});
|
|
1668
|
+
if (costKilled && !errorMessage) {
|
|
1669
|
+
errorMessage = `cost ceiling ($${opts.costCeilingUsd}) exceeded \u2014 child terminated`;
|
|
1670
|
+
}
|
|
1671
|
+
if (timedOut && !aborted && !errorMessage) {
|
|
1672
|
+
errorMessage = stalled ? `no output for ${Math.round(opts.stallTimeoutMs / 1e3)}s \u2014 presumed hung` : `wall-clock limit (${Math.round(opts.timeoutMs / 1e3)}s) reached while still active`;
|
|
1673
|
+
}
|
|
1674
|
+
const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" || costKilled;
|
|
1675
|
+
const status2 = aborted ? "aborted" : timedOut ? "timeout" : failed ? "failed" : "done";
|
|
1676
|
+
return {
|
|
1677
|
+
status: status2,
|
|
1678
|
+
finalText,
|
|
1679
|
+
usage,
|
|
1680
|
+
model: modelSeen,
|
|
1681
|
+
stopReason,
|
|
1682
|
+
errorMessage,
|
|
1683
|
+
stderrTail: stderrAccum.trim(),
|
|
1684
|
+
exitCode,
|
|
1685
|
+
durationMs: Date.now() - startedAt,
|
|
1686
|
+
transcriptPath,
|
|
1687
|
+
activity
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function sanitizeId(id) {
|
|
1691
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1692
|
+
}
|
|
1693
|
+
function pushActivity(activity, name, args) {
|
|
1694
|
+
const label = toolLabel(name, args).slice(0, LABEL_MAX);
|
|
1695
|
+
activity.push({ kind: "tool", label });
|
|
1696
|
+
if (activity.length > ACTIVITY_MAX) {
|
|
1697
|
+
activity.splice(0, activity.length - ACTIVITY_MAX);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function toolLabel(name, args) {
|
|
1701
|
+
const a = args && typeof args === "object" ? args : {};
|
|
1702
|
+
const pick = (...keys) => {
|
|
1703
|
+
for (const key of keys) {
|
|
1704
|
+
const v = a[key];
|
|
1705
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
1706
|
+
}
|
|
1707
|
+
return void 0;
|
|
1708
|
+
};
|
|
1709
|
+
let detail;
|
|
1710
|
+
switch (name) {
|
|
1711
|
+
case "bash":
|
|
1712
|
+
detail = pick("command");
|
|
1713
|
+
break;
|
|
1714
|
+
case "read":
|
|
1715
|
+
case "write":
|
|
1716
|
+
case "edit":
|
|
1717
|
+
detail = pick("path", "file_path");
|
|
1718
|
+
break;
|
|
1719
|
+
case "grep":
|
|
1720
|
+
detail = pick("pattern");
|
|
1721
|
+
break;
|
|
1722
|
+
case "find":
|
|
1723
|
+
detail = pick("pattern", "path");
|
|
1724
|
+
break;
|
|
1725
|
+
case "ls":
|
|
1726
|
+
detail = pick("path");
|
|
1727
|
+
break;
|
|
1728
|
+
default: {
|
|
1729
|
+
for (const v of Object.values(a)) {
|
|
1730
|
+
if (typeof v === "string" && v.trim()) {
|
|
1731
|
+
detail = v;
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
const clean = detail?.replace(/\s+/g, " ").trim();
|
|
1738
|
+
return clean ? `${name}: ${clean}` : name;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/extensions/subagent/factory.ts
|
|
1742
|
+
var TASK_OUTPUT_CAP = 12 * 1024;
|
|
1743
|
+
var AGGREGATE_OUTPUT_CAP = 48 * 1024;
|
|
1744
|
+
var PREVIEW_CAP = 8 * 1024;
|
|
1745
|
+
var STDERR_DETAILS_CAP = 1024;
|
|
1746
|
+
var UPDATE_THROTTLE_MS = 500;
|
|
1747
|
+
function tunable(envName, fallback) {
|
|
1748
|
+
const raw = process.env[envName];
|
|
1749
|
+
if (!raw) return fallback;
|
|
1750
|
+
const n = Number.parseFloat(raw);
|
|
1751
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1752
|
+
}
|
|
1753
|
+
var TASK_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_TIMEOUT_SEC", 3600) * 1e3;
|
|
1754
|
+
var STALL_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_STALL_SEC", 600) * 1e3;
|
|
1755
|
+
var COST_CEILING_USD = tunable("PI_PILOT_SUBAGENT_COST_USD", 20);
|
|
1756
|
+
var subagentExtensionFactory = (pi) => {
|
|
1757
|
+
const lastDetails = /* @__PURE__ */ new Map();
|
|
1758
|
+
const rosterAtRegistration = discoverAgents();
|
|
1759
|
+
pi.registerTool({
|
|
1760
|
+
name: "subagent",
|
|
1761
|
+
label: "Subagent",
|
|
1762
|
+
description: `Delegate self-contained tasks to subagents running in isolated contexts (separate pi processes that see nothing of this conversation). Returns only the subagents' final reports. Pass \`agent\` + \`task\` for one delegation, or \`tasks\` (up to ${MAX_TASKS_PER_CALL}) to run INDEPENDENT delegations in parallel. Available agents:
|
|
1763
|
+
` + rosterSummary(rosterAtRegistration) + `
|
|
1764
|
+
The roster is re-read on every call from ${userAgentsDir()}/*.md (builtin presets scout/worker/reviewer; a same-name user file overrides its preset; workspaces with project agents trusted also merge <cwd>/.pi/agents/*.md, which win over both).`,
|
|
1765
|
+
parameters: subagentParamsSchema,
|
|
1766
|
+
executionMode: "parallel",
|
|
1767
|
+
promptSnippet: "subagent: delegate self-contained tasks (recon, a bounded implementation step, a review pass) to isolated child agents \u2014 only their final reports return, keeping large searches and side work out of this context. Independent tasks can fan out in parallel via `tasks`.",
|
|
1768
|
+
promptGuidelines: [
|
|
1769
|
+
"Use subagent when a task is self-contained and would otherwise flood this context (broad codebase recon, a bounded implementation step, a review pass). Don't delegate trivial lookups \u2014 a single read/grep inline is faster and cheaper than a child agent.",
|
|
1770
|
+
"Write complete subagent briefs: the child sees NOTHING of this conversation. Include the relevant paths, constraints, acceptance criteria, and the exact shape of the report you want back.",
|
|
1771
|
+
"Pick the cheapest sufficient agent: scout for read-only recon, reviewer for read-mostly review, worker only when files must change.",
|
|
1772
|
+
`Use \`tasks\` (max ${MAX_TASKS_PER_CALL} per call) ONLY for independent work \u2014 no task's input may depend on another's output, and parallel workers must never edit the same files. All results return together; sequence dependent steps as separate calls instead.`,
|
|
1773
|
+
"Subagents cannot ask the user questions and cannot spawn further subagents. Resolve user decisions (ask_user) BEFORE delegating, and sequence dependent work yourself: delegate, read the report, then issue the next call with the context it needs."
|
|
1774
|
+
],
|
|
1775
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
1776
|
+
const roster = discoverAgents(ctx.cwd);
|
|
1777
|
+
const calls = normalizeCalls(params);
|
|
1778
|
+
if (typeof calls === "string") {
|
|
1779
|
+
return invalidCallResult(calls, params);
|
|
1780
|
+
}
|
|
1781
|
+
const { mode, briefs } = calls;
|
|
1782
|
+
const unknown = [...new Set(briefs.map((b) => b.agent.trim()))].filter(
|
|
1783
|
+
(name) => !roster.has(name)
|
|
1784
|
+
);
|
|
1785
|
+
if (unknown.length > 0) {
|
|
1786
|
+
return invalidCallResult(
|
|
1787
|
+
`Unknown agent${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")}. Available agents:
|
|
1788
|
+
` + rosterSummary(roster) + "\nCall subagent again with one of these names.",
|
|
1789
|
+
params
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
if (signal?.aborted) throw new Error("Aborted by user");
|
|
1793
|
+
const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
1794
|
+
const resolved = briefs.map((b) => ({
|
|
1795
|
+
agent: roster.get(b.agent.trim()),
|
|
1796
|
+
task: b.task
|
|
1797
|
+
}));
|
|
1798
|
+
const tasks = resolved.map((r) => ({
|
|
1799
|
+
agent: r.agent.name,
|
|
1800
|
+
agentSource: r.agent.source,
|
|
1801
|
+
task: r.task,
|
|
1802
|
+
status: "queued",
|
|
1803
|
+
activity: [],
|
|
1804
|
+
usage: emptyUsage()
|
|
1805
|
+
}));
|
|
1806
|
+
const details = { version: 1, mode, tasks };
|
|
1807
|
+
const emitter = makeThrottledEmitter(onUpdate, details);
|
|
1808
|
+
emitter.emit(true);
|
|
1809
|
+
const runOne = async (i) => {
|
|
1810
|
+
const task = tasks[i];
|
|
1811
|
+
const r = resolved[i];
|
|
1812
|
+
let release;
|
|
1813
|
+
try {
|
|
1814
|
+
release = await acquireChildSlot(signal, sessionFile);
|
|
1815
|
+
} catch (err2) {
|
|
1816
|
+
if (!signal?.aborted) throw err2;
|
|
1817
|
+
task.status = "aborted";
|
|
1818
|
+
emitter.emit(true);
|
|
1819
|
+
return void 0;
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
task.status = "running";
|
|
1823
|
+
emitter.emit(true);
|
|
1824
|
+
const outcome = await runChild({
|
|
1825
|
+
// Per-child id: registry entries, pidfiles and transcript
|
|
1826
|
+
// files must not collide across one call's siblings.
|
|
1827
|
+
toolCallId: mode === "single" ? toolCallId : `${toolCallId}.${i}`,
|
|
1828
|
+
agent: r.agent,
|
|
1829
|
+
task: r.task,
|
|
1830
|
+
cwd: ctx.cwd,
|
|
1831
|
+
sessionFile,
|
|
1832
|
+
appendSystemPrompt: composeChildPrompt(r.agent),
|
|
1833
|
+
inheritModel: r.agent.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : void 0),
|
|
1834
|
+
signal,
|
|
1835
|
+
timeoutMs: TASK_TIMEOUT_MS,
|
|
1836
|
+
stallTimeoutMs: STALL_TIMEOUT_MS,
|
|
1837
|
+
costCeilingUsd: COST_CEILING_USD,
|
|
1838
|
+
onProgress: (p) => {
|
|
1839
|
+
task.usage = p.usage;
|
|
1840
|
+
task.model = p.model ?? task.model;
|
|
1841
|
+
task.activity = p.activity;
|
|
1842
|
+
emitter.emit();
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
mergeOutcome(task, outcome);
|
|
1846
|
+
emitter.emit(true);
|
|
1847
|
+
return outcome;
|
|
1848
|
+
} finally {
|
|
1849
|
+
release();
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
try {
|
|
1853
|
+
const outcomes = await Promise.all(resolved.map((_, i) => runOne(i)));
|
|
1854
|
+
emitter.cancel();
|
|
1855
|
+
lastDetails.set(toolCallId, details);
|
|
1856
|
+
if (tasks.some((t) => t.status === "aborted")) {
|
|
1857
|
+
throw new Error("Aborted by user");
|
|
1858
|
+
}
|
|
1859
|
+
return {
|
|
1860
|
+
content: [{ type: "text", text: combinedParentText(mode, resolved, outcomes) }],
|
|
1861
|
+
details
|
|
1862
|
+
};
|
|
1863
|
+
} finally {
|
|
1864
|
+
emitter.cancel();
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
pi.on("tool_result", (ev) => {
|
|
1869
|
+
if (ev.toolName !== "subagent") return void 0;
|
|
1870
|
+
const fromMap = lastDetails.get(ev.toolCallId);
|
|
1871
|
+
lastDetails.delete(ev.toolCallId);
|
|
1872
|
+
const details = isSubagentDetails(ev.details) ? ev.details : fromMap;
|
|
1873
|
+
if (!details) return void 0;
|
|
1874
|
+
const failed = details.tasks.some(
|
|
1875
|
+
(t) => t.status === "failed" || t.status === "timeout"
|
|
1876
|
+
);
|
|
1877
|
+
const patch = {};
|
|
1878
|
+
if (failed && !ev.isError) patch.isError = true;
|
|
1879
|
+
if (ev.isError && !isSubagentDetails(ev.details) && fromMap) patch.details = fromMap;
|
|
1880
|
+
return patch.isError !== void 0 || patch.details !== void 0 ? patch : void 0;
|
|
1881
|
+
});
|
|
1882
|
+
};
|
|
1883
|
+
function normalizeCalls(params) {
|
|
1884
|
+
const hasSingle = params.agent !== void 0 || params.task !== void 0;
|
|
1885
|
+
const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
|
|
1886
|
+
if (hasSingle && hasTasks) {
|
|
1887
|
+
return "Pass EITHER `agent` + `task` (single delegation) OR `tasks` (parallel fan-out) \u2014 not both in one call.";
|
|
1888
|
+
}
|
|
1889
|
+
if (hasTasks) {
|
|
1890
|
+
const list = params.tasks;
|
|
1891
|
+
if (list.length > MAX_TASKS_PER_CALL) {
|
|
1892
|
+
return `tasks[] is capped at ${MAX_TASKS_PER_CALL} per call (got ${list.length}). Split the fan-out into multiple subagent calls.`;
|
|
1893
|
+
}
|
|
1894
|
+
if (list.some((t) => !t?.agent?.trim() || !t?.task?.trim())) {
|
|
1895
|
+
return "Every tasks[] entry needs a non-empty `agent` and `task`.";
|
|
1896
|
+
}
|
|
1897
|
+
return { mode: "parallel", briefs: list.map((t) => ({ agent: t.agent, task: t.task })) };
|
|
1898
|
+
}
|
|
1899
|
+
if (params.agent?.trim() && params.task?.trim()) {
|
|
1900
|
+
return { mode: "single", briefs: [{ agent: params.agent, task: params.task }] };
|
|
1901
|
+
}
|
|
1902
|
+
return "Provide either `agent` + `task` (single delegation) or `tasks` (parallel fan-out of independent briefs).";
|
|
1903
|
+
}
|
|
1904
|
+
function invalidCallResult(text, params) {
|
|
1905
|
+
return {
|
|
1906
|
+
content: [{ type: "text", text }],
|
|
1907
|
+
details: {
|
|
1908
|
+
version: 1,
|
|
1909
|
+
mode: Array.isArray(params.tasks) && params.tasks.length > 0 ? "parallel" : "single",
|
|
1910
|
+
tasks: []
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
function composeChildPrompt(agent) {
|
|
1915
|
+
const header = `You are running as the "${agent.name}" subagent, delegated one task by another agent via pi-pilot. Work only within the project's working directory.`;
|
|
1916
|
+
return agent.systemPrompt ? `${header}
|
|
1917
|
+
|
|
1918
|
+
${agent.systemPrompt}` : header;
|
|
1919
|
+
}
|
|
1920
|
+
function mergeOutcome(task, outcome) {
|
|
1921
|
+
task.status = outcome.status;
|
|
1922
|
+
task.usage = outcome.usage;
|
|
1923
|
+
task.activity = outcome.activity;
|
|
1924
|
+
task.model = outcome.model ?? task.model;
|
|
1925
|
+
task.stopReason = outcome.stopReason;
|
|
1926
|
+
task.errorMessage = outcome.errorMessage;
|
|
1927
|
+
task.stderrTail = outcome.stderrTail ? outcome.stderrTail.slice(-STDERR_DETAILS_CAP) : void 0;
|
|
1928
|
+
task.exitCode = outcome.exitCode;
|
|
1929
|
+
task.durationMs = outcome.durationMs;
|
|
1930
|
+
task.transcriptPath = outcome.transcriptPath;
|
|
1931
|
+
task.finalPreview = outcome.finalText ? tailCap(outcome.finalText, PREVIEW_CAP) : void 0;
|
|
1932
|
+
}
|
|
1933
|
+
function combinedParentText(mode, resolved, outcomes) {
|
|
1934
|
+
if (mode === "single") {
|
|
1935
|
+
return formatParentText(resolved[0].agent, outcomes[0], TASK_OUTPUT_CAP);
|
|
1936
|
+
}
|
|
1937
|
+
const n = outcomes.length;
|
|
1938
|
+
const perTaskCap = Math.min(TASK_OUTPUT_CAP, Math.floor(AGGREGATE_OUTPUT_CAP / n));
|
|
1939
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1940
|
+
for (const o of outcomes) {
|
|
1941
|
+
const s = o?.status ?? "aborted";
|
|
1942
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
1943
|
+
}
|
|
1944
|
+
const summary = [...counts.entries()].map(([s, c]) => `${c} ${s}`).join(", ");
|
|
1945
|
+
const sections = outcomes.map((o, i) => {
|
|
1946
|
+
const head = `=== task ${i + 1}/${n} ===`;
|
|
1947
|
+
const body = o ? formatParentText(resolved[i].agent, o, perTaskCap) : `[${resolved[i].agent.name}] never started (aborted while queued)`;
|
|
1948
|
+
return `${head}
|
|
1949
|
+
${body}`;
|
|
1950
|
+
});
|
|
1951
|
+
return [`[subagent] ${n} parallel tasks: ${summary}`, ...sections].join("\n\n");
|
|
1952
|
+
}
|
|
1953
|
+
function formatParentText(agent, outcome, outputCap) {
|
|
1954
|
+
const stats = `${Math.round(outcome.durationMs / 1e3)}s \xB7 ${outcome.usage.turns} turns \xB7 ${fmtTokens(outcome.usage.input + outcome.usage.output)} tokens \xB7 $${outcome.usage.cost.toFixed(3)}`;
|
|
1955
|
+
if (outcome.status === "done") {
|
|
1956
|
+
const body = outcome.finalText || "(the subagent produced no final text)";
|
|
1957
|
+
return `[${agent.name}] done in ${stats}
|
|
1958
|
+
|
|
1959
|
+
${tailCap(body, outputCap)}`;
|
|
1960
|
+
}
|
|
1961
|
+
const head = outcome.status === "timeout" ? `[${agent.name}] TIMED OUT after ${stats}` : `[${agent.name}] FAILED (stopReason=${outcome.stopReason ?? "?"}, exit ${outcome.exitCode}) after ${stats}`;
|
|
1962
|
+
const parts = [head];
|
|
1963
|
+
if (outcome.errorMessage) parts.push(`error: ${outcome.errorMessage}`);
|
|
1964
|
+
if (outcome.finalText) parts.push(`partial output:
|
|
1965
|
+
${tailCap(outcome.finalText, 2 * 1024)}`);
|
|
1966
|
+
if (outcome.stderrTail) parts.push(`stderr tail:
|
|
1967
|
+
${outcome.stderrTail.slice(-STDERR_DETAILS_CAP)}`);
|
|
1968
|
+
if (outcome.transcriptPath) parts.push(`full transcript: ${outcome.transcriptPath}`);
|
|
1969
|
+
if (outcome.status === "timeout") {
|
|
1970
|
+
parts.push(
|
|
1971
|
+
"note for the user: limits are env-tunable \u2014 PI_PILOT_SUBAGENT_STALL_SEC (silence detector) / PI_PILOT_SUBAGENT_TIMEOUT_SEC (total ceiling), server restart applies."
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
return parts.join("\n");
|
|
1975
|
+
}
|
|
1976
|
+
function tailCap(text, max) {
|
|
1977
|
+
if (text.length <= max) return text;
|
|
1978
|
+
const dropped = text.length - max;
|
|
1979
|
+
return `\u2026(${dropped} chars truncated \u2014 full transcript in details)
|
|
1980
|
+
${text.slice(-max)}`;
|
|
1981
|
+
}
|
|
1982
|
+
function fmtTokens(n) {
|
|
1983
|
+
return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
|
1984
|
+
}
|
|
1985
|
+
function makeThrottledEmitter(onUpdate, details) {
|
|
1986
|
+
let lastEmit = 0;
|
|
1987
|
+
let timer;
|
|
1988
|
+
let cancelled = false;
|
|
1989
|
+
const fire = () => {
|
|
1990
|
+
timer = void 0;
|
|
1991
|
+
lastEmit = Date.now();
|
|
1992
|
+
onUpdate?.({
|
|
1993
|
+
content: [{ type: "text", text: statusLine(details) }],
|
|
1994
|
+
details: structuredClone(details)
|
|
1995
|
+
});
|
|
1996
|
+
};
|
|
1997
|
+
return {
|
|
1998
|
+
emit: (force = false) => {
|
|
1999
|
+
if (cancelled || !onUpdate) return;
|
|
2000
|
+
const elapsed = Date.now() - lastEmit;
|
|
2001
|
+
if (force || elapsed >= UPDATE_THROTTLE_MS) {
|
|
2002
|
+
if (timer) {
|
|
2003
|
+
clearTimeout(timer);
|
|
2004
|
+
timer = void 0;
|
|
2005
|
+
}
|
|
2006
|
+
fire();
|
|
2007
|
+
} else if (!timer) {
|
|
2008
|
+
timer = setTimeout(fire, UPDATE_THROTTLE_MS - elapsed);
|
|
2009
|
+
}
|
|
2010
|
+
},
|
|
2011
|
+
cancel: () => {
|
|
2012
|
+
cancelled = true;
|
|
2013
|
+
if (timer) {
|
|
2014
|
+
clearTimeout(timer);
|
|
2015
|
+
timer = void 0;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
function statusLine(details) {
|
|
2021
|
+
if (details.tasks.length > 1) {
|
|
2022
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2023
|
+
let cost = 0;
|
|
2024
|
+
let turns = 0;
|
|
2025
|
+
for (const t2 of details.tasks) {
|
|
2026
|
+
counts.set(t2.status, (counts.get(t2.status) ?? 0) + 1);
|
|
2027
|
+
cost += t2.usage.cost;
|
|
2028
|
+
turns += t2.usage.turns;
|
|
2029
|
+
}
|
|
2030
|
+
const bits2 = [`${details.tasks.length} tasks`];
|
|
2031
|
+
for (const status2 of ["running", "queued", "done", "failed", "timeout", "aborted"]) {
|
|
2032
|
+
const c = counts.get(status2);
|
|
2033
|
+
if (c) bits2.push(`${c} ${status2}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (turns > 0) bits2.push(`$${cost.toFixed(3)}`);
|
|
2036
|
+
return bits2.join(" \xB7 ");
|
|
2037
|
+
}
|
|
2038
|
+
const t = details.tasks[0];
|
|
2039
|
+
if (!t) return "subagent";
|
|
2040
|
+
const bits = [t.agent, t.status];
|
|
2041
|
+
if (t.usage.turns > 0) {
|
|
2042
|
+
bits.push(`${t.usage.turns} turns`);
|
|
2043
|
+
bits.push(`${fmtTokens(t.usage.input + t.usage.output)} tok`);
|
|
2044
|
+
bits.push(`$${t.usage.cost.toFixed(3)}`);
|
|
2045
|
+
}
|
|
2046
|
+
const last = t.activity[t.activity.length - 1];
|
|
2047
|
+
if (last && t.status === "running") bits.push(last.label);
|
|
2048
|
+
return bits.join(" \xB7 ");
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// src/extensions/web_search/schema.ts
|
|
2052
|
+
import { Type as Type5 } from "typebox";
|
|
2053
|
+
var webSearchParamsSchema = Type5.Object({
|
|
2054
|
+
query: Type5.String({
|
|
2055
|
+
description: "The search query. Phrase it like a search-engine query (keywords, entities), not a chat sentence."
|
|
2056
|
+
}),
|
|
2057
|
+
max_results: Type5.Optional(
|
|
2058
|
+
Type5.Number({
|
|
2059
|
+
description: "How many results to return (1\u201310). Default 5.",
|
|
2060
|
+
minimum: 1,
|
|
2061
|
+
maximum: 10
|
|
2062
|
+
})
|
|
2063
|
+
),
|
|
2064
|
+
topic: Type5.Optional(
|
|
2065
|
+
Type5.Union([Type5.Literal("general"), Type5.Literal("news")], {
|
|
2066
|
+
description: 'Search topic. Use "news" for recent/current events. Default "general".'
|
|
2067
|
+
})
|
|
2068
|
+
),
|
|
2069
|
+
search_depth: Type5.Optional(
|
|
2070
|
+
Type5.Union([Type5.Literal("basic"), Type5.Literal("advanced")], {
|
|
2071
|
+
description: '"advanced" digs deeper (slower, costs more credits); "basic" is usually enough. Default "basic".'
|
|
2072
|
+
})
|
|
2073
|
+
)
|
|
2074
|
+
});
|
|
2075
|
+
var webFetchParamsSchema = Type5.Object({
|
|
2076
|
+
urls: Type5.Array(
|
|
2077
|
+
Type5.String({ description: "An absolute http(s) URL." }),
|
|
2078
|
+
{
|
|
2079
|
+
description: "URLs to fetch and extract the main text from (1\u20135).",
|
|
2080
|
+
minItems: 1,
|
|
2081
|
+
maxItems: 5
|
|
2082
|
+
}
|
|
2083
|
+
)
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
// src/storage/web-search-prefs.ts
|
|
2087
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2088
|
+
import { join as join9 } from "path";
|
|
2089
|
+
var PREFS_PATH2 = join9(config.dataDir, "web-search.json");
|
|
2090
|
+
var cache4 = {};
|
|
2091
|
+
async function loadWebSearchPrefs() {
|
|
2092
|
+
try {
|
|
2093
|
+
const raw = await readFile4(PREFS_PATH2, "utf8");
|
|
2094
|
+
const parsed = JSON.parse(raw);
|
|
2095
|
+
cache4 = { tavilyApiKey: typeof parsed.tavilyApiKey === "string" ? parsed.tavilyApiKey : void 0 };
|
|
2096
|
+
} catch (err2) {
|
|
2097
|
+
cache4 = {};
|
|
2098
|
+
if (err2.code !== "ENOENT") {
|
|
2099
|
+
console.warn(`[web-search-prefs] ignoring unreadable ${PREFS_PATH2}:`, err2);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
function getTavilyApiKey() {
|
|
2104
|
+
const fromSettings = cache4.tavilyApiKey?.trim();
|
|
2105
|
+
if (fromSettings) return fromSettings;
|
|
2106
|
+
const fromEnv = process.env.TAVILY_API_KEY?.trim();
|
|
2107
|
+
return fromEnv || void 0;
|
|
2108
|
+
}
|
|
2109
|
+
function getKeyStatus() {
|
|
2110
|
+
const fromSettings = cache4.tavilyApiKey?.trim();
|
|
2111
|
+
const fromEnv = process.env.TAVILY_API_KEY?.trim();
|
|
2112
|
+
const live = fromSettings || fromEnv || void 0;
|
|
2113
|
+
const source = fromSettings ? "settings" : fromEnv ? "env" : "none";
|
|
2114
|
+
return {
|
|
2115
|
+
configured: Boolean(live),
|
|
2116
|
+
source,
|
|
2117
|
+
...live ? { hint: maskKey(live) } : {}
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function maskKey(key) {
|
|
2121
|
+
return `\u2026${key.slice(-4)}`;
|
|
2122
|
+
}
|
|
2123
|
+
async function setTavilyApiKey(key) {
|
|
2124
|
+
const trimmed = key.trim();
|
|
2125
|
+
cache4 = trimmed ? { tavilyApiKey: trimmed } : {};
|
|
2126
|
+
await save3();
|
|
2127
|
+
}
|
|
2128
|
+
async function clearTavilyApiKey() {
|
|
2129
|
+
cache4 = {};
|
|
2130
|
+
await save3();
|
|
2131
|
+
}
|
|
2132
|
+
async function save3() {
|
|
2133
|
+
await writeJsonAtomic(PREFS_PATH2, cache4, { mode: 384 });
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/extensions/web_search/client.ts
|
|
2137
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
2138
|
+
function baseUrl() {
|
|
2139
|
+
return process.env.TAVILY_BASE_URL ?? "https://api.tavily.com";
|
|
2140
|
+
}
|
|
2141
|
+
var WebSearchError = class extends Error {
|
|
2142
|
+
constructor(message) {
|
|
2143
|
+
super(message);
|
|
2144
|
+
this.name = "WebSearchError";
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
function apiKey() {
|
|
2148
|
+
const key = getTavilyApiKey();
|
|
2149
|
+
if (!key) {
|
|
2150
|
+
throw new WebSearchError(
|
|
2151
|
+
"Web search is not configured: add a Tavily API key in Settings \u2192 Web search, or set the TAVILY_API_KEY environment variable. Get a free key at https://app.tavily.com."
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
return key;
|
|
2155
|
+
}
|
|
2156
|
+
async function postJson(path, body, signal) {
|
|
2157
|
+
const key = apiKey();
|
|
2158
|
+
const ctl = new AbortController();
|
|
2159
|
+
const onAbort = () => ctl.abort(signal?.reason);
|
|
2160
|
+
if (signal) {
|
|
2161
|
+
if (signal.aborted) ctl.abort(signal.reason);
|
|
2162
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2163
|
+
}
|
|
2164
|
+
const timer = setTimeout(
|
|
2165
|
+
() => ctl.abort(new WebSearchError(`Tavily request timed out after ${DEFAULT_TIMEOUT_MS / 1e3}s.`)),
|
|
2166
|
+
DEFAULT_TIMEOUT_MS
|
|
2167
|
+
);
|
|
2168
|
+
let res;
|
|
2169
|
+
try {
|
|
2170
|
+
res = await fetch(`${baseUrl()}${path}`, {
|
|
2171
|
+
method: "POST",
|
|
2172
|
+
headers: {
|
|
2173
|
+
"content-type": "application/json",
|
|
2174
|
+
authorization: `Bearer ${key}`
|
|
2175
|
+
},
|
|
2176
|
+
body: JSON.stringify(body),
|
|
2177
|
+
signal: ctl.signal
|
|
2178
|
+
});
|
|
2179
|
+
} catch (err2) {
|
|
2180
|
+
if (signal?.aborted) throw err2;
|
|
2181
|
+
const reason = ctl.signal.reason;
|
|
2182
|
+
if (reason instanceof WebSearchError) throw reason;
|
|
2183
|
+
throw new WebSearchError(`Tavily request failed: ${errMsg(err2)}`);
|
|
2184
|
+
} finally {
|
|
2185
|
+
clearTimeout(timer);
|
|
2186
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
2187
|
+
}
|
|
2188
|
+
if (!res.ok) {
|
|
2189
|
+
throw new WebSearchError(await describeHttpError(res));
|
|
2190
|
+
}
|
|
2191
|
+
try {
|
|
2192
|
+
return await res.json();
|
|
2193
|
+
} catch {
|
|
2194
|
+
throw new WebSearchError(
|
|
2195
|
+
`Tavily returned a non-JSON response (HTTP ${res.status}) \u2014 the service may be down or returning an error page.`
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
async function describeHttpError(res) {
|
|
2200
|
+
let detail = "";
|
|
2201
|
+
try {
|
|
2202
|
+
const data = await res.json();
|
|
2203
|
+
const d = data?.detail ?? data?.error;
|
|
2204
|
+
if (typeof d === "string") detail = d;
|
|
2205
|
+
else if (d && typeof d === "object" && typeof d.error === "string") {
|
|
2206
|
+
detail = d.error;
|
|
2207
|
+
}
|
|
2208
|
+
} catch {
|
|
2209
|
+
}
|
|
2210
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
2211
|
+
if (res.status === 401 || res.status === 403) {
|
|
2212
|
+
return `Tavily rejected the API key (HTTP ${res.status})${suffix}. Check TAVILY_API_KEY.`;
|
|
2213
|
+
}
|
|
2214
|
+
if (res.status === 429) {
|
|
2215
|
+
return `Tavily rate limit / quota exceeded (HTTP 429)${suffix}.`;
|
|
2216
|
+
}
|
|
2217
|
+
return `Tavily request failed (HTTP ${res.status})${suffix}.`;
|
|
2218
|
+
}
|
|
2219
|
+
function errMsg(err2) {
|
|
2220
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
2221
|
+
}
|
|
2222
|
+
function tavilySearch(opts, signal) {
|
|
2223
|
+
return postJson(
|
|
2224
|
+
"/search",
|
|
2225
|
+
{
|
|
2226
|
+
query: opts.query,
|
|
2227
|
+
max_results: opts.maxResults,
|
|
2228
|
+
topic: opts.topic,
|
|
2229
|
+
search_depth: opts.searchDepth,
|
|
2230
|
+
include_answer: true
|
|
2231
|
+
},
|
|
2232
|
+
signal
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
function tavilyExtract(urls, signal) {
|
|
2236
|
+
return postJson("/extract", { urls }, signal);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// src/extensions/web_search/factory.ts
|
|
2240
|
+
var SNIPPET_CARD_CAP = 320;
|
|
2241
|
+
var SNIPPET_MODEL_CAP = 1024;
|
|
2242
|
+
var FETCH_PREVIEW_CAP = 2 * 1024;
|
|
2243
|
+
var FETCH_PER_URL_CAP = 8 * 1024;
|
|
2244
|
+
var FETCH_TOTAL_CAP = 24 * 1024;
|
|
2245
|
+
function clampInt(v, lo, hi, dflt) {
|
|
2246
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return dflt;
|
|
2247
|
+
return Math.max(lo, Math.min(hi, Math.round(v)));
|
|
2248
|
+
}
|
|
2249
|
+
function truncate(s, cap) {
|
|
2250
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}\u2026`;
|
|
2251
|
+
}
|
|
2252
|
+
var webSearchExtensionFactory = (pi) => {
|
|
2253
|
+
pi.registerTool({
|
|
2254
|
+
name: "web_search",
|
|
2255
|
+
label: "Web search",
|
|
2256
|
+
description: "Search the web and get back a short answer plus ranked results (title, URL, snippet). Use it for current events, external documentation, or facts you're unsure of or that may have changed since your training cutoff. Follow up with `web_fetch` to read a result's full page when the snippet isn't enough.",
|
|
2257
|
+
parameters: webSearchParamsSchema,
|
|
2258
|
+
executionMode: "parallel",
|
|
2259
|
+
promptSnippet: "web_search: search the web (answer + ranked results) for current or uncertain facts; pair with web_fetch to read a page in full.",
|
|
2260
|
+
promptGuidelines: [
|
|
2261
|
+
"Reach for web_search when the answer depends on current / post-cutoff information, external docs, or facts you can't verify from the repo or your own knowledge \u2014 not for things you already know.",
|
|
2262
|
+
'Phrase `query` like a search-engine query (keywords, entities), not a chat sentence. Set `topic: "news"` for recent events.',
|
|
2263
|
+
"Cite the URLs you relied on, and use web_fetch to read a page in full before trusting details beyond the snippet."
|
|
2264
|
+
],
|
|
2265
|
+
async execute(_toolCallId, params, signal) {
|
|
2266
|
+
const query = params.query.trim();
|
|
2267
|
+
if (!query) throw new WebSearchError("web_search needs a non-empty query.");
|
|
2268
|
+
const maxResults = clampInt(params.max_results, 1, 10, 5);
|
|
2269
|
+
const topic = params.topic === "news" ? "news" : "general";
|
|
2270
|
+
const searchDepth = params.search_depth === "advanced" ? "advanced" : "basic";
|
|
2271
|
+
const data = await tavilySearch({ query, maxResults, topic, searchDepth }, signal);
|
|
2272
|
+
const raw = (data.results ?? []).filter(
|
|
2273
|
+
(r) => typeof r.url === "string"
|
|
2274
|
+
);
|
|
2275
|
+
const answer = typeof data.answer === "string" && data.answer.trim() ? data.answer.trim() : void 0;
|
|
2276
|
+
const results = raw.map((r) => ({
|
|
2277
|
+
title: (r.title ?? "").trim() || r.url,
|
|
2278
|
+
url: r.url,
|
|
2279
|
+
content: truncate((r.content ?? "").trim(), SNIPPET_CARD_CAP),
|
|
2280
|
+
score: typeof r.score === "number" ? r.score : void 0,
|
|
2281
|
+
publishedDate: typeof r.published_date === "string" ? r.published_date : void 0
|
|
2282
|
+
}));
|
|
2283
|
+
const details = { version: 1, kind: "search", query, answer, results };
|
|
2284
|
+
return { content: [{ type: "text", text: formatSearch(query, answer, raw) }], details };
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
pi.registerTool({
|
|
2288
|
+
name: "web_fetch",
|
|
2289
|
+
label: "Web fetch",
|
|
2290
|
+
description: "Fetch one or more web pages and extract their main text as clean, readable content (stripped of HTML/navigation/boilerplate). Use it to read a page in full \u2014 a URL returned by web_search, a documentation link, or a URL the user gave you.",
|
|
2291
|
+
parameters: webFetchParamsSchema,
|
|
2292
|
+
executionMode: "parallel",
|
|
2293
|
+
promptSnippet: "web_fetch: fetch URL(s) and extract the main page text \u2014 read a web_search result or a user-given link in full.",
|
|
2294
|
+
promptGuidelines: [
|
|
2295
|
+
"Use web_fetch to read the full text of a page when a web_search snippet isn't enough, or whenever the user hands you a URL.",
|
|
2296
|
+
"Pass up to 5 absolute http(s) URLs in one call to read them together."
|
|
2297
|
+
],
|
|
2298
|
+
async execute(_toolCallId, params, signal) {
|
|
2299
|
+
const urls = params.urls.map((u) => u.trim()).filter(Boolean);
|
|
2300
|
+
if (urls.length === 0) throw new WebSearchError("web_fetch needs at least one non-empty URL.");
|
|
2301
|
+
const data = await tavilyExtract(urls, signal);
|
|
2302
|
+
const results = (data.results ?? []).filter((r) => typeof r.url === "string").map((r) => {
|
|
2303
|
+
const full = (r.raw_content ?? "").trim();
|
|
2304
|
+
return { url: r.url, content: truncate(full, FETCH_PREVIEW_CAP), chars: full.length };
|
|
2305
|
+
});
|
|
2306
|
+
const failed = (data.failed_results ?? []).filter((f) => typeof f.url === "string").map((f) => ({ url: f.url, error: (f.error ?? "extraction failed").toString() }));
|
|
2307
|
+
const details = { version: 1, kind: "fetch", results, failed };
|
|
2308
|
+
return { content: [{ type: "text", text: formatFetch(data) }], details };
|
|
2309
|
+
}
|
|
2310
|
+
});
|
|
2311
|
+
};
|
|
2312
|
+
function formatSearch(query, answer, results) {
|
|
2313
|
+
const lines = [];
|
|
2314
|
+
lines.push(`Search results for "${query}" (${results.length} result${results.length === 1 ? "" : "s"}):`);
|
|
2315
|
+
if (answer) {
|
|
2316
|
+
lines.push("");
|
|
2317
|
+
lines.push(`Answer: ${answer}`);
|
|
2318
|
+
}
|
|
2319
|
+
results.forEach((r, i) => {
|
|
2320
|
+
lines.push("");
|
|
2321
|
+
lines.push(`${i + 1}. ${(r.title ?? "").trim() || r.url}`);
|
|
2322
|
+
lines.push(` ${r.url}`);
|
|
2323
|
+
const snippet = (r.content ?? "").trim();
|
|
2324
|
+
if (snippet) lines.push(` ${truncate(snippet, SNIPPET_MODEL_CAP)}`);
|
|
2325
|
+
});
|
|
2326
|
+
if (results.length === 0) {
|
|
2327
|
+
lines.push("");
|
|
2328
|
+
lines.push("No results found.");
|
|
2329
|
+
}
|
|
2330
|
+
return lines.join("\n");
|
|
2331
|
+
}
|
|
2332
|
+
function formatFetch(data) {
|
|
2333
|
+
const results = (data.results ?? []).filter(
|
|
2334
|
+
(r) => typeof r.url === "string"
|
|
2335
|
+
);
|
|
2336
|
+
const failed = (data.failed_results ?? []).filter(
|
|
2337
|
+
(f) => typeof f.url === "string"
|
|
2338
|
+
);
|
|
2339
|
+
const lines = [];
|
|
2340
|
+
let budget = FETCH_TOTAL_CAP;
|
|
2341
|
+
for (const r of results) {
|
|
2342
|
+
const full = (r.raw_content ?? "").trim();
|
|
2343
|
+
const cap = Math.min(FETCH_PER_URL_CAP, budget);
|
|
2344
|
+
const slice = full.length > cap ? `${full.slice(0, cap)}\u2026` : full;
|
|
2345
|
+
budget -= Math.min(full.length, cap);
|
|
2346
|
+
lines.push(`## ${r.url}`);
|
|
2347
|
+
lines.push(slice || "(no extractable content)");
|
|
2348
|
+
lines.push("");
|
|
2349
|
+
if (budget <= 0) {
|
|
2350
|
+
lines.push("\u2026 (remaining pages omitted to fit the context budget)");
|
|
2351
|
+
break;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
for (const f of failed) {
|
|
2355
|
+
lines.push(`Failed to fetch ${f.url}: ${f.error ?? "extraction failed"}`);
|
|
2356
|
+
}
|
|
2357
|
+
if (results.length === 0 && failed.length === 0) lines.push("No content extracted.");
|
|
2358
|
+
return lines.join("\n").trim();
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/storage/builtin-extension-prefs.ts
|
|
2362
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2363
|
+
import { join as join10 } from "path";
|
|
2364
|
+
var PREFS_PATH3 = join10(config.dataDir, "builtin-extensions.json");
|
|
2365
|
+
var cache5 = { disabled: [] };
|
|
2366
|
+
async function loadBuiltinPrefs() {
|
|
2367
|
+
try {
|
|
2368
|
+
const raw = await readFile5(PREFS_PATH3, "utf8");
|
|
2369
|
+
const parsed = JSON.parse(raw);
|
|
2370
|
+
cache5 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
|
|
2371
|
+
} catch (err2) {
|
|
2372
|
+
cache5 = { disabled: [] };
|
|
2373
|
+
if (err2.code !== "ENOENT") {
|
|
2374
|
+
console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH3}:`, err2);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
function isBuiltinDisabled(id) {
|
|
2379
|
+
if (cache5.disabled.includes(id)) return true;
|
|
2380
|
+
if (id === "todo" && cache5.disabled.includes("plan")) return true;
|
|
2381
|
+
return false;
|
|
2382
|
+
}
|
|
2383
|
+
function getDisabledBuiltins() {
|
|
2384
|
+
return [...cache5.disabled];
|
|
2385
|
+
}
|
|
2386
|
+
async function setBuiltinEnabled(id, enabled) {
|
|
2387
|
+
const next = new Set(cache5.disabled);
|
|
2388
|
+
if (enabled) next.delete(id);
|
|
2389
|
+
else next.add(id);
|
|
2390
|
+
cache5 = { disabled: [...next] };
|
|
2391
|
+
await save4();
|
|
2392
|
+
}
|
|
2393
|
+
async function save4() {
|
|
2394
|
+
await writeJsonAtomic(PREFS_PATH3, cache5);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/extensions/index.ts
|
|
2398
|
+
var BUILTIN_EXTENSIONS = [
|
|
2399
|
+
{
|
|
2400
|
+
id: "todo",
|
|
2401
|
+
name: "Todo",
|
|
2402
|
+
description: "A CRUD task list for tracking multi-step work \u2014 adds the todo tool and the /todos command.",
|
|
2403
|
+
tools: ["todo"],
|
|
2404
|
+
commands: ["todos"],
|
|
2405
|
+
factory: todoExtensionFactory
|
|
2406
|
+
},
|
|
2407
|
+
{
|
|
2408
|
+
id: "ask_user",
|
|
2409
|
+
name: "Ask user",
|
|
2410
|
+
description: "Lets the agent pause and ask you a structured multiple-choice question \u2014 adds the ask_user tool.",
|
|
2411
|
+
tools: ["ask_user"],
|
|
2412
|
+
commands: [],
|
|
2413
|
+
factory: askUserExtensionFactory
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
id: "artifact",
|
|
2417
|
+
name: "Artifacts",
|
|
2418
|
+
description: "Lets the agent publish substantial, self-contained content \u2014 web pages, SVG diagrams, documents, code files \u2014 as versioned artifacts rendered in a side panel. Adds the create_artifact tool.",
|
|
2419
|
+
tools: ["create_artifact"],
|
|
2420
|
+
commands: [],
|
|
2421
|
+
factory: artifactExtensionFactory
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
id: "subagent",
|
|
2425
|
+
name: "Subagent",
|
|
2426
|
+
description: "Lets the agent delegate self-contained tasks to isolated child agents (separate pinned-pi processes; only their final report returns). Builtin presets scout/worker/reviewer; user agents from ~/.pi/agent/agents/*.md. Adds the subagent tool.",
|
|
2427
|
+
tools: ["subagent"],
|
|
2428
|
+
commands: [],
|
|
2429
|
+
factory: subagentExtensionFactory
|
|
2430
|
+
},
|
|
2431
|
+
{
|
|
2432
|
+
id: "web",
|
|
2433
|
+
name: "Web search",
|
|
2434
|
+
description: "Lets the agent search the web and read pages \u2014 adds the web_search and web_fetch tools (backed by Tavily; needs the TAVILY_API_KEY environment variable).",
|
|
2435
|
+
tools: ["web_search", "web_fetch"],
|
|
2436
|
+
commands: [],
|
|
2437
|
+
factory: webSearchExtensionFactory
|
|
2438
|
+
}
|
|
2439
|
+
];
|
|
2440
|
+
function gate(def) {
|
|
2441
|
+
return (pi) => {
|
|
2442
|
+
if (isBuiltinDisabled(def.id)) return;
|
|
2443
|
+
return def.factory(pi);
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
var builtinExtensionFactories = BUILTIN_EXTENSIONS.map(gate);
|
|
2447
|
+
|
|
2448
|
+
// src/extensions/ask_user/cleanup.ts
|
|
2449
|
+
var CUSTOM_TYPE = "ask_user-restart-cancelled";
|
|
2450
|
+
function reconcileAfterRestart(sessionManager) {
|
|
2451
|
+
const branch = sessionManager.getBranch();
|
|
2452
|
+
if (branch.length === 0) return;
|
|
2453
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
2454
|
+
const danglingIds = [];
|
|
2455
|
+
const danglingAlreadyHandled = /* @__PURE__ */ new Set();
|
|
2456
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
2457
|
+
const entry = branch[i];
|
|
2458
|
+
if (entry.type === "custom_message") {
|
|
2459
|
+
const cm = entry;
|
|
2460
|
+
if (cm.customType === CUSTOM_TYPE) {
|
|
2461
|
+
const ids = cm.details?.ids;
|
|
2462
|
+
if (Array.isArray(ids)) {
|
|
2463
|
+
for (const id of ids) {
|
|
2464
|
+
if (typeof id === "string") danglingAlreadyHandled.add(id);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
if (entry.type !== "message") continue;
|
|
2471
|
+
const msg = entry.message;
|
|
2472
|
+
if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
2473
|
+
satisfied.add(msg.toolCallId);
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
2477
|
+
for (const block of msg.content) {
|
|
2478
|
+
if (!block || typeof block !== "object") continue;
|
|
2479
|
+
const b = block;
|
|
2480
|
+
if (b.type !== "toolCall") continue;
|
|
2481
|
+
if (b.name !== "ask_user") continue;
|
|
858
2482
|
if (typeof b.id !== "string") continue;
|
|
859
2483
|
if (satisfied.has(b.id)) continue;
|
|
860
2484
|
if (danglingAlreadyHandled.has(b.id)) continue;
|
|
@@ -876,6 +2500,116 @@ function reconcileAfterRestart(sessionManager) {
|
|
|
876
2500
|
);
|
|
877
2501
|
}
|
|
878
2502
|
|
|
2503
|
+
// src/extensions/subagent/cleanup.ts
|
|
2504
|
+
import { execFile as execFile2 } from "child_process";
|
|
2505
|
+
import { readdir, readFile as readFile6, rm as rm4 } from "fs/promises";
|
|
2506
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2507
|
+
import { join as join11 } from "path";
|
|
2508
|
+
var CUSTOM_TYPE2 = "subagent-restart-cancelled";
|
|
2509
|
+
function reconcileAfterRestart2(sessionManager) {
|
|
2510
|
+
const branch = sessionManager.getBranch();
|
|
2511
|
+
if (branch.length === 0) return;
|
|
2512
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
2513
|
+
const danglingIds = [];
|
|
2514
|
+
const danglingAlreadyHandled = /* @__PURE__ */ new Set();
|
|
2515
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
2516
|
+
const entry = branch[i];
|
|
2517
|
+
if (entry.type === "custom_message") {
|
|
2518
|
+
const cm = entry;
|
|
2519
|
+
if (cm.customType === CUSTOM_TYPE2) {
|
|
2520
|
+
const ids = cm.details?.ids;
|
|
2521
|
+
if (Array.isArray(ids)) {
|
|
2522
|
+
for (const id of ids) {
|
|
2523
|
+
if (typeof id === "string") danglingAlreadyHandled.add(id);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
if (entry.type !== "message") continue;
|
|
2530
|
+
const msg = entry.message;
|
|
2531
|
+
if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
2532
|
+
satisfied.add(msg.toolCallId);
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
2536
|
+
for (const block of msg.content) {
|
|
2537
|
+
if (!block || typeof block !== "object") continue;
|
|
2538
|
+
const b = block;
|
|
2539
|
+
if (b.type !== "toolCall") continue;
|
|
2540
|
+
if (b.name !== "subagent") continue;
|
|
2541
|
+
if (typeof b.id !== "string") continue;
|
|
2542
|
+
if (satisfied.has(b.id)) continue;
|
|
2543
|
+
if (danglingAlreadyHandled.has(b.id)) continue;
|
|
2544
|
+
danglingIds.push(b.id);
|
|
2545
|
+
}
|
|
2546
|
+
break;
|
|
2547
|
+
}
|
|
2548
|
+
if (msg.role === "user") break;
|
|
2549
|
+
}
|
|
2550
|
+
if (danglingIds.length === 0) return;
|
|
2551
|
+
const idList = danglingIds.join(", ");
|
|
2552
|
+
const text = `[pi-pilot] Your previous subagent delegation(s) [${idList}] were cancelled because the server restarted mid-run. The child agent's work may be partially applied to the working tree \u2014 verify before assuming anything happened. Re-delegate if the task still matters.`;
|
|
2553
|
+
sessionManager.appendCustomMessageEntry(
|
|
2554
|
+
CUSTOM_TYPE2,
|
|
2555
|
+
text,
|
|
2556
|
+
true,
|
|
2557
|
+
// display flag — no-op in pi-pilot, harmless in the TUI
|
|
2558
|
+
{ ids: danglingIds }
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir2()) {
|
|
2562
|
+
let dirNames;
|
|
2563
|
+
try {
|
|
2564
|
+
dirNames = (await readdir(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
|
|
2565
|
+
} catch {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
let swept = 0;
|
|
2569
|
+
for (const name of dirNames) {
|
|
2570
|
+
const dir = join11(rootDir, name);
|
|
2571
|
+
const pidRaw = await readFile6(join11(dir, "pid"), "utf8").catch(() => "");
|
|
2572
|
+
const [childLine, ownerLine] = pidRaw.split("\n");
|
|
2573
|
+
const childPid = Number.parseInt((childLine ?? "").trim(), 10);
|
|
2574
|
+
const ownerPid = Number.parseInt((ownerLine ?? "").trim(), 10);
|
|
2575
|
+
if (Number.isInteger(ownerPid) && ownerPid > 1 && await isLiveNodeProcess(ownerPid)) {
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
try {
|
|
2579
|
+
if (Number.isInteger(childPid) && childPid > 1 && await isLiveNodeProcess(childPid)) {
|
|
2580
|
+
try {
|
|
2581
|
+
process.kill(childPid, "SIGTERM");
|
|
2582
|
+
swept++;
|
|
2583
|
+
} catch {
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
} finally {
|
|
2587
|
+
await rm4(dir, { recursive: true, force: true }).catch(() => {
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (swept > 0) {
|
|
2592
|
+
console.warn(`[subagent] swept ${swept} orphaned child(ren) from a previous run`);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function isLiveNodeProcess(pid) {
|
|
2596
|
+
try {
|
|
2597
|
+
process.kill(pid, 0);
|
|
2598
|
+
} catch {
|
|
2599
|
+
return Promise.resolve(false);
|
|
2600
|
+
}
|
|
2601
|
+
return new Promise((resolve8) => {
|
|
2602
|
+
execFile2("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
|
|
2603
|
+
if (err2) {
|
|
2604
|
+
resolve8(false);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
const name = stdout.trim().toLowerCase();
|
|
2608
|
+
resolve8(name === "node" || name === "pi");
|
|
2609
|
+
});
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
879
2613
|
// src/ws/bridge.ts
|
|
880
2614
|
function translatePiEvent(ev) {
|
|
881
2615
|
switch (ev.type) {
|
|
@@ -917,13 +2651,16 @@ function translatePiEvent(ev) {
|
|
|
917
2651
|
toolName: ev.toolName,
|
|
918
2652
|
args: ev.args
|
|
919
2653
|
};
|
|
920
|
-
case "tool_execution_update":
|
|
2654
|
+
case "tool_execution_update": {
|
|
2655
|
+
const updateDetails = shouldForwardDetails(ev.toolName) ? ev.partialResult?.details : void 0;
|
|
921
2656
|
return {
|
|
922
2657
|
kind: "tool_execution_update",
|
|
923
2658
|
toolCallId: ev.toolCallId,
|
|
924
2659
|
toolName: ev.toolName,
|
|
925
|
-
partialText: extractText(ev.partialResult)
|
|
2660
|
+
partialText: extractText(ev.partialResult),
|
|
2661
|
+
...updateDetails !== void 0 ? { details: updateDetails } : {}
|
|
926
2662
|
};
|
|
2663
|
+
}
|
|
927
2664
|
case "tool_execution_end": {
|
|
928
2665
|
const details = shouldForwardDetails(ev.toolName) ? ev.result?.details : void 0;
|
|
929
2666
|
return {
|
|
@@ -1036,6 +2773,37 @@ function inFlightAssistantSnapshot(streamingMessage) {
|
|
|
1036
2773
|
}
|
|
1037
2774
|
return events;
|
|
1038
2775
|
}
|
|
2776
|
+
function inFlightRunningToolsSnapshot(pendingToolCalls, messages) {
|
|
2777
|
+
const pending2 = new Set(pendingToolCalls);
|
|
2778
|
+
if (pending2.size === 0) return [];
|
|
2779
|
+
const infoById = /* @__PURE__ */ new Map();
|
|
2780
|
+
for (const message of messages) {
|
|
2781
|
+
if (!message || typeof message !== "object") continue;
|
|
2782
|
+
if (message.role !== "assistant") continue;
|
|
2783
|
+
const content = message.content;
|
|
2784
|
+
if (!Array.isArray(content)) continue;
|
|
2785
|
+
for (const block of content) {
|
|
2786
|
+
if (!block || typeof block !== "object") continue;
|
|
2787
|
+
const b = block;
|
|
2788
|
+
if (b.type === "toolCall" && typeof b.id === "string" && pending2.has(b.id)) {
|
|
2789
|
+
infoById.set(b.id, { name: typeof b.name === "string" ? b.name : "tool", args: b.arguments });
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
const events = [];
|
|
2794
|
+
for (const toolCallId of pending2) {
|
|
2795
|
+
const info = infoById.get(toolCallId);
|
|
2796
|
+
if (!info) continue;
|
|
2797
|
+
if (info.name === "ask_user") continue;
|
|
2798
|
+
events.push({
|
|
2799
|
+
kind: "tool_execution_start",
|
|
2800
|
+
toolCallId,
|
|
2801
|
+
toolName: info.name,
|
|
2802
|
+
args: info.args
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
return events;
|
|
2806
|
+
}
|
|
1039
2807
|
function inFlightToolCallsSnapshot(sessionFile) {
|
|
1040
2808
|
const pending2 = snapshotForSession(sessionFile);
|
|
1041
2809
|
return pending2.map((p) => ({
|
|
@@ -1045,7 +2813,13 @@ function inFlightToolCallsSnapshot(sessionFile) {
|
|
|
1045
2813
|
args: p.args
|
|
1046
2814
|
}));
|
|
1047
2815
|
}
|
|
1048
|
-
var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
|
|
2816
|
+
var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
|
|
2817
|
+
"ask_user",
|
|
2818
|
+
"todo",
|
|
2819
|
+
"subagent",
|
|
2820
|
+
"web_search",
|
|
2821
|
+
"web_fetch"
|
|
2822
|
+
]);
|
|
1049
2823
|
function shouldForwardDetails(toolName) {
|
|
1050
2824
|
return DETAILS_FORWARD_WHITELIST.has(toolName);
|
|
1051
2825
|
}
|
|
@@ -1146,6 +2920,7 @@ var ExtensionUIBridge = class {
|
|
|
1146
2920
|
|
|
1147
2921
|
// src/workspace-manager.ts
|
|
1148
2922
|
var EXTENSIONS_ENABLED = process.env.PI_PILOT_ENABLE_EXTENSIONS === "1";
|
|
2923
|
+
var MAX_LIVE_RUNTIMES = 12;
|
|
1149
2924
|
var createRuntime = async ({
|
|
1150
2925
|
cwd,
|
|
1151
2926
|
sessionManager,
|
|
@@ -1169,18 +2944,36 @@ var createRuntime = async ({
|
|
|
1169
2944
|
diagnostics: services.diagnostics
|
|
1170
2945
|
};
|
|
1171
2946
|
};
|
|
1172
|
-
var
|
|
1173
|
-
|
|
2947
|
+
var KEY_SEP = "\0";
|
|
2948
|
+
var SessionRuntimeManager = class {
|
|
2949
|
+
/** All live runtimes, keyed by `runtimeKey`. */
|
|
2950
|
+
runtimes = /* @__PURE__ */ new Map();
|
|
2951
|
+
/** Per-build lock keyed by `runtimeKey` to serialize concurrent creations. */
|
|
2952
|
+
pending = /* @__PURE__ */ new Map();
|
|
2953
|
+
/** The runtime the hub last made primary, per workspace. Drives `get`. */
|
|
2954
|
+
activeByWorkspace = /* @__PURE__ */ new Map();
|
|
1174
2955
|
/**
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
2956
|
+
* WS subscribers, keyed by workspaceId (not runtimeKey): a connection
|
|
2957
|
+
* viewing any session of a workspace receives that workspace's
|
|
2958
|
+
* server-initiated broadcasts (extension errors, context_usage). Owned by
|
|
2959
|
+
* the manager so it can pre-exist any runtime build (extensions may fire
|
|
2960
|
+
* `onError` from `session_start` before any client subscribed).
|
|
1179
2961
|
*/
|
|
1180
2962
|
subscribers = /* @__PURE__ */ new Map();
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
2963
|
+
touchSeq = 0;
|
|
2964
|
+
/** `runtimeKey` for a (workspace, session identity). */
|
|
2965
|
+
keyOf(workspaceId, sessionIdentity) {
|
|
2966
|
+
return `${workspaceId}${KEY_SEP}${sessionIdentity}`;
|
|
2967
|
+
}
|
|
2968
|
+
/** `runtimeKey` for a built runtime, from its session file (or sessionId). */
|
|
2969
|
+
keyForRuntime(workspaceId, runtime) {
|
|
2970
|
+
const file = runtime.session.sessionFile;
|
|
2971
|
+
return this.keyOf(workspaceId, file ? resolve4(file) : runtime.session.sessionId);
|
|
2972
|
+
}
|
|
2973
|
+
/** Public so the WS hub derives the exact same key for a returned runtime. */
|
|
2974
|
+
runtimeKeyFor(workspaceId, runtime) {
|
|
2975
|
+
return this.keyForRuntime(workspaceId, runtime);
|
|
2976
|
+
}
|
|
1184
2977
|
getOrCreateSubscriberSet(workspaceId) {
|
|
1185
2978
|
let set = this.subscribers.get(workspaceId);
|
|
1186
2979
|
if (!set) {
|
|
@@ -1189,64 +2982,187 @@ var WorkspaceManager = class {
|
|
|
1189
2982
|
}
|
|
1190
2983
|
return set;
|
|
1191
2984
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
try {
|
|
1200
|
-
const state = await p;
|
|
1201
|
-
this.states.set(workspaceId, state);
|
|
1202
|
-
return state.runtime;
|
|
1203
|
-
} finally {
|
|
1204
|
-
this.pending.delete(workspaceId);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
async build(workspaceId) {
|
|
1208
|
-
const ws = await getWorkspace(workspaceId);
|
|
1209
|
-
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
1210
|
-
const sessionManager = SessionManager.continueRecent(ws.path);
|
|
2985
|
+
/**
|
|
2986
|
+
* Build (but do not register) a runtime for `workspaceId` from a
|
|
2987
|
+
* SessionManager factory. Binds the UI bridge + onError and runs ask_user
|
|
2988
|
+
* post-restart cleanup. The bridge broadcasts to the workspace's subscriber
|
|
2989
|
+
* set, resolved lazily so it works even if the set is created later.
|
|
2990
|
+
*/
|
|
2991
|
+
async buildState(workspaceId, cwd, makeSessionManager) {
|
|
1211
2992
|
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
1212
|
-
cwd
|
|
1213
|
-
agentDir:
|
|
1214
|
-
sessionManager
|
|
2993
|
+
cwd,
|
|
2994
|
+
agentDir: getAgentDir2(),
|
|
2995
|
+
sessionManager: makeSessionManager()
|
|
1215
2996
|
});
|
|
1216
|
-
const subscribers = this.getOrCreateSubscriberSet(workspaceId);
|
|
1217
2997
|
const bridge = new ExtensionUIBridge();
|
|
1218
|
-
|
|
2998
|
+
await this.bindExtensions(workspaceId, runtime, bridge);
|
|
2999
|
+
reapplyToolPrefs(workspaceId, runtime.session);
|
|
3000
|
+
safeReconcileBuiltins(workspaceId, runtime.session.sessionManager);
|
|
3001
|
+
return {
|
|
3002
|
+
runtime,
|
|
3003
|
+
bridge,
|
|
3004
|
+
workspaceId,
|
|
3005
|
+
sessionPath: runtime.session.sessionFile ? resolve4(runtime.session.sessionFile) : null,
|
|
3006
|
+
touchedAt: ++this.touchSeq
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
/** Bind (or re-bind, after a fork) the UI context + onError on a session. */
|
|
3010
|
+
async bindExtensions(workspaceId, runtime, bridge) {
|
|
3011
|
+
const onError = (err2) => {
|
|
1219
3012
|
const msg = {
|
|
1220
3013
|
type: "extension_error",
|
|
1221
3014
|
workspaceId,
|
|
1222
|
-
extensionPath:
|
|
1223
|
-
event:
|
|
1224
|
-
message:
|
|
3015
|
+
extensionPath: err2.extensionPath,
|
|
3016
|
+
event: err2.event,
|
|
3017
|
+
message: err2.error
|
|
1225
3018
|
};
|
|
1226
|
-
|
|
3019
|
+
const set = this.subscribers.get(workspaceId);
|
|
3020
|
+
if (set) broadcastTo(set, msg);
|
|
1227
3021
|
console.error(
|
|
1228
|
-
`[ext-error] ${workspaceId} ${
|
|
1229
|
-
${
|
|
3022
|
+
`[ext-error] ${workspaceId} ${err2.extensionPath}@${err2.event}: ${err2.error}` + (err2.stack ? `
|
|
3023
|
+
${err2.stack}` : "")
|
|
1230
3024
|
);
|
|
1231
3025
|
};
|
|
1232
3026
|
await runtime.session.bindExtensions({ uiContext: bridge, onError });
|
|
1233
|
-
safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
|
|
1234
|
-
runtime.setRebindSession(async () => {
|
|
1235
|
-
await runtime.session.bindExtensions({ uiContext: bridge, onError });
|
|
1236
|
-
cancelPendingExcept(runtime.session.sessionFile ?? null);
|
|
1237
|
-
safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
|
|
1238
|
-
this.notifySessionReplaced(workspaceId);
|
|
1239
|
-
});
|
|
1240
|
-
return { runtime, bridge };
|
|
1241
3027
|
}
|
|
3028
|
+
/** Register a freshly-built state under its session key, deduping against a
|
|
3029
|
+
* concurrent build of the same session. Returns the winning state. */
|
|
3030
|
+
async adopt(state) {
|
|
3031
|
+
const key = this.keyForRuntime(state.workspaceId, state.runtime);
|
|
3032
|
+
const existing = this.runtimes.get(key);
|
|
3033
|
+
if (existing) {
|
|
3034
|
+
await this.disposeState(state);
|
|
3035
|
+
return existing;
|
|
3036
|
+
}
|
|
3037
|
+
this.runtimes.set(key, state);
|
|
3038
|
+
this.evictIfOverCap(key);
|
|
3039
|
+
return state;
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Ensure a runtime exists for the target session and return it.
|
|
3043
|
+
*
|
|
3044
|
+
* - With `sessionPath`: opens that specific session (deduped by key).
|
|
3045
|
+
* - Without: returns the workspace's active/any live runtime, or builds the
|
|
3046
|
+
* "continue recent" default if none exists yet.
|
|
3047
|
+
*
|
|
3048
|
+
* Does NOT change the active pointer — the hub owns that via `setActive`.
|
|
3049
|
+
*/
|
|
3050
|
+
async getOrCreate(workspaceId, sessionPath) {
|
|
3051
|
+
const ws = await getWorkspace(workspaceId);
|
|
3052
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
3053
|
+
if (sessionPath) {
|
|
3054
|
+
if (!isAbsolute2(sessionPath)) throw new Error("Session path must be absolute");
|
|
3055
|
+
const resolved = resolve4(sessionPath);
|
|
3056
|
+
const key = this.keyOf(workspaceId, resolved);
|
|
3057
|
+
const existing2 = this.runtimes.get(key);
|
|
3058
|
+
if (existing2) {
|
|
3059
|
+
existing2.touchedAt = ++this.touchSeq;
|
|
3060
|
+
return existing2.runtime;
|
|
3061
|
+
}
|
|
3062
|
+
const inflight4 = this.pending.get(key);
|
|
3063
|
+
if (inflight4) return (await inflight4).runtime;
|
|
3064
|
+
const p2 = this.buildState(
|
|
3065
|
+
workspaceId,
|
|
3066
|
+
ws.path,
|
|
3067
|
+
() => SessionManager.open(resolved, void 0, ws.path)
|
|
3068
|
+
).then((s) => this.adopt(s));
|
|
3069
|
+
this.pending.set(key, p2);
|
|
3070
|
+
try {
|
|
3071
|
+
return (await p2).runtime;
|
|
3072
|
+
} finally {
|
|
3073
|
+
this.pending.delete(key);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
const existing = this.get(workspaceId);
|
|
3077
|
+
if (existing) return existing;
|
|
3078
|
+
const defaultKey = this.keyOf(workspaceId, "<default>");
|
|
3079
|
+
const inflight3 = this.pending.get(defaultKey);
|
|
3080
|
+
if (inflight3) return (await inflight3).runtime;
|
|
3081
|
+
const p = this.buildState(
|
|
3082
|
+
workspaceId,
|
|
3083
|
+
ws.path,
|
|
3084
|
+
() => SessionManager.continueRecent(ws.path)
|
|
3085
|
+
).then((s) => this.adopt(s));
|
|
3086
|
+
this.pending.set(defaultKey, p);
|
|
3087
|
+
try {
|
|
3088
|
+
return (await p).runtime;
|
|
3089
|
+
} finally {
|
|
3090
|
+
this.pending.delete(defaultKey);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
/** Create a brand-new empty session + runtime for the workspace (does not
|
|
3094
|
+
* touch any existing runtime, so a streaming session keeps running). */
|
|
3095
|
+
async createSession(workspaceId) {
|
|
3096
|
+
const ws = await getWorkspace(workspaceId);
|
|
3097
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
3098
|
+
const state = await this.buildState(
|
|
3099
|
+
workspaceId,
|
|
3100
|
+
ws.path,
|
|
3101
|
+
() => SessionManager.create(ws.path)
|
|
3102
|
+
);
|
|
3103
|
+
return (await this.adopt(state)).runtime;
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Fork an existing session at `entryId` into a new branched session and
|
|
3107
|
+
* return a runtime bound to the branch. The source session's own runtime
|
|
3108
|
+
* (if any) is untouched. Returns `{ cancelled: true }` if pi cancelled the
|
|
3109
|
+
* fork (e.g. a `session_before_switch` veto).
|
|
3110
|
+
*/
|
|
3111
|
+
async fork(workspaceId, sourceSessionPath, entryId) {
|
|
3112
|
+
const ws = await getWorkspace(workspaceId);
|
|
3113
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
3114
|
+
if (!isAbsolute2(sourceSessionPath)) throw new Error("Session path must be absolute");
|
|
3115
|
+
const state = await this.buildState(
|
|
3116
|
+
workspaceId,
|
|
3117
|
+
ws.path,
|
|
3118
|
+
() => SessionManager.open(resolve4(sourceSessionPath), void 0, ws.path)
|
|
3119
|
+
);
|
|
3120
|
+
let result;
|
|
3121
|
+
try {
|
|
3122
|
+
result = await state.runtime.fork(entryId);
|
|
3123
|
+
} catch (err2) {
|
|
3124
|
+
await this.disposeState(state);
|
|
3125
|
+
throw err2;
|
|
3126
|
+
}
|
|
3127
|
+
if (result.cancelled) {
|
|
3128
|
+
await this.disposeState(state);
|
|
3129
|
+
return { cancelled: true };
|
|
3130
|
+
}
|
|
3131
|
+
await this.bindExtensions(workspaceId, state.runtime, state.bridge);
|
|
3132
|
+
safeReconcileBuiltins(workspaceId, state.runtime.session.sessionManager);
|
|
3133
|
+
state.sessionPath = state.runtime.session.sessionFile ? resolve4(state.runtime.session.sessionFile) : null;
|
|
3134
|
+
const winner = await this.adopt(state);
|
|
3135
|
+
return { cancelled: false, runtime: winner.runtime };
|
|
3136
|
+
}
|
|
3137
|
+
/** The active session's runtime for this workspace (hub-designated), or any
|
|
3138
|
+
* live runtime for it, or undefined. Used by per-workspace REST routes. */
|
|
1242
3139
|
get(workspaceId) {
|
|
1243
|
-
|
|
3140
|
+
const activeKey = this.activeByWorkspace.get(workspaceId);
|
|
3141
|
+
if (activeKey) {
|
|
3142
|
+
const active = this.runtimes.get(activeKey);
|
|
3143
|
+
if (active) return active.runtime;
|
|
3144
|
+
}
|
|
3145
|
+
for (const state of this.runtimes.values()) {
|
|
3146
|
+
if (state.workspaceId === workspaceId) return state.runtime;
|
|
3147
|
+
}
|
|
3148
|
+
return void 0;
|
|
3149
|
+
}
|
|
3150
|
+
/** The runtime bound to a specific (workspace, session), if live. */
|
|
3151
|
+
getForSession(workspaceId, sessionPath) {
|
|
3152
|
+
return this.runtimes.get(this.keyOf(workspaceId, resolve4(sessionPath)))?.runtime;
|
|
3153
|
+
}
|
|
3154
|
+
/** Mark `runtime` as the active session for its workspace (hub on primary
|
|
3155
|
+
* bind), so per-workspace routes resolve to it. */
|
|
3156
|
+
setActive(workspaceId, runtime) {
|
|
3157
|
+
const key = this.keyForRuntime(workspaceId, runtime);
|
|
3158
|
+
this.activeByWorkspace.set(workspaceId, key);
|
|
3159
|
+
const state = this.runtimes.get(key);
|
|
3160
|
+
if (state) state.touchedAt = ++this.touchSeq;
|
|
1244
3161
|
}
|
|
1245
3162
|
/**
|
|
1246
|
-
* Register a WS connection as a subscriber for `workspaceId
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
* subscribers.
|
|
3163
|
+
* Register a WS connection as a subscriber for `workspaceId` (server-
|
|
3164
|
+
* initiated broadcasts: extension errors, context_usage). Safe to call
|
|
3165
|
+
* before any runtime build.
|
|
1250
3166
|
*/
|
|
1251
3167
|
addSubscriber(workspaceId, ws) {
|
|
1252
3168
|
this.getOrCreateSubscriberSet(workspaceId).add(ws);
|
|
@@ -1257,60 +3173,44 @@ ${err.stack}` : "")
|
|
|
1257
3173
|
set.delete(ws);
|
|
1258
3174
|
if (set.size === 0) this.subscribers.delete(workspaceId);
|
|
1259
3175
|
}
|
|
1260
|
-
/**
|
|
1261
|
-
*
|
|
1262
|
-
* workspace. Used by API handlers that mutate runtime state and need
|
|
1263
|
-
* to refresh derived snapshots (e.g. `context_usage` after `setModel`,
|
|
1264
|
-
* which pi's event stream doesn't surface unless thinking-level also
|
|
1265
|
-
* clamps).
|
|
1266
|
-
*/
|
|
3176
|
+
/** Fan a server-initiated message out to every WS subscribed to the
|
|
3177
|
+
* workspace (e.g. context_usage after setModel). */
|
|
1267
3178
|
broadcast(workspaceId, msg) {
|
|
1268
3179
|
const set = this.subscribers.get(workspaceId);
|
|
1269
3180
|
if (!set || set.size === 0) return;
|
|
1270
3181
|
broadcastTo(set, msg);
|
|
1271
3182
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
};
|
|
1287
|
-
}
|
|
1288
|
-
notifySessionReplaced(workspaceId) {
|
|
1289
|
-
const listeners = this.rebindListeners.get(workspaceId);
|
|
1290
|
-
if (!listeners) return;
|
|
1291
|
-
for (const listener of [...listeners]) {
|
|
1292
|
-
try {
|
|
1293
|
-
listener();
|
|
1294
|
-
} catch (e) {
|
|
1295
|
-
console.error(`[wm] rebind listener for ${workspaceId} failed:`, e);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Check that `sessionPath` belongs to the given workspace's session list.
|
|
3185
|
+
* Returns an error string if validation fails, or `null` if the path is
|
|
3186
|
+
* owned by this workspace.
|
|
3187
|
+
*/
|
|
3188
|
+
async validateSessionOwnership(workspaceId, sessionPath) {
|
|
3189
|
+
if (!isAbsolute2(sessionPath)) return "session path must be absolute";
|
|
3190
|
+
const ws = await getWorkspace(workspaceId);
|
|
3191
|
+
if (!ws) return `workspace not found: ${workspaceId}`;
|
|
3192
|
+
const sessions = await SessionManager.list(ws.path);
|
|
3193
|
+
const resolved = resolve4(sessionPath);
|
|
3194
|
+
const found = sessions.some((s) => resolve4(s.path) === resolved);
|
|
3195
|
+
if (!found) return `session not found in workspace: ${sessionPath}`;
|
|
3196
|
+
return null;
|
|
1298
3197
|
}
|
|
1299
3198
|
async listSessions(workspaceId) {
|
|
1300
3199
|
const ws = await getWorkspace(workspaceId);
|
|
1301
3200
|
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
3201
|
+
const streaming = /* @__PURE__ */ new Set();
|
|
3202
|
+
for (const state of this.runtimes.values()) {
|
|
3203
|
+
if (state.workspaceId !== workspaceId) continue;
|
|
3204
|
+
if (state.sessionPath && state.runtime.session.isStreaming) {
|
|
3205
|
+
streaming.add(state.sessionPath);
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
1302
3208
|
const sessions = await SessionManager.list(ws.path);
|
|
1303
|
-
return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map(toSessionSummary);
|
|
3209
|
+
return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(resolve4(info.path))));
|
|
1304
3210
|
}
|
|
1305
3211
|
getSessionHistory(workspaceId, sessionPath) {
|
|
1306
|
-
const runtime = this.
|
|
3212
|
+
const runtime = sessionPath ? this.getForSession(workspaceId, sessionPath) : this.get(workspaceId);
|
|
1307
3213
|
if (!runtime) return { items: [], isStreaming: false };
|
|
1308
|
-
if (sessionPath) {
|
|
1309
|
-
const activeFile = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
|
|
1310
|
-
if (activeFile !== resolve2(sessionPath)) {
|
|
1311
|
-
return { items: [], isStreaming: false };
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
3214
|
const isStreaming = runtime.session.isStreaming ?? false;
|
|
1315
3215
|
const branch = runtime.session.sessionManager.getBranch();
|
|
1316
3216
|
const items = [];
|
|
@@ -1321,7 +3221,7 @@ ${err.stack}` : "")
|
|
|
1321
3221
|
const role = msg.role;
|
|
1322
3222
|
if (role === "user") {
|
|
1323
3223
|
const text = extractUserText2(msg);
|
|
1324
|
-
if (text) items.push({ kind: "user", text });
|
|
3224
|
+
if (text) items.push({ kind: "user", text, entryId: entry.id });
|
|
1325
3225
|
} else if (role === "assistant") {
|
|
1326
3226
|
const { text, thinking, toolCalls } = extractAssistantContent(
|
|
1327
3227
|
msg
|
|
@@ -1359,15 +3259,13 @@ ${err.stack}` : "")
|
|
|
1359
3259
|
/**
|
|
1360
3260
|
* Delete a session JSONL file belonging to this workspace.
|
|
1361
3261
|
*
|
|
1362
|
-
*
|
|
1363
|
-
* can map them to the right status code:
|
|
3262
|
+
* HTTP-tagged errors (HttpError) map to status codes at the route layer:
|
|
1364
3263
|
* - 400: sessionPath not absolute
|
|
1365
3264
|
* - 404: workspace gone, or session not in this workspace's list
|
|
1366
|
-
* - 409:
|
|
3265
|
+
* - 409: a live runtime is bound to it and is streaming (stop it first)
|
|
1367
3266
|
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
* success — the goal state has been reached.
|
|
3267
|
+
* If a live but idle runtime is bound to the session, it is disposed before
|
|
3268
|
+
* the file is unlinked. Idempotent on ENOENT.
|
|
1371
3269
|
*/
|
|
1372
3270
|
async deleteSession(workspaceId, sessionPath) {
|
|
1373
3271
|
const ws = await getWorkspace(workspaceId);
|
|
@@ -1376,90 +3274,124 @@ ${err.stack}` : "")
|
|
|
1376
3274
|
throw new HttpError(400, "Session path must be absolute");
|
|
1377
3275
|
}
|
|
1378
3276
|
const sessions = await SessionManager.list(ws.path);
|
|
1379
|
-
const resolved =
|
|
1380
|
-
const target = sessions.find((session) =>
|
|
3277
|
+
const resolved = resolve4(sessionPath);
|
|
3278
|
+
const target = sessions.find((session) => resolve4(session.path) === resolved);
|
|
1381
3279
|
if (!target) {
|
|
1382
3280
|
throw new HttpError(404, `Session not found: ${sessionPath}`);
|
|
1383
3281
|
}
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1386
|
-
if (
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
3282
|
+
const key = this.keyOf(workspaceId, resolved);
|
|
3283
|
+
const live = this.runtimes.get(key);
|
|
3284
|
+
if (live) {
|
|
3285
|
+
if (live.runtime.session.isStreaming) {
|
|
3286
|
+
throw new HttpError(
|
|
3287
|
+
409,
|
|
3288
|
+
"Cannot delete a streaming session \u2014 stop it first"
|
|
3289
|
+
);
|
|
3290
|
+
}
|
|
3291
|
+
await this.disposeState(live, key);
|
|
1391
3292
|
}
|
|
1392
3293
|
try {
|
|
1393
3294
|
await unlink2(resolved);
|
|
1394
|
-
} catch (
|
|
1395
|
-
if (
|
|
3295
|
+
} catch (err2) {
|
|
3296
|
+
if (err2?.code === "ENOENT") {
|
|
1396
3297
|
console.warn(
|
|
1397
3298
|
`[wm] deleteSession: ${resolved} was already gone at unlink time`
|
|
1398
3299
|
);
|
|
3300
|
+
await forgetSessionToolPrefs(resolved);
|
|
1399
3301
|
return;
|
|
1400
|
-
}
|
|
1401
|
-
throw
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
async switchSession(workspaceId, sessionPath) {
|
|
1405
|
-
const ws = await getWorkspace(workspaceId);
|
|
1406
|
-
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
1407
|
-
if (!isAbsolute2(sessionPath)) {
|
|
1408
|
-
throw new Error("Session path must be absolute");
|
|
1409
|
-
}
|
|
1410
|
-
const sessions = await SessionManager.list(ws.path);
|
|
1411
|
-
const resolved = resolve2(sessionPath);
|
|
1412
|
-
const target = sessions.find((session) => resolve2(session.path) === resolved);
|
|
1413
|
-
if (!target) {
|
|
1414
|
-
throw new Error(`Session not found: ${sessionPath}`);
|
|
1415
|
-
}
|
|
1416
|
-
const runtime = await this.getOrCreate(workspaceId);
|
|
1417
|
-
const currentPath = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
|
|
1418
|
-
if (currentPath === resolved) return false;
|
|
1419
|
-
if (runtime.session.isStreaming) {
|
|
1420
|
-
throw new Error("Cannot switch sessions while the agent is streaming");
|
|
3302
|
+
}
|
|
3303
|
+
throw err2;
|
|
1421
3304
|
}
|
|
1422
|
-
|
|
1423
|
-
return !result.cancelled;
|
|
3305
|
+
await forgetSessionToolPrefs(resolved);
|
|
1424
3306
|
}
|
|
3307
|
+
/** Dispose every runtime for a workspace (e.g. when it's removed). */
|
|
1425
3308
|
async dispose(workspaceId) {
|
|
1426
|
-
|
|
1427
|
-
if (!state) return;
|
|
1428
|
-
this.states.delete(workspaceId);
|
|
1429
|
-
this.rebindListeners.delete(workspaceId);
|
|
3309
|
+
this.activeByWorkspace.delete(workspaceId);
|
|
1430
3310
|
this.subscribers.delete(workspaceId);
|
|
3311
|
+
const doomed = [...this.runtimes].filter(([, s]) => s.workspaceId === workspaceId);
|
|
3312
|
+
for (const [key, state] of doomed) {
|
|
3313
|
+
await this.disposeState(state, key);
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
async disposeAll() {
|
|
3317
|
+
await Promise.allSettled([...this.pending.values()]);
|
|
3318
|
+
const states = [...this.runtimes.entries()];
|
|
3319
|
+
this.runtimes.clear();
|
|
3320
|
+
this.activeByWorkspace.clear();
|
|
3321
|
+
this.subscribers.clear();
|
|
3322
|
+
await Promise.all(states.map(([, state]) => this.disposeState(state)));
|
|
3323
|
+
}
|
|
3324
|
+
/** Tear down a single runtime + its bridge, releasing any ask_user Promises
|
|
3325
|
+
* bound to it first. Removes it from `runtimes` when `key` is given. */
|
|
3326
|
+
async disposeState(state, key) {
|
|
3327
|
+
if (key) this.runtimes.delete(key);
|
|
3328
|
+
if (key && this.activeByWorkspace.get(state.workspaceId) === key) {
|
|
3329
|
+
this.activeByWorkspace.delete(state.workspaceId);
|
|
3330
|
+
}
|
|
1431
3331
|
cancelPendingForSession(state.runtime.session.sessionFile ?? null);
|
|
1432
3332
|
try {
|
|
1433
3333
|
state.bridge.dispose();
|
|
1434
3334
|
} catch (e) {
|
|
1435
|
-
console.error(`[wm] dispose bridge ${workspaceId} failed:`, e);
|
|
3335
|
+
console.error(`[wm] dispose bridge ${state.workspaceId} failed:`, e);
|
|
1436
3336
|
}
|
|
1437
3337
|
try {
|
|
1438
|
-
state.runtime.
|
|
3338
|
+
await state.runtime.dispose();
|
|
1439
3339
|
} catch (e) {
|
|
1440
|
-
console.error(`[wm] dispose ${workspaceId} failed:`, e);
|
|
3340
|
+
console.error(`[wm] dispose ${state.workspaceId} failed:`, e);
|
|
3341
|
+
}
|
|
3342
|
+
const sweptChildren = killChildrenForSession(state.runtime.session.sessionFile ?? null);
|
|
3343
|
+
if (sweptChildren > 0) {
|
|
3344
|
+
console.warn(
|
|
3345
|
+
`[wm] killed ${sweptChildren} lingering subagent child(ren) for ${state.workspaceId}`
|
|
3346
|
+
);
|
|
1441
3347
|
}
|
|
1442
3348
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
3349
|
+
/**
|
|
3350
|
+
* Keep the live-runtime count under `MAX_LIVE_RUNTIMES` by disposing the
|
|
3351
|
+
* least-recently-touched IDLE runtime (never streaming, never the freshly-
|
|
3352
|
+
* registered one, never a workspace's active pointer). Best-effort: if every
|
|
3353
|
+
* runtime is busy we simply exceed the cap until one frees up.
|
|
3354
|
+
*/
|
|
3355
|
+
evictIfOverCap(justRegistered) {
|
|
3356
|
+
while (this.runtimes.size > MAX_LIVE_RUNTIMES) {
|
|
3357
|
+
let victimKey;
|
|
3358
|
+
let victimTouched = Infinity;
|
|
3359
|
+
for (const [key, state] of this.runtimes) {
|
|
3360
|
+
if (key === justRegistered) continue;
|
|
3361
|
+
if (this.activeByWorkspace.get(state.workspaceId) === key) continue;
|
|
3362
|
+
if (state.runtime.session.isStreaming) continue;
|
|
3363
|
+
if (state.touchedAt < victimTouched) {
|
|
3364
|
+
victimTouched = state.touchedAt;
|
|
3365
|
+
victimKey = key;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
if (!victimKey) break;
|
|
3369
|
+
const victim = this.runtimes.get(victimKey);
|
|
3370
|
+
if (!victim) break;
|
|
3371
|
+
void this.disposeState(victim, victimKey);
|
|
3372
|
+
}
|
|
1447
3373
|
}
|
|
1448
3374
|
};
|
|
1449
|
-
function
|
|
3375
|
+
function safeReconcileBuiltins(workspaceId, sm) {
|
|
1450
3376
|
try {
|
|
1451
3377
|
reconcileAfterRestart(sm);
|
|
1452
3378
|
} catch (e) {
|
|
1453
3379
|
console.error(`[wm] ask_user cleanup for ${workspaceId} failed:`, e);
|
|
1454
3380
|
}
|
|
3381
|
+
try {
|
|
3382
|
+
reconcileAfterRestart2(sm);
|
|
3383
|
+
} catch (e) {
|
|
3384
|
+
console.error(`[wm] subagent cleanup for ${workspaceId} failed:`, e);
|
|
3385
|
+
}
|
|
1455
3386
|
}
|
|
1456
|
-
function toSessionSummary(info) {
|
|
3387
|
+
function toSessionSummary(info, running2) {
|
|
1457
3388
|
const preview = info.firstMessage.replace(/\s+/g, " ").trim();
|
|
1458
3389
|
return {
|
|
1459
3390
|
path: info.path,
|
|
1460
3391
|
name: info.name,
|
|
1461
3392
|
updatedAt: info.modified.toISOString(),
|
|
1462
|
-
preview: preview ? preview.slice(0, 160) : void 0
|
|
3393
|
+
preview: preview ? preview.slice(0, 160) : void 0,
|
|
3394
|
+
...running2 ? { running: true } : {}
|
|
1463
3395
|
};
|
|
1464
3396
|
}
|
|
1465
3397
|
function extractUserText2(msg) {
|
|
@@ -1495,7 +3427,7 @@ function extractContentText(content) {
|
|
|
1495
3427
|
}
|
|
1496
3428
|
return parts.join("");
|
|
1497
3429
|
}
|
|
1498
|
-
var workspaceManager = new
|
|
3430
|
+
var workspaceManager = new SessionRuntimeManager();
|
|
1499
3431
|
function broadcastTo(subscribers, msg) {
|
|
1500
3432
|
const wire = JSON.stringify(msg);
|
|
1501
3433
|
for (const ws of subscribers) {
|
|
@@ -1508,6 +3440,9 @@ function broadcastTo(subscribers, msg) {
|
|
|
1508
3440
|
}
|
|
1509
3441
|
|
|
1510
3442
|
// src/api/config.ts
|
|
3443
|
+
var BUILTIN_TOOL_SOURCE = new Map(
|
|
3444
|
+
BUILTIN_EXTENSIONS.flatMap((d) => d.tools.map((tool) => [tool, d.name]))
|
|
3445
|
+
);
|
|
1511
3446
|
function buildConfigResponse(workspaceId) {
|
|
1512
3447
|
const runtime = workspaceManager.get(workspaceId);
|
|
1513
3448
|
if (!runtime) throw new Error("runtime not initialized");
|
|
@@ -1525,10 +3460,14 @@ function buildConfigResponse(workspaceId) {
|
|
|
1525
3460
|
name: m.name,
|
|
1526
3461
|
reasoning: m.reasoning
|
|
1527
3462
|
}));
|
|
1528
|
-
const allTools = session.getAllTools().map((t) =>
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
3463
|
+
const allTools = session.getAllTools().map((t) => {
|
|
3464
|
+
const builtinExtension = BUILTIN_TOOL_SOURCE.get(t.name);
|
|
3465
|
+
return {
|
|
3466
|
+
name: t.name,
|
|
3467
|
+
description: t.description,
|
|
3468
|
+
...builtinExtension ? { builtinExtension } : {}
|
|
3469
|
+
};
|
|
3470
|
+
});
|
|
1532
3471
|
return {
|
|
1533
3472
|
currentModel,
|
|
1534
3473
|
thinkingLevel: session.thinkingLevel,
|
|
@@ -1578,8 +3517,8 @@ function mountConfigRoutes(app2) {
|
|
|
1578
3517
|
try {
|
|
1579
3518
|
await workspaceManager.getOrCreate(id);
|
|
1580
3519
|
return c.json(buildConfigResponse(id));
|
|
1581
|
-
} catch (
|
|
1582
|
-
const message =
|
|
3520
|
+
} catch (err2) {
|
|
3521
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
1583
3522
|
return c.json({ ok: false, error: message }, 500);
|
|
1584
3523
|
}
|
|
1585
3524
|
});
|
|
@@ -1605,8 +3544,8 @@ function mountConfigRoutes(app2) {
|
|
|
1605
3544
|
await runtime.session.setModel(model);
|
|
1606
3545
|
broadcastContextUsage(id, runtime);
|
|
1607
3546
|
return c.json(buildConfigResponse(id));
|
|
1608
|
-
} catch (
|
|
1609
|
-
const message =
|
|
3547
|
+
} catch (err2) {
|
|
3548
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
1610
3549
|
return c.json({ ok: false, error: message }, 500);
|
|
1611
3550
|
}
|
|
1612
3551
|
});
|
|
@@ -1630,8 +3569,8 @@ function mountConfigRoutes(app2) {
|
|
|
1630
3569
|
}
|
|
1631
3570
|
runtime.session.setThinkingLevel(body.level);
|
|
1632
3571
|
return c.json(buildConfigResponse(id));
|
|
1633
|
-
} catch (
|
|
1634
|
-
const message =
|
|
3572
|
+
} catch (err2) {
|
|
3573
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
1635
3574
|
return c.json({ ok: false, error: message }, 500);
|
|
1636
3575
|
}
|
|
1637
3576
|
});
|
|
@@ -1652,21 +3591,50 @@ function mountConfigRoutes(app2) {
|
|
|
1652
3591
|
if (!runtime) {
|
|
1653
3592
|
return c.json({ ok: false, error: "runtime not initialized" }, 500);
|
|
1654
3593
|
}
|
|
1655
|
-
runtime.session
|
|
3594
|
+
await persistActiveTools(id, runtime.session, body.tools);
|
|
1656
3595
|
return c.json(buildConfigResponse(id));
|
|
1657
|
-
} catch (
|
|
1658
|
-
const message =
|
|
3596
|
+
} catch (err2) {
|
|
3597
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
3598
|
+
return c.json({ ok: false, error: message }, 500);
|
|
3599
|
+
}
|
|
3600
|
+
});
|
|
3601
|
+
app2.put("/:id/config/session-name", async (c) => {
|
|
3602
|
+
const id = c.req.param("id");
|
|
3603
|
+
const exists2 = await requireWorkspace(c, id);
|
|
3604
|
+
if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
|
|
3605
|
+
const body = await c.req.json();
|
|
3606
|
+
if (typeof body?.name !== "string") {
|
|
3607
|
+
return c.json({ ok: false, error: "name is required" }, 400);
|
|
3608
|
+
}
|
|
3609
|
+
const trimmed = body.name.trim();
|
|
3610
|
+
if (!trimmed) {
|
|
3611
|
+
return c.json({ ok: false, error: "name must not be empty" }, 400);
|
|
3612
|
+
}
|
|
3613
|
+
try {
|
|
3614
|
+
const sessionPath = c.req.query("sessionPath");
|
|
3615
|
+
if (sessionPath) {
|
|
3616
|
+
const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
|
|
3617
|
+
if (err2) return c.json({ ok: false, error: err2 }, 404);
|
|
3618
|
+
}
|
|
3619
|
+
const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
|
|
3620
|
+
if (runtime.session.isStreaming) {
|
|
3621
|
+
return c.json({ ok: false, error: "cannot rename while the agent is streaming" }, 409);
|
|
3622
|
+
}
|
|
3623
|
+
runtime.session.setSessionName(trimmed);
|
|
3624
|
+
return c.json({ name: trimmed });
|
|
3625
|
+
} catch (err2) {
|
|
3626
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
1659
3627
|
return c.json({ ok: false, error: message }, 500);
|
|
1660
3628
|
}
|
|
1661
3629
|
});
|
|
1662
3630
|
}
|
|
1663
3631
|
|
|
1664
3632
|
// src/api/files.ts
|
|
1665
|
-
import { execFile as
|
|
1666
|
-
import { readdir } from "fs/promises";
|
|
1667
|
-
import { join as
|
|
3633
|
+
import { execFile as execFile3 } from "child_process";
|
|
3634
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
3635
|
+
import { join as join12, relative, sep as sep2 } from "path";
|
|
1668
3636
|
import { promisify as promisify2 } from "util";
|
|
1669
|
-
var exec2 = promisify2(
|
|
3637
|
+
var exec2 = promisify2(execFile3);
|
|
1670
3638
|
var LIST_TTL_MS = 1e4;
|
|
1671
3639
|
var MAX_CACHED_WORKSPACES = 16;
|
|
1672
3640
|
var MAX_FILES_TRACKED = 2e4;
|
|
@@ -1694,8 +3662,8 @@ var listCache = /* @__PURE__ */ new Map();
|
|
|
1694
3662
|
var inflight2 = /* @__PURE__ */ new Map();
|
|
1695
3663
|
async function getFileList(workspacePath) {
|
|
1696
3664
|
const now = Date.now();
|
|
1697
|
-
const
|
|
1698
|
-
if (
|
|
3665
|
+
const cached2 = listCache.get(workspacePath);
|
|
3666
|
+
if (cached2 && cached2.expiresAt > now) return cached2.files;
|
|
1699
3667
|
const pending2 = inflight2.get(workspacePath);
|
|
1700
3668
|
if (pending2) return (await pending2).files;
|
|
1701
3669
|
const probe = probeFileList(workspacePath).then((files) => {
|
|
@@ -1746,14 +3714,14 @@ async function walkDir(root, dir, depth, out) {
|
|
|
1746
3714
|
if (depth > WALK_MAX_DEPTH) return;
|
|
1747
3715
|
let dirents;
|
|
1748
3716
|
try {
|
|
1749
|
-
dirents = await
|
|
3717
|
+
dirents = await readdir2(dir, { withFileTypes: true });
|
|
1750
3718
|
} catch {
|
|
1751
3719
|
return;
|
|
1752
3720
|
}
|
|
1753
3721
|
for (const d of dirents) {
|
|
1754
3722
|
if (out.length >= MAX_FILES_TRACKED) return;
|
|
1755
3723
|
if (WALK_IGNORES.has(d.name)) continue;
|
|
1756
|
-
const abs =
|
|
3724
|
+
const abs = join12(dir, d.name);
|
|
1757
3725
|
if (d.isDirectory()) {
|
|
1758
3726
|
await walkDir(root, abs, depth + 1, out);
|
|
1759
3727
|
} else if (d.isFile()) {
|
|
@@ -1793,7 +3761,7 @@ function mountFilesRoute(app2) {
|
|
|
1793
3761
|
if (!qRaw) {
|
|
1794
3762
|
const slice = all.slice(0, limit);
|
|
1795
3763
|
entries = slice.map((relPath) => ({
|
|
1796
|
-
path:
|
|
3764
|
+
path: join12(workspacePath, relPath),
|
|
1797
3765
|
relPath
|
|
1798
3766
|
}));
|
|
1799
3767
|
truncated = all.length > limit;
|
|
@@ -1810,25 +3778,25 @@ function mountFilesRoute(app2) {
|
|
|
1810
3778
|
scored.sort((a, b) => b.score - a.score);
|
|
1811
3779
|
const top = scored.slice(0, limit);
|
|
1812
3780
|
entries = top.map((e) => ({
|
|
1813
|
-
path:
|
|
3781
|
+
path: join12(workspacePath, e.relPath),
|
|
1814
3782
|
relPath: e.relPath
|
|
1815
3783
|
}));
|
|
1816
3784
|
truncated = matchCount > limit;
|
|
1817
3785
|
}
|
|
1818
3786
|
const body = { workspacePath, entries, truncated };
|
|
1819
3787
|
return c.json(body);
|
|
1820
|
-
} catch (
|
|
1821
|
-
const message =
|
|
1822
|
-
console.error(`[api/files] search for ${id} failed:`,
|
|
3788
|
+
} catch (err2) {
|
|
3789
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
3790
|
+
console.error(`[api/files] search for ${id} failed:`, err2);
|
|
1823
3791
|
return c.json({ ok: false, error: message }, 500);
|
|
1824
3792
|
}
|
|
1825
3793
|
});
|
|
1826
3794
|
}
|
|
1827
3795
|
|
|
1828
3796
|
// src/api/resources.ts
|
|
1829
|
-
import { readdir as
|
|
1830
|
-
import { join as
|
|
1831
|
-
import { getAgentDir as
|
|
3797
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
3798
|
+
import { join as join13 } from "path";
|
|
3799
|
+
import { getAgentDir as getAgentDir3 } from "@earendil-works/pi-coding-agent";
|
|
1832
3800
|
function toResourceSource(info) {
|
|
1833
3801
|
return {
|
|
1834
3802
|
scope: info.scope,
|
|
@@ -1837,16 +3805,16 @@ function toResourceSource(info) {
|
|
|
1837
3805
|
};
|
|
1838
3806
|
}
|
|
1839
3807
|
async function scanExtensionDirs(workspaceCwd) {
|
|
1840
|
-
const dirs = [
|
|
3808
|
+
const dirs = [join13(getAgentDir3(), "extensions"), join13(workspaceCwd, ".pi", "extensions")];
|
|
1841
3809
|
const found = [];
|
|
1842
3810
|
for (const dir of dirs) {
|
|
1843
3811
|
try {
|
|
1844
|
-
const entries = await
|
|
3812
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
1845
3813
|
for (const entry of entries) {
|
|
1846
3814
|
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
1847
|
-
found.push(
|
|
3815
|
+
found.push(join13(dir, entry.name));
|
|
1848
3816
|
} else if (entry.isDirectory()) {
|
|
1849
|
-
found.push(
|
|
3817
|
+
found.push(join13(dir, entry.name));
|
|
1850
3818
|
}
|
|
1851
3819
|
}
|
|
1852
3820
|
} catch {
|
|
@@ -1901,9 +3869,9 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
|
|
|
1901
3869
|
shortcuts: [...e.shortcuts.keys()]
|
|
1902
3870
|
};
|
|
1903
3871
|
});
|
|
1904
|
-
const extensionErrors = extResult.errors.map((
|
|
1905
|
-
path:
|
|
1906
|
-
error:
|
|
3872
|
+
const extensionErrors = extResult.errors.map((err2) => ({
|
|
3873
|
+
path: err2.path,
|
|
3874
|
+
error: err2.error
|
|
1907
3875
|
}));
|
|
1908
3876
|
const disabledExtensions = EXTENSIONS_ENABLED ? [] : await scanExtensionDirs(workspaceCwd);
|
|
1909
3877
|
const disabledBuiltins = new Set(getDisabledBuiltins());
|
|
@@ -1928,15 +3896,15 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
|
|
|
1928
3896
|
async function rootsFor(workspaceId) {
|
|
1929
3897
|
const ws = await getWorkspace(workspaceId);
|
|
1930
3898
|
if (!ws) throw new HttpError(404, "workspace not found");
|
|
1931
|
-
const roots = resolveResourceRoots({ agentDir:
|
|
3899
|
+
const roots = resolveResourceRoots({ agentDir: getAgentDir3(), workspaceCwd: ws.path });
|
|
1932
3900
|
return { roots, workspaceCwd: ws.path };
|
|
1933
3901
|
}
|
|
1934
|
-
function respondError(c,
|
|
1935
|
-
if (
|
|
1936
|
-
return c.json({ ok: false, error:
|
|
3902
|
+
function respondError(c, err2) {
|
|
3903
|
+
if (err2 instanceof HttpError) {
|
|
3904
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
1937
3905
|
}
|
|
1938
|
-
const message =
|
|
1939
|
-
console.error(`[api/resources] unexpected error:`,
|
|
3906
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
3907
|
+
console.error(`[api/resources] unexpected error:`, err2);
|
|
1940
3908
|
return c.json({ ok: false, error: message }, 500);
|
|
1941
3909
|
}
|
|
1942
3910
|
async function reload(workspaceId) {
|
|
@@ -1953,8 +3921,8 @@ function mountResourcesRoute(app2) {
|
|
|
1953
3921
|
await workspaceManager.getOrCreate(id);
|
|
1954
3922
|
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1955
3923
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1956
|
-
} catch (
|
|
1957
|
-
return respondError(c,
|
|
3924
|
+
} catch (err2) {
|
|
3925
|
+
return respondError(c, err2);
|
|
1958
3926
|
}
|
|
1959
3927
|
});
|
|
1960
3928
|
app2.post("/:id/resources/reload", async (c) => {
|
|
@@ -1966,8 +3934,8 @@ function mountResourcesRoute(app2) {
|
|
|
1966
3934
|
await reload(id);
|
|
1967
3935
|
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1968
3936
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1969
|
-
} catch (
|
|
1970
|
-
return respondError(c,
|
|
3937
|
+
} catch (err2) {
|
|
3938
|
+
return respondError(c, err2);
|
|
1971
3939
|
}
|
|
1972
3940
|
});
|
|
1973
3941
|
app2.get("/:id/resources/skill", async (c) => {
|
|
@@ -1992,8 +3960,8 @@ function mountResourcesRoute(app2) {
|
|
|
1992
3960
|
body: data.body
|
|
1993
3961
|
};
|
|
1994
3962
|
return c.json(body);
|
|
1995
|
-
} catch (
|
|
1996
|
-
return respondError(c,
|
|
3963
|
+
} catch (err2) {
|
|
3964
|
+
return respondError(c, err2);
|
|
1997
3965
|
}
|
|
1998
3966
|
});
|
|
1999
3967
|
app2.post("/:id/resources/skills", async (c) => {
|
|
@@ -2017,8 +3985,8 @@ function mountResourcesRoute(app2) {
|
|
|
2017
3985
|
});
|
|
2018
3986
|
await reload(id);
|
|
2019
3987
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2020
|
-
} catch (
|
|
2021
|
-
return respondError(c,
|
|
3988
|
+
} catch (err2) {
|
|
3989
|
+
return respondError(c, err2);
|
|
2022
3990
|
}
|
|
2023
3991
|
});
|
|
2024
3992
|
app2.put("/:id/resources/skills", async (c) => {
|
|
@@ -2042,8 +4010,8 @@ function mountResourcesRoute(app2) {
|
|
|
2042
4010
|
});
|
|
2043
4011
|
await reload(id);
|
|
2044
4012
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2045
|
-
} catch (
|
|
2046
|
-
return respondError(c,
|
|
4013
|
+
} catch (err2) {
|
|
4014
|
+
return respondError(c, err2);
|
|
2047
4015
|
}
|
|
2048
4016
|
});
|
|
2049
4017
|
app2.delete("/:id/resources/skills", async (c) => {
|
|
@@ -2058,8 +4026,8 @@ function mountResourcesRoute(app2) {
|
|
|
2058
4026
|
await deleteSkill(filePath, roots);
|
|
2059
4027
|
await reload(id);
|
|
2060
4028
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2061
|
-
} catch (
|
|
2062
|
-
return respondError(c,
|
|
4029
|
+
} catch (err2) {
|
|
4030
|
+
return respondError(c, err2);
|
|
2063
4031
|
}
|
|
2064
4032
|
});
|
|
2065
4033
|
app2.get("/:id/resources/prompt", async (c) => {
|
|
@@ -2084,8 +4052,8 @@ function mountResourcesRoute(app2) {
|
|
|
2084
4052
|
body: data.body
|
|
2085
4053
|
};
|
|
2086
4054
|
return c.json(body);
|
|
2087
|
-
} catch (
|
|
2088
|
-
return respondError(c,
|
|
4055
|
+
} catch (err2) {
|
|
4056
|
+
return respondError(c, err2);
|
|
2089
4057
|
}
|
|
2090
4058
|
});
|
|
2091
4059
|
app2.post("/:id/resources/prompts", async (c) => {
|
|
@@ -2109,8 +4077,8 @@ function mountResourcesRoute(app2) {
|
|
|
2109
4077
|
});
|
|
2110
4078
|
await reload(id);
|
|
2111
4079
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2112
|
-
} catch (
|
|
2113
|
-
return respondError(c,
|
|
4080
|
+
} catch (err2) {
|
|
4081
|
+
return respondError(c, err2);
|
|
2114
4082
|
}
|
|
2115
4083
|
});
|
|
2116
4084
|
app2.put("/:id/resources/prompts", async (c) => {
|
|
@@ -2134,8 +4102,8 @@ function mountResourcesRoute(app2) {
|
|
|
2134
4102
|
});
|
|
2135
4103
|
await reload(id);
|
|
2136
4104
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2137
|
-
} catch (
|
|
2138
|
-
return respondError(c,
|
|
4105
|
+
} catch (err2) {
|
|
4106
|
+
return respondError(c, err2);
|
|
2139
4107
|
}
|
|
2140
4108
|
});
|
|
2141
4109
|
app2.delete("/:id/resources/prompts", async (c) => {
|
|
@@ -2150,8 +4118,8 @@ function mountResourcesRoute(app2) {
|
|
|
2150
4118
|
await deletePrompt(filePath, roots);
|
|
2151
4119
|
await reload(id);
|
|
2152
4120
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2153
|
-
} catch (
|
|
2154
|
-
return respondError(c,
|
|
4121
|
+
} catch (err2) {
|
|
4122
|
+
return respondError(c, err2);
|
|
2155
4123
|
}
|
|
2156
4124
|
});
|
|
2157
4125
|
app2.put("/:id/resources/builtin-extensions", async (c) => {
|
|
@@ -2175,10 +4143,11 @@ function mountResourcesRoute(app2) {
|
|
|
2175
4143
|
}
|
|
2176
4144
|
await setBuiltinEnabled(body.id, body.enabled);
|
|
2177
4145
|
await runtime.session.reload();
|
|
4146
|
+
reapplyToolPrefs(id, runtime.session);
|
|
2178
4147
|
const { roots, workspaceCwd } = await rootsFor(id);
|
|
2179
4148
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2180
|
-
} catch (
|
|
2181
|
-
return respondError(c,
|
|
4149
|
+
} catch (err2) {
|
|
4150
|
+
return respondError(c, err2);
|
|
2182
4151
|
}
|
|
2183
4152
|
});
|
|
2184
4153
|
}
|
|
@@ -2186,14 +4155,182 @@ function isScope(value) {
|
|
|
2186
4155
|
return value === "user" || value === "project";
|
|
2187
4156
|
}
|
|
2188
4157
|
|
|
4158
|
+
// src/api/tree.ts
|
|
4159
|
+
import { Hono } from "hono";
|
|
4160
|
+
var treeRoute = new Hono();
|
|
4161
|
+
treeRoute.get("/", async (c) => {
|
|
4162
|
+
const id = c.req.param("id") ?? "";
|
|
4163
|
+
const existed = await getWorkspace(id);
|
|
4164
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
4165
|
+
try {
|
|
4166
|
+
const runtime = await workspaceManager.getOrCreate(id);
|
|
4167
|
+
const sm = runtime.session.sessionManager;
|
|
4168
|
+
const tree = sm.getTree();
|
|
4169
|
+
const leafId = sm.getLeafId();
|
|
4170
|
+
const activeIds = /* @__PURE__ */ new Set();
|
|
4171
|
+
if (leafId) {
|
|
4172
|
+
const branch = sm.getBranch(leafId);
|
|
4173
|
+
for (const entry of branch) {
|
|
4174
|
+
activeIds.add(entry.id);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
const nodes = [];
|
|
4178
|
+
flattenTree(tree, leafId, activeIds, nodes, 0);
|
|
4179
|
+
const body = { nodes, leafId };
|
|
4180
|
+
return c.json(body);
|
|
4181
|
+
} catch (err2) {
|
|
4182
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4183
|
+
console.error(`[api] tree for ${id} failed:`, err2);
|
|
4184
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4185
|
+
}
|
|
4186
|
+
});
|
|
4187
|
+
function flattenTree(nodes, leafId, activeIds, out, depth) {
|
|
4188
|
+
const sorted = [...nodes].sort(
|
|
4189
|
+
(a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
|
4190
|
+
);
|
|
4191
|
+
for (const node of sorted) {
|
|
4192
|
+
const entry = node.entry;
|
|
4193
|
+
const id = entry.id;
|
|
4194
|
+
out.push({
|
|
4195
|
+
id,
|
|
4196
|
+
parentId: entry.parentId,
|
|
4197
|
+
depth,
|
|
4198
|
+
entryType: mapEntryType(entry.type),
|
|
4199
|
+
messageRole: entry.type === "message" ? mapMessageRole(entry["message"]) : void 0,
|
|
4200
|
+
timestamp: entry.timestamp,
|
|
4201
|
+
preview: extractPreview(entry),
|
|
4202
|
+
active: activeIds.has(id),
|
|
4203
|
+
isLeaf: id === leafId,
|
|
4204
|
+
childCount: node.children.length,
|
|
4205
|
+
label: node.label
|
|
4206
|
+
});
|
|
4207
|
+
if (node.children.length > 0) {
|
|
4208
|
+
flattenTree(node.children, leafId, activeIds, out, depth + 1);
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
function mapEntryType(type) {
|
|
4213
|
+
switch (type) {
|
|
4214
|
+
case "message":
|
|
4215
|
+
return "message";
|
|
4216
|
+
case "compaction":
|
|
4217
|
+
return "compaction";
|
|
4218
|
+
case "branch_summary":
|
|
4219
|
+
return "branch_summary";
|
|
4220
|
+
case "model_change":
|
|
4221
|
+
return "model_change";
|
|
4222
|
+
case "thinking_level_change":
|
|
4223
|
+
return "thinking_level_change";
|
|
4224
|
+
case "custom":
|
|
4225
|
+
return "custom";
|
|
4226
|
+
case "custom_message":
|
|
4227
|
+
return "custom_message";
|
|
4228
|
+
case "label":
|
|
4229
|
+
return "label";
|
|
4230
|
+
case "session_info":
|
|
4231
|
+
return "session_info";
|
|
4232
|
+
default:
|
|
4233
|
+
console.warn(`[tree] unknown entry type "${type}" \u2014 mapping to "custom". pi SDK may have added a type tree.ts doesn't handle yet.`);
|
|
4234
|
+
return "custom";
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
function mapMessageRole(msg) {
|
|
4238
|
+
if (!msg || typeof msg !== "object") return void 0;
|
|
4239
|
+
const role = msg.role;
|
|
4240
|
+
switch (role) {
|
|
4241
|
+
case "user":
|
|
4242
|
+
return "user";
|
|
4243
|
+
case "assistant":
|
|
4244
|
+
return "assistant";
|
|
4245
|
+
case "toolResult":
|
|
4246
|
+
return "toolResult";
|
|
4247
|
+
case "bashExecution":
|
|
4248
|
+
return "bashExecution";
|
|
4249
|
+
case "custom":
|
|
4250
|
+
return "custom";
|
|
4251
|
+
case "branchSummary":
|
|
4252
|
+
return "branchSummary";
|
|
4253
|
+
case "compactionSummary":
|
|
4254
|
+
return "compactionSummary";
|
|
4255
|
+
default:
|
|
4256
|
+
return void 0;
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
var PREVIEW_MAX = 120;
|
|
4260
|
+
function extractPreview(entry) {
|
|
4261
|
+
switch (entry.type) {
|
|
4262
|
+
case "message":
|
|
4263
|
+
return extractMessagePreview(entry["message"]);
|
|
4264
|
+
case "compaction":
|
|
4265
|
+
return truncate2(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
|
|
4266
|
+
case "branch_summary":
|
|
4267
|
+
return truncate2(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
|
|
4268
|
+
case "model_change":
|
|
4269
|
+
return `${entry["provider"] ?? ""}/${entry["modelId"] ?? ""}`;
|
|
4270
|
+
case "thinking_level_change":
|
|
4271
|
+
return `Thinking: ${entry["thinkingLevel"] ?? ""}`;
|
|
4272
|
+
case "session_info":
|
|
4273
|
+
return entry["name"] ? `Name: ${entry["name"]}` : "Session info";
|
|
4274
|
+
case "custom_message":
|
|
4275
|
+
return truncate2(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
|
|
4276
|
+
case "custom":
|
|
4277
|
+
return `Custom: ${entry["customType"] ?? ""}`;
|
|
4278
|
+
case "label":
|
|
4279
|
+
return "Label";
|
|
4280
|
+
default:
|
|
4281
|
+
return "";
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
function extractMessagePreview(msg) {
|
|
4285
|
+
if (!msg || typeof msg !== "object") return "";
|
|
4286
|
+
const m = msg;
|
|
4287
|
+
if (m.role === "bashExecution") {
|
|
4288
|
+
return truncate2(`$ ${m.command ?? ""}`, PREVIEW_MAX);
|
|
4289
|
+
}
|
|
4290
|
+
return truncate2(extractContentText2(m.content), PREVIEW_MAX);
|
|
4291
|
+
}
|
|
4292
|
+
function extractContentText2(content) {
|
|
4293
|
+
if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
|
|
4294
|
+
if (!Array.isArray(content)) return "";
|
|
4295
|
+
const parts = [];
|
|
4296
|
+
for (const block of content) {
|
|
4297
|
+
if (!block || typeof block !== "object") continue;
|
|
4298
|
+
const b = block;
|
|
4299
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
4300
|
+
parts.push(b.text);
|
|
4301
|
+
} else if (b.type === "thinking" && typeof b.thinking === "string") {
|
|
4302
|
+
parts.push(b.thinking);
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
4306
|
+
}
|
|
4307
|
+
function truncate2(text, max) {
|
|
4308
|
+
if (text.length <= max) return text;
|
|
4309
|
+
return text.slice(0, max) + "\u2026";
|
|
4310
|
+
}
|
|
4311
|
+
|
|
2189
4312
|
// src/api/workspaces.ts
|
|
2190
|
-
var workspacesRoute = new
|
|
4313
|
+
var workspacesRoute = new Hono2();
|
|
2191
4314
|
workspacesRoute.get("/", async (c) => {
|
|
2192
4315
|
const raw = await listWorkspaces();
|
|
2193
4316
|
const workspaces = await Promise.all(raw.map(enrichWorkspace));
|
|
2194
4317
|
const body = { workspaces };
|
|
2195
4318
|
return c.json(body);
|
|
2196
4319
|
});
|
|
4320
|
+
workspacesRoute.put("/reorder", async (c) => {
|
|
4321
|
+
const body = await c.req.json();
|
|
4322
|
+
if (!body?.ids || !Array.isArray(body.ids) || body.ids.length === 0 || !body.ids.every((id) => typeof id === "string")) {
|
|
4323
|
+
return c.json(
|
|
4324
|
+
{ ok: false, error: "ids must be a non-empty array of strings" },
|
|
4325
|
+
400
|
|
4326
|
+
);
|
|
4327
|
+
}
|
|
4328
|
+
await reorderWorkspaces(body.ids);
|
|
4329
|
+
const raw = await listWorkspaces();
|
|
4330
|
+
const workspaces = await Promise.all(raw.map(enrichWorkspace));
|
|
4331
|
+
const resBody = { workspaces };
|
|
4332
|
+
return c.json(resBody);
|
|
4333
|
+
});
|
|
2197
4334
|
workspacesRoute.get("/:id/sessions", async (c) => {
|
|
2198
4335
|
const id = c.req.param("id");
|
|
2199
4336
|
const existed = await getWorkspace(id);
|
|
@@ -2202,9 +4339,9 @@ workspacesRoute.get("/:id/sessions", async (c) => {
|
|
|
2202
4339
|
const sessions = await workspaceManager.listSessions(id);
|
|
2203
4340
|
const body = { sessions };
|
|
2204
4341
|
return c.json(body);
|
|
2205
|
-
} catch (
|
|
2206
|
-
const message =
|
|
2207
|
-
console.error(`[api] list sessions for ${id} failed:`,
|
|
4342
|
+
} catch (err2) {
|
|
4343
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4344
|
+
console.error(`[api] list sessions for ${id} failed:`, err2);
|
|
2208
4345
|
return c.json({ ok: false, error: message }, 500);
|
|
2209
4346
|
}
|
|
2210
4347
|
});
|
|
@@ -2220,15 +4357,15 @@ workspacesRoute.delete("/:id/sessions", async (c) => {
|
|
|
2220
4357
|
await workspaceManager.deleteSession(id, sessionPath);
|
|
2221
4358
|
const body = { ok: true };
|
|
2222
4359
|
return c.json(body);
|
|
2223
|
-
} catch (
|
|
2224
|
-
if (
|
|
4360
|
+
} catch (err2) {
|
|
4361
|
+
if (err2 instanceof HttpError) {
|
|
2225
4362
|
return c.json(
|
|
2226
|
-
{ ok: false, error:
|
|
2227
|
-
|
|
4363
|
+
{ ok: false, error: err2.message },
|
|
4364
|
+
err2.status
|
|
2228
4365
|
);
|
|
2229
4366
|
}
|
|
2230
|
-
const message =
|
|
2231
|
-
console.error(`[api] delete session for ${id} failed:`,
|
|
4367
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4368
|
+
console.error(`[api] delete session for ${id} failed:`, err2);
|
|
2232
4369
|
return c.json({ ok: false, error: message }, 500);
|
|
2233
4370
|
}
|
|
2234
4371
|
});
|
|
@@ -2245,9 +4382,31 @@ workspacesRoute.get("/:id/fork-points", async (c) => {
|
|
|
2245
4382
|
}));
|
|
2246
4383
|
const body = { points };
|
|
2247
4384
|
return c.json(body);
|
|
2248
|
-
} catch (
|
|
2249
|
-
const message =
|
|
2250
|
-
console.error(`[api] fork-points for ${id} failed:`,
|
|
4385
|
+
} catch (err2) {
|
|
4386
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4387
|
+
console.error(`[api] fork-points for ${id} failed:`, err2);
|
|
4388
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4389
|
+
}
|
|
4390
|
+
});
|
|
4391
|
+
workspacesRoute.get("/:id/export", async (c) => {
|
|
4392
|
+
const id = c.req.param("id");
|
|
4393
|
+
const existed = await getWorkspace(id);
|
|
4394
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
4395
|
+
try {
|
|
4396
|
+
const sessionPath = c.req.query("sessionPath");
|
|
4397
|
+
if (sessionPath) {
|
|
4398
|
+
const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
|
|
4399
|
+
if (err2) return c.json({ ok: false, error: err2 }, 404);
|
|
4400
|
+
}
|
|
4401
|
+
const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
|
|
4402
|
+
const outputPath = await runtime.session.exportToHtml();
|
|
4403
|
+
const html = await readFile7(outputPath, "utf-8");
|
|
4404
|
+
const filename = basename2(outputPath);
|
|
4405
|
+
const body = { html, filename };
|
|
4406
|
+
return c.json(body);
|
|
4407
|
+
} catch (err2) {
|
|
4408
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4409
|
+
console.error(`[api] export for ${id} failed:`, err2);
|
|
2251
4410
|
return c.json({ ok: false, error: message }, 500);
|
|
2252
4411
|
}
|
|
2253
4412
|
});
|
|
@@ -2260,9 +4419,9 @@ workspacesRoute.get("/:id/history", async (c) => {
|
|
|
2260
4419
|
const sessionPath = c.req.query("sessionPath");
|
|
2261
4420
|
const body = workspaceManager.getSessionHistory(id, sessionPath);
|
|
2262
4421
|
return c.json(body);
|
|
2263
|
-
} catch (
|
|
2264
|
-
const message =
|
|
2265
|
-
console.error(`[api] history for ${id} failed:`,
|
|
4422
|
+
} catch (err2) {
|
|
4423
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4424
|
+
console.error(`[api] history for ${id} failed:`, err2);
|
|
2266
4425
|
return c.json({ ok: false, error: message }, 500);
|
|
2267
4426
|
}
|
|
2268
4427
|
});
|
|
@@ -2274,7 +4433,7 @@ workspacesRoute.post("/", async (c) => {
|
|
|
2274
4433
|
if (!isAbsolute3(body.path)) {
|
|
2275
4434
|
return c.json({ ok: false, error: "path must be absolute" }, 400);
|
|
2276
4435
|
}
|
|
2277
|
-
const resolved =
|
|
4436
|
+
const resolved = resolve5(body.path);
|
|
2278
4437
|
try {
|
|
2279
4438
|
const st = await stat2(resolved);
|
|
2280
4439
|
if (!st.isDirectory()) {
|
|
@@ -2300,35 +4459,47 @@ workspacesRoute.delete("/:id", async (c) => {
|
|
|
2300
4459
|
const body = { ok: true };
|
|
2301
4460
|
return c.json(body);
|
|
2302
4461
|
});
|
|
4462
|
+
workspacesRoute.patch("/:id", async (c) => {
|
|
4463
|
+
const id = c.req.param("id");
|
|
4464
|
+
const body = await c.req.json();
|
|
4465
|
+
if (typeof body?.trustProjectAgents !== "boolean") {
|
|
4466
|
+
return c.json({ ok: false, error: "trustProjectAgents must be a boolean" }, 400);
|
|
4467
|
+
}
|
|
4468
|
+
const updated = await setWorkspaceTrustProjectAgents(id, body.trustProjectAgents);
|
|
4469
|
+
if (!updated) return c.json({ ok: false, error: "not found" }, 404);
|
|
4470
|
+
const res = { workspace: await enrichWorkspace(updated) };
|
|
4471
|
+
return c.json(res);
|
|
4472
|
+
});
|
|
2303
4473
|
mountConfigRoutes(workspacesRoute);
|
|
2304
4474
|
mountResourcesRoute(workspacesRoute);
|
|
2305
4475
|
mountFilesRoute(workspacesRoute);
|
|
4476
|
+
workspacesRoute.route("/:id/tree", treeRoute);
|
|
2306
4477
|
|
|
2307
4478
|
// src/api/fs.ts
|
|
2308
|
-
import { readdir as
|
|
2309
|
-
import { homedir as
|
|
2310
|
-
import { dirname as
|
|
2311
|
-
import { Hono as
|
|
2312
|
-
var fsRoute = new
|
|
4479
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
4480
|
+
import { homedir as homedir3 } from "os";
|
|
4481
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "path";
|
|
4482
|
+
import { Hono as Hono3 } from "hono";
|
|
4483
|
+
var fsRoute = new Hono3();
|
|
2313
4484
|
fsRoute.get("/browse", async (c) => {
|
|
2314
4485
|
const rawPath = c.req.query("path");
|
|
2315
4486
|
const showHidden = c.req.query("showHidden") === "1";
|
|
2316
|
-
const target = rawPath && isAbsolute4(rawPath) ?
|
|
4487
|
+
const target = rawPath && isAbsolute4(rawPath) ? resolve6(rawPath) : homedir3();
|
|
2317
4488
|
let dirents;
|
|
2318
4489
|
try {
|
|
2319
|
-
dirents = await
|
|
2320
|
-
} catch (
|
|
2321
|
-
const code =
|
|
4490
|
+
dirents = await readdir4(target, { withFileTypes: true });
|
|
4491
|
+
} catch (err2) {
|
|
4492
|
+
const code = err2.code;
|
|
2322
4493
|
const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
|
|
2323
4494
|
return c.json({ ok: false, error: msg, path: target }, 400);
|
|
2324
4495
|
}
|
|
2325
4496
|
const entries = dirents.filter((d) => d.isDirectory()).filter((d) => showHidden || !d.name.startsWith(".")).map((d) => ({
|
|
2326
4497
|
name: d.name,
|
|
2327
|
-
path:
|
|
4498
|
+
path: join14(target, d.name),
|
|
2328
4499
|
type: "dir"
|
|
2329
4500
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
2330
4501
|
const parent = (() => {
|
|
2331
|
-
const p =
|
|
4502
|
+
const p = dirname5(target);
|
|
2332
4503
|
return p === target ? null : p;
|
|
2333
4504
|
})();
|
|
2334
4505
|
const body = { path: target, parent, entries };
|
|
@@ -2336,13 +4507,49 @@ fsRoute.get("/browse", async (c) => {
|
|
|
2336
4507
|
});
|
|
2337
4508
|
|
|
2338
4509
|
// src/api/model-configs.ts
|
|
2339
|
-
import { readFile as
|
|
2340
|
-
import {
|
|
2341
|
-
import { Hono as
|
|
4510
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
4511
|
+
import { join as join15 } from "path";
|
|
4512
|
+
import { Hono as Hono4 } from "hono";
|
|
2342
4513
|
import {
|
|
2343
|
-
getAgentDir as
|
|
4514
|
+
getAgentDir as getAgentDir4
|
|
2344
4515
|
} from "@earendil-works/pi-coding-agent";
|
|
2345
|
-
|
|
4516
|
+
|
|
4517
|
+
// src/api/model-config-keys.ts
|
|
4518
|
+
var MASKED_KEY_RE = /^….{1,8}$/;
|
|
4519
|
+
function maskApiKey(key) {
|
|
4520
|
+
return `\u2026${key.slice(-4)}`;
|
|
4521
|
+
}
|
|
4522
|
+
function isPreservedApiKey(key) {
|
|
4523
|
+
return key === "" || MASKED_KEY_RE.test(key);
|
|
4524
|
+
}
|
|
4525
|
+
function maskConfigForResponse(config2) {
|
|
4526
|
+
const providers = {};
|
|
4527
|
+
for (const [name, provider] of Object.entries(config2.providers)) {
|
|
4528
|
+
providers[name] = {
|
|
4529
|
+
...provider,
|
|
4530
|
+
apiKey: provider.apiKey ? maskApiKey(provider.apiKey) : ""
|
|
4531
|
+
};
|
|
4532
|
+
}
|
|
4533
|
+
return { providers };
|
|
4534
|
+
}
|
|
4535
|
+
function preserveApiKeys(incoming, existing) {
|
|
4536
|
+
const providers = { ...incoming.providers };
|
|
4537
|
+
for (const [name, provider] of Object.entries(providers)) {
|
|
4538
|
+
if (!isPreservedApiKey(provider.apiKey)) continue;
|
|
4539
|
+
const prev = existing.providers[name]?.apiKey;
|
|
4540
|
+
if (prev) {
|
|
4541
|
+
providers[name] = { ...provider, apiKey: prev };
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
return { providers };
|
|
4545
|
+
}
|
|
4546
|
+
function resolveUpsertApiKey(incomingKey, existingKey) {
|
|
4547
|
+
if (!isPreservedApiKey(incomingKey)) return incomingKey;
|
|
4548
|
+
return existingKey || void 0;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
// src/api/model-configs.ts
|
|
4552
|
+
var modelConfigsRoute = new Hono4();
|
|
2346
4553
|
var writeLock = Promise.resolve();
|
|
2347
4554
|
function withWriteLock(fn) {
|
|
2348
4555
|
const next = writeLock.then(fn, fn);
|
|
@@ -2352,28 +4559,26 @@ function withWriteLock(fn) {
|
|
|
2352
4559
|
return next;
|
|
2353
4560
|
}
|
|
2354
4561
|
function modelsPath() {
|
|
2355
|
-
return
|
|
4562
|
+
return join15(getAgentDir4(), "models.json");
|
|
2356
4563
|
}
|
|
2357
4564
|
async function readModelsJson() {
|
|
2358
4565
|
try {
|
|
2359
|
-
const raw = await
|
|
4566
|
+
const raw = await readFile8(modelsPath(), "utf-8");
|
|
2360
4567
|
return JSON.parse(raw);
|
|
2361
|
-
} catch (
|
|
2362
|
-
if (
|
|
4568
|
+
} catch (err2) {
|
|
4569
|
+
if (err2?.code === "ENOENT") {
|
|
2363
4570
|
return { providers: {} };
|
|
2364
4571
|
}
|
|
2365
|
-
throw
|
|
4572
|
+
throw err2;
|
|
2366
4573
|
}
|
|
2367
4574
|
}
|
|
2368
4575
|
async function writeModelsJson(config2) {
|
|
2369
|
-
|
|
2370
|
-
await mkdir4(dirname5(p), { recursive: true });
|
|
2371
|
-
await writeFile4(p, JSON.stringify(config2, null, 2), "utf-8");
|
|
4576
|
+
await writeJsonAtomic(modelsPath(), config2, { mode: 384 });
|
|
2372
4577
|
}
|
|
2373
4578
|
var ValidationError = class extends Error {
|
|
2374
|
-
constructor(message,
|
|
4579
|
+
constructor(message, status2) {
|
|
2375
4580
|
super(message);
|
|
2376
|
-
this.status =
|
|
4581
|
+
this.status = status2;
|
|
2377
4582
|
}
|
|
2378
4583
|
status;
|
|
2379
4584
|
};
|
|
@@ -2391,10 +4596,10 @@ function refreshRegistry(workspaceId) {
|
|
|
2391
4596
|
modelConfigsRoute.get("/", async (c) => {
|
|
2392
4597
|
try {
|
|
2393
4598
|
const config2 = await readModelsJson();
|
|
2394
|
-
const body = { config: config2 };
|
|
4599
|
+
const body = { config: maskConfigForResponse(config2) };
|
|
2395
4600
|
return c.json(body);
|
|
2396
|
-
} catch (
|
|
2397
|
-
const message =
|
|
4601
|
+
} catch (err2) {
|
|
4602
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2398
4603
|
return c.json({ ok: false, error: message }, 500);
|
|
2399
4604
|
}
|
|
2400
4605
|
});
|
|
@@ -2404,15 +4609,18 @@ modelConfigsRoute.put("/", async (c) => {
|
|
|
2404
4609
|
return c.json({ ok: false, error: "config.providers is required" }, 400);
|
|
2405
4610
|
}
|
|
2406
4611
|
try {
|
|
2407
|
-
await withWriteLock(async () => {
|
|
2408
|
-
await
|
|
4612
|
+
const config2 = await withWriteLock(async () => {
|
|
4613
|
+
const existing = await readModelsJson();
|
|
4614
|
+
const merged = preserveApiKeys(body.config, existing);
|
|
4615
|
+
await writeModelsJson(merged);
|
|
4616
|
+
return merged;
|
|
2409
4617
|
});
|
|
2410
4618
|
const workspaceId = c.req.query("workspaceId");
|
|
2411
4619
|
refreshRegistry(workspaceId ?? void 0);
|
|
2412
|
-
const resp = { config:
|
|
4620
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2413
4621
|
return c.json(resp);
|
|
2414
|
-
} catch (
|
|
2415
|
-
const message =
|
|
4622
|
+
} catch (err2) {
|
|
4623
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2416
4624
|
return c.json({ ok: false, error: message }, 500);
|
|
2417
4625
|
}
|
|
2418
4626
|
});
|
|
@@ -2421,8 +4629,8 @@ modelConfigsRoute.post("/providers", async (c) => {
|
|
|
2421
4629
|
if (!body?.name || !body?.provider) {
|
|
2422
4630
|
return c.json({ ok: false, error: "name and provider are required" }, 400);
|
|
2423
4631
|
}
|
|
2424
|
-
if (!body.provider.baseUrl || !body.provider.api
|
|
2425
|
-
return c.json({ ok: false, error: "provider must have baseUrl
|
|
4632
|
+
if (!body.provider.baseUrl || !body.provider.api) {
|
|
4633
|
+
return c.json({ ok: false, error: "provider must have baseUrl and api" }, 400);
|
|
2426
4634
|
}
|
|
2427
4635
|
if (!Array.isArray(body.provider.models)) {
|
|
2428
4636
|
return c.json({ ok: false, error: "provider.models must be an array" }, 400);
|
|
@@ -2430,16 +4638,23 @@ modelConfigsRoute.post("/providers", async (c) => {
|
|
|
2430
4638
|
try {
|
|
2431
4639
|
const config2 = await withWriteLock(async () => {
|
|
2432
4640
|
const cfg = await readModelsJson();
|
|
2433
|
-
cfg.providers[body.name]
|
|
4641
|
+
const apiKey2 = resolveUpsertApiKey(body.provider.apiKey, cfg.providers[body.name]?.apiKey);
|
|
4642
|
+
if (!apiKey2) {
|
|
4643
|
+
throw new ValidationError("apiKey is required for a new provider", 400);
|
|
4644
|
+
}
|
|
4645
|
+
cfg.providers[body.name] = { ...body.provider, apiKey: apiKey2 };
|
|
2434
4646
|
await writeModelsJson(cfg);
|
|
2435
4647
|
return cfg;
|
|
2436
4648
|
});
|
|
2437
4649
|
const workspaceId = c.req.query("workspaceId");
|
|
2438
4650
|
refreshRegistry(workspaceId ?? void 0);
|
|
2439
|
-
const resp = { config: config2 };
|
|
4651
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2440
4652
|
return c.json(resp);
|
|
2441
|
-
} catch (
|
|
2442
|
-
|
|
4653
|
+
} catch (err2) {
|
|
4654
|
+
if (err2 instanceof ValidationError) {
|
|
4655
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
4656
|
+
}
|
|
4657
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2443
4658
|
return c.json({ ok: false, error: message }, 500);
|
|
2444
4659
|
}
|
|
2445
4660
|
});
|
|
@@ -2460,13 +4675,13 @@ modelConfigsRoute.delete("/providers", async (c) => {
|
|
|
2460
4675
|
});
|
|
2461
4676
|
const workspaceId = c.req.query("workspaceId");
|
|
2462
4677
|
refreshRegistry(workspaceId ?? void 0);
|
|
2463
|
-
const resp = { config: config2 };
|
|
4678
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2464
4679
|
return c.json(resp);
|
|
2465
|
-
} catch (
|
|
2466
|
-
if (
|
|
2467
|
-
return c.json({ ok: false, error:
|
|
4680
|
+
} catch (err2) {
|
|
4681
|
+
if (err2 instanceof ValidationError) {
|
|
4682
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
2468
4683
|
}
|
|
2469
|
-
const message =
|
|
4684
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2470
4685
|
return c.json({ ok: false, error: message }, 500);
|
|
2471
4686
|
}
|
|
2472
4687
|
});
|
|
@@ -2492,13 +4707,13 @@ modelConfigsRoute.post("/providers/:provider/models", async (c) => {
|
|
|
2492
4707
|
});
|
|
2493
4708
|
const workspaceId = c.req.query("workspaceId");
|
|
2494
4709
|
refreshRegistry(workspaceId ?? void 0);
|
|
2495
|
-
const resp = { config: config2 };
|
|
4710
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2496
4711
|
return c.json(resp);
|
|
2497
|
-
} catch (
|
|
2498
|
-
if (
|
|
2499
|
-
return c.json({ ok: false, error:
|
|
4712
|
+
} catch (err2) {
|
|
4713
|
+
if (err2 instanceof ValidationError) {
|
|
4714
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
2500
4715
|
}
|
|
2501
|
-
const message =
|
|
4716
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2502
4717
|
return c.json({ ok: false, error: message }, 500);
|
|
2503
4718
|
}
|
|
2504
4719
|
});
|
|
@@ -2528,13 +4743,13 @@ modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
|
|
|
2528
4743
|
});
|
|
2529
4744
|
const workspaceId = c.req.query("workspaceId");
|
|
2530
4745
|
refreshRegistry(workspaceId ?? void 0);
|
|
2531
|
-
const resp = { config: config2 };
|
|
4746
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2532
4747
|
return c.json(resp);
|
|
2533
|
-
} catch (
|
|
2534
|
-
if (
|
|
2535
|
-
return c.json({ ok: false, error:
|
|
4748
|
+
} catch (err2) {
|
|
4749
|
+
if (err2 instanceof ValidationError) {
|
|
4750
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
2536
4751
|
}
|
|
2537
|
-
const message =
|
|
4752
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2538
4753
|
return c.json({ ok: false, error: message }, 500);
|
|
2539
4754
|
}
|
|
2540
4755
|
});
|
|
@@ -2557,19 +4772,83 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
|
|
|
2557
4772
|
});
|
|
2558
4773
|
const workspaceId = c.req.query("workspaceId");
|
|
2559
4774
|
refreshRegistry(workspaceId ?? void 0);
|
|
2560
|
-
const resp = { config: config2 };
|
|
4775
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
2561
4776
|
return c.json(resp);
|
|
2562
|
-
} catch (
|
|
2563
|
-
if (
|
|
2564
|
-
return c.json({ ok: false, error:
|
|
4777
|
+
} catch (err2) {
|
|
4778
|
+
if (err2 instanceof ValidationError) {
|
|
4779
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
2565
4780
|
}
|
|
2566
|
-
const message =
|
|
4781
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4782
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4783
|
+
}
|
|
4784
|
+
});
|
|
4785
|
+
|
|
4786
|
+
// src/api/web-search.ts
|
|
4787
|
+
import { Hono as Hono5 } from "hono";
|
|
4788
|
+
var webSearchRoute = new Hono5();
|
|
4789
|
+
function status() {
|
|
4790
|
+
return getKeyStatus();
|
|
4791
|
+
}
|
|
4792
|
+
webSearchRoute.get("/", (c) => c.json(status()));
|
|
4793
|
+
webSearchRoute.put("/", async (c) => {
|
|
4794
|
+
const body = await c.req.json().catch(() => null);
|
|
4795
|
+
if (!body || typeof body.apiKey !== "string") {
|
|
4796
|
+
return c.json({ ok: false, error: "apiKey (string) is required" }, 400);
|
|
4797
|
+
}
|
|
4798
|
+
try {
|
|
4799
|
+
await setTavilyApiKey(body.apiKey);
|
|
4800
|
+
return c.json(status());
|
|
4801
|
+
} catch (err2) {
|
|
4802
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4803
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4804
|
+
}
|
|
4805
|
+
});
|
|
4806
|
+
webSearchRoute.delete("/", async (c) => {
|
|
4807
|
+
try {
|
|
4808
|
+
await clearTavilyApiKey();
|
|
4809
|
+
return c.json(status());
|
|
4810
|
+
} catch (err2) {
|
|
4811
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2567
4812
|
return c.json({ ok: false, error: message }, 500);
|
|
2568
4813
|
}
|
|
2569
4814
|
});
|
|
2570
4815
|
|
|
2571
4816
|
// src/ws/hub.ts
|
|
2572
4817
|
import { WebSocketServer } from "ws";
|
|
4818
|
+
|
|
4819
|
+
// src/security.ts
|
|
4820
|
+
var LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
4821
|
+
function buildAllowedHosts() {
|
|
4822
|
+
const ports = /* @__PURE__ */ new Set([config.port, 5173]);
|
|
4823
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
4824
|
+
for (const name of LOOPBACK_HOSTNAMES) {
|
|
4825
|
+
for (const port of ports) {
|
|
4826
|
+
hosts.add(`${name}:${port}`);
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
return hosts;
|
|
4830
|
+
}
|
|
4831
|
+
function buildAllowedWsOrigins() {
|
|
4832
|
+
const origins = /* @__PURE__ */ new Set([config.corsOrigin]);
|
|
4833
|
+
for (const name of LOOPBACK_HOSTNAMES) {
|
|
4834
|
+
origins.add(`http://${name}:${config.port}`);
|
|
4835
|
+
origins.add(`http://${name}:5173`);
|
|
4836
|
+
}
|
|
4837
|
+
return origins;
|
|
4838
|
+
}
|
|
4839
|
+
var allowedHosts = buildAllowedHosts();
|
|
4840
|
+
var allowedWsOrigins = buildAllowedWsOrigins();
|
|
4841
|
+
function isAllowedHost(host) {
|
|
4842
|
+
if (!host) return false;
|
|
4843
|
+
return allowedHosts.has(host.toLowerCase());
|
|
4844
|
+
}
|
|
4845
|
+
function isAllowedWsOrigin(origin) {
|
|
4846
|
+
if (!origin) return false;
|
|
4847
|
+
return allowedWsOrigins.has(origin);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// src/ws/hub.ts
|
|
4851
|
+
var BACKGROUND_CAP = 4;
|
|
2573
4852
|
var replacementLocks = /* @__PURE__ */ new Map();
|
|
2574
4853
|
function withReplacementLock(workspaceId, fn) {
|
|
2575
4854
|
const prev = replacementLocks.get(workspaceId) ?? Promise.resolve();
|
|
@@ -2584,23 +4863,40 @@ function withReplacementLock(workspaceId, fn) {
|
|
|
2584
4863
|
return next;
|
|
2585
4864
|
}
|
|
2586
4865
|
function attachWsHub(httpServer) {
|
|
2587
|
-
const wss = new WebSocketServer({
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
send(ws, { type: "error", message: "invalid JSON" });
|
|
4866
|
+
const wss = new WebSocketServer({
|
|
4867
|
+
server: httpServer,
|
|
4868
|
+
path: "/ws",
|
|
4869
|
+
verifyClient: (info, cb) => {
|
|
4870
|
+
const host = info.req.headers.host;
|
|
4871
|
+
const origin = info.origin;
|
|
4872
|
+
if (!isAllowedHost(host) || !isAllowedWsOrigin(origin)) {
|
|
4873
|
+
cb(false, 403, "Forbidden");
|
|
2596
4874
|
return;
|
|
2597
4875
|
}
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
4876
|
+
cb(true);
|
|
4877
|
+
}
|
|
4878
|
+
});
|
|
4879
|
+
wss.on("connection", (ws) => {
|
|
4880
|
+
const state = { background: /* @__PURE__ */ new Map() };
|
|
4881
|
+
let inbound = Promise.resolve();
|
|
4882
|
+
ws.on("message", (raw) => {
|
|
4883
|
+
inbound = inbound.then(async () => {
|
|
4884
|
+
let msg;
|
|
4885
|
+
try {
|
|
4886
|
+
msg = JSON.parse(raw.toString());
|
|
4887
|
+
} catch {
|
|
4888
|
+
send(ws, { type: "error", message: "invalid JSON" });
|
|
4889
|
+
return;
|
|
4890
|
+
}
|
|
4891
|
+
try {
|
|
4892
|
+
await handle(ws, state, msg);
|
|
4893
|
+
} catch (err2) {
|
|
4894
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4895
|
+
send(ws, { type: "error", message, command: msg.type });
|
|
4896
|
+
}
|
|
4897
|
+
}).catch((err2) => {
|
|
4898
|
+
console.error("[ws] inbound chain error:", err2);
|
|
4899
|
+
});
|
|
2604
4900
|
});
|
|
2605
4901
|
ws.on("close", () => {
|
|
2606
4902
|
detach(state, ws);
|
|
@@ -2611,124 +4907,162 @@ function attachWsHub(httpServer) {
|
|
|
2611
4907
|
async function handle(ws, state, msg) {
|
|
2612
4908
|
switch (msg.type) {
|
|
2613
4909
|
case "subscribe": {
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
switched = await workspaceManager.switchSession(msg.workspaceId, msg.sessionPath);
|
|
2623
|
-
} catch (err) {
|
|
2624
|
-
switchError = err instanceof Error ? err.message : String(err);
|
|
2625
|
-
}
|
|
2626
|
-
});
|
|
2627
|
-
}
|
|
2628
|
-
if (!switched && !hadCurrentSubscription) {
|
|
2629
|
-
bindCurrentSession(ws, state, msg.workspaceId);
|
|
2630
|
-
}
|
|
2631
|
-
if (switchError) {
|
|
2632
|
-
send(ws, { type: "error", message: switchError, command: "subscribe" });
|
|
4910
|
+
let runtime;
|
|
4911
|
+
try {
|
|
4912
|
+
runtime = await workspaceManager.getOrCreate(msg.workspaceId, msg.sessionPath);
|
|
4913
|
+
} catch (err2) {
|
|
4914
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4915
|
+
send(ws, { type: "error", message, command: "subscribe" });
|
|
4916
|
+
send(ws, { type: "ack", command: "subscribe" });
|
|
4917
|
+
return;
|
|
2633
4918
|
}
|
|
4919
|
+
promoteToPrimary(ws, state, msg.workspaceId, runtime);
|
|
2634
4920
|
send(ws, { type: "ack", command: "subscribe" });
|
|
2635
4921
|
return;
|
|
2636
4922
|
}
|
|
4923
|
+
case "unsubscribe": {
|
|
4924
|
+
for (const [key, bg] of [...state.background]) {
|
|
4925
|
+
if (bg.workspaceId === msg.workspaceId) teardownBackground(state, key, ws);
|
|
4926
|
+
}
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
2637
4929
|
case "prompt": {
|
|
2638
|
-
const
|
|
2639
|
-
if (!
|
|
4930
|
+
const primary = state.primary;
|
|
4931
|
+
if (!primary) {
|
|
2640
4932
|
send(ws, { type: "error", message: "not subscribed", command: "prompt" });
|
|
2641
4933
|
return;
|
|
2642
4934
|
}
|
|
2643
|
-
if (replacementLocks.has(
|
|
4935
|
+
if (replacementLocks.has(primary.workspaceId)) {
|
|
2644
4936
|
send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
|
|
2645
4937
|
return;
|
|
2646
4938
|
}
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
send(ws, { type: "error", message: "runtime gone", command: "prompt" });
|
|
2650
|
-
return;
|
|
2651
|
-
}
|
|
2652
|
-
void runtime.session.prompt(msg.message, {
|
|
2653
|
-
streamingBehavior: msg.streamingBehavior
|
|
2654
|
-
}).catch((err) => {
|
|
2655
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4939
|
+
void primary.runtime.session.prompt(msg.message, { streamingBehavior: msg.streamingBehavior }).catch((err2) => {
|
|
4940
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
2656
4941
|
send(ws, { type: "error", message, command: "prompt" });
|
|
2657
4942
|
});
|
|
2658
4943
|
return;
|
|
2659
4944
|
}
|
|
2660
4945
|
case "abort": {
|
|
2661
|
-
const
|
|
2662
|
-
if (!
|
|
4946
|
+
const primary = state.primary;
|
|
4947
|
+
if (!primary) {
|
|
2663
4948
|
send(ws, { type: "error", message: "not subscribed", command: "abort" });
|
|
2664
4949
|
return;
|
|
2665
4950
|
}
|
|
2666
|
-
|
|
2667
|
-
send(ws, { type: "error", message: "session switching in progress", command: "abort" });
|
|
2668
|
-
return;
|
|
2669
|
-
}
|
|
2670
|
-
const runtime = workspaceManager.get(wsId);
|
|
2671
|
-
if (!runtime) return;
|
|
2672
|
-
await runtime.session.abort();
|
|
4951
|
+
await primary.runtime.session.abort();
|
|
2673
4952
|
return;
|
|
2674
4953
|
}
|
|
2675
4954
|
case "new_session": {
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
send(ws, { type: "error", message: "runtime gone", command: "new_session" });
|
|
2685
|
-
return;
|
|
2686
|
-
}
|
|
2687
|
-
if (runtime.session.isStreaming) {
|
|
2688
|
-
send(ws, { type: "error", message: "cannot create session while streaming", command: "new_session" });
|
|
4955
|
+
const workspaceId = msg.workspaceId;
|
|
4956
|
+
await withReplacementLock(workspaceId, async () => {
|
|
4957
|
+
let runtime;
|
|
4958
|
+
try {
|
|
4959
|
+
runtime = await workspaceManager.createSession(workspaceId);
|
|
4960
|
+
} catch (err2) {
|
|
4961
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4962
|
+
send(ws, { type: "error", message, command: "new_session" });
|
|
2689
4963
|
return;
|
|
2690
4964
|
}
|
|
2691
|
-
|
|
2692
|
-
if (result.cancelled) {
|
|
2693
|
-
send(ws, { type: "error", message: "new session cancelled", command: "new_session" });
|
|
2694
|
-
}
|
|
4965
|
+
promoteToPrimary(ws, state, workspaceId, runtime);
|
|
2695
4966
|
});
|
|
2696
4967
|
return;
|
|
2697
4968
|
}
|
|
2698
4969
|
case "fork": {
|
|
2699
|
-
const
|
|
2700
|
-
if (!
|
|
4970
|
+
const primary = state.primary;
|
|
4971
|
+
if (!primary) {
|
|
2701
4972
|
send(ws, { type: "error", message: "not subscribed", command: "fork" });
|
|
2702
4973
|
return;
|
|
2703
4974
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
4975
|
+
const workspaceId = primary.workspaceId;
|
|
4976
|
+
await withReplacementLock(workspaceId, async () => {
|
|
4977
|
+
const source = state.primary;
|
|
4978
|
+
if (!source) {
|
|
4979
|
+
send(ws, { type: "error", message: "not subscribed", command: "fork" });
|
|
2708
4980
|
return;
|
|
2709
4981
|
}
|
|
2710
|
-
if (
|
|
4982
|
+
if (!source.sessionPath) {
|
|
4983
|
+
send(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4986
|
+
if (source.runtime.session.isStreaming) {
|
|
2711
4987
|
send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
|
|
2712
4988
|
return;
|
|
2713
4989
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
4990
|
+
let result;
|
|
4991
|
+
try {
|
|
4992
|
+
result = await workspaceManager.fork(workspaceId, source.sessionPath, msg.entryId);
|
|
4993
|
+
} catch (err2) {
|
|
4994
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4995
|
+
send(ws, { type: "error", message, command: "fork" });
|
|
4996
|
+
return;
|
|
4997
|
+
}
|
|
4998
|
+
if (result.cancelled || !result.runtime) {
|
|
2716
4999
|
send(ws, { type: "error", message: "fork cancelled", command: "fork" });
|
|
5000
|
+
return;
|
|
2717
5001
|
}
|
|
5002
|
+
promoteToPrimary(ws, state, workspaceId, result.runtime);
|
|
2718
5003
|
});
|
|
2719
5004
|
return;
|
|
2720
5005
|
}
|
|
2721
5006
|
case "answer_question": {
|
|
2722
|
-
const
|
|
2723
|
-
if (!
|
|
5007
|
+
const primary = state.primary;
|
|
5008
|
+
if (!primary) {
|
|
2724
5009
|
send(ws, { type: "error", message: "not subscribed", command: "answer_question" });
|
|
2725
5010
|
return;
|
|
2726
5011
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
5012
|
+
resolveAnswer(msg.toolCallId, msg.answer, primary.runtime.session.sessionFile ?? null);
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
case "navigate_tree": {
|
|
5016
|
+
const primary = state.primary;
|
|
5017
|
+
if (!primary) {
|
|
5018
|
+
send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
|
|
5019
|
+
return;
|
|
5020
|
+
}
|
|
5021
|
+
if (msg.workspaceId !== primary.workspaceId) {
|
|
5022
|
+
send(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
|
|
5023
|
+
return;
|
|
5024
|
+
}
|
|
5025
|
+
await withReplacementLock(primary.workspaceId, async () => {
|
|
5026
|
+
const current = state.primary;
|
|
5027
|
+
if (!current) {
|
|
5028
|
+
send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
if (current.runtime.session.isStreaming) {
|
|
5032
|
+
send(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
|
|
5033
|
+
return;
|
|
5034
|
+
}
|
|
5035
|
+
const result = await current.runtime.session.navigateTree(msg.targetId, {
|
|
5036
|
+
summarize: msg.summarize,
|
|
5037
|
+
customInstructions: msg.customInstructions
|
|
5038
|
+
});
|
|
5039
|
+
send(ws, {
|
|
5040
|
+
type: "navigate_tree_result",
|
|
5041
|
+
workspaceId: current.workspaceId,
|
|
5042
|
+
editorText: result.editorText,
|
|
5043
|
+
cancelled: result.cancelled
|
|
5044
|
+
});
|
|
5045
|
+
});
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
case "compact": {
|
|
5049
|
+
const primary = state.primary;
|
|
5050
|
+
if (!primary) {
|
|
5051
|
+
send(ws, { type: "error", message: "not subscribed", command: "compact" });
|
|
5052
|
+
return;
|
|
5053
|
+
}
|
|
5054
|
+
if (primary.runtime.session.isStreaming) {
|
|
5055
|
+
send(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
if (primary.runtime.session.isCompacting) {
|
|
5059
|
+
send(ws, { type: "error", message: "compaction already in progress", command: "compact" });
|
|
5060
|
+
return;
|
|
5061
|
+
}
|
|
5062
|
+
primary.runtime.session.compact().catch((err2) => {
|
|
5063
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
5064
|
+
send(ws, { type: "error", message, command: "compact" });
|
|
5065
|
+
});
|
|
2732
5066
|
return;
|
|
2733
5067
|
}
|
|
2734
5068
|
default: {
|
|
@@ -2738,28 +5072,26 @@ async function handle(ws, state, msg) {
|
|
|
2738
5072
|
}
|
|
2739
5073
|
}
|
|
2740
5074
|
}
|
|
2741
|
-
function
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
state.unsubscribeRebind = workspaceManager.onSessionReplaced(workspaceId, () => {
|
|
2747
|
-
if (state.workspaceId !== workspaceId) return;
|
|
2748
|
-
bindCurrentSession(ws, state, workspaceId);
|
|
2749
|
-
});
|
|
2750
|
-
}
|
|
2751
|
-
function bindCurrentSession(ws, state, workspaceId) {
|
|
2752
|
-
const runtime = workspaceManager.get(workspaceId);
|
|
2753
|
-
if (!runtime) {
|
|
2754
|
-
send(ws, { type: "error", message: "runtime gone", command: "subscribe" });
|
|
5075
|
+
function promoteToPrimary(ws, state, workspaceId, runtime) {
|
|
5076
|
+
const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
|
|
5077
|
+
if (state.primary?.runtimeKey === runtimeKey) {
|
|
5078
|
+
workspaceManager.setActive(workspaceId, runtime);
|
|
5079
|
+
sendSubscribed(ws, workspaceId, runtime);
|
|
2755
5080
|
return;
|
|
2756
5081
|
}
|
|
2757
|
-
state.
|
|
5082
|
+
if (state.primary) demotePrimaryToBackground(ws, state);
|
|
5083
|
+
if (state.background.has(runtimeKey)) teardownBackground(state, runtimeKey, ws);
|
|
5084
|
+
bindPrimary(ws, state, workspaceId, runtime);
|
|
5085
|
+
}
|
|
5086
|
+
function bindPrimary(ws, state, workspaceId, runtime) {
|
|
5087
|
+
workspaceManager.addSubscriber(workspaceId, ws);
|
|
5088
|
+
workspaceManager.setActive(workspaceId, runtime);
|
|
2758
5089
|
const session = runtime.session;
|
|
2759
5090
|
const sessionPath = session.sessionFile ?? null;
|
|
5091
|
+
const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
|
|
2760
5092
|
let assistantStartAt;
|
|
2761
5093
|
let assistantFirstTokenAt;
|
|
2762
|
-
|
|
5094
|
+
const unsubscribe = session.subscribe((ev) => {
|
|
2763
5095
|
const payload = translatePiEvent(ev);
|
|
2764
5096
|
if (!payload) return;
|
|
2765
5097
|
if (payload.kind === "message_start" && payload.role === "assistant") {
|
|
@@ -2768,12 +5100,7 @@ function bindCurrentSession(ws, state, workspaceId) {
|
|
|
2768
5100
|
} else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
|
|
2769
5101
|
assistantFirstTokenAt = performance.now();
|
|
2770
5102
|
}
|
|
2771
|
-
send(ws, {
|
|
2772
|
-
type: "event",
|
|
2773
|
-
workspaceId,
|
|
2774
|
-
sessionPath,
|
|
2775
|
-
payload
|
|
2776
|
-
});
|
|
5103
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload });
|
|
2777
5104
|
if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
|
|
2778
5105
|
const now = performance.now();
|
|
2779
5106
|
const timing = {
|
|
@@ -2781,12 +5108,7 @@ function bindCurrentSession(ws, state, workspaceId) {
|
|
|
2781
5108
|
firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
|
|
2782
5109
|
totalMs: Math.round(now - assistantStartAt)
|
|
2783
5110
|
};
|
|
2784
|
-
send(ws, {
|
|
2785
|
-
type: "event",
|
|
2786
|
-
workspaceId,
|
|
2787
|
-
sessionPath,
|
|
2788
|
-
payload: timing
|
|
2789
|
-
});
|
|
5111
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload: timing });
|
|
2790
5112
|
assistantStartAt = void 0;
|
|
2791
5113
|
assistantFirstTokenAt = void 0;
|
|
2792
5114
|
}
|
|
@@ -2794,33 +5116,80 @@ function bindCurrentSession(ws, state, workspaceId) {
|
|
|
2794
5116
|
sendContextUsage(ws, runtime, workspaceId, sessionPath);
|
|
2795
5117
|
}
|
|
2796
5118
|
});
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
5119
|
+
state.primary = { runtimeKey, workspaceId, sessionPath, runtime, unsubscribe };
|
|
5120
|
+
sendSubscribed(ws, workspaceId, runtime);
|
|
5121
|
+
const streamingMessage = runtime.session.state.streamingMessage;
|
|
5122
|
+
const scanMessages = streamingMessage ? [...runtime.session.messages, streamingMessage] : runtime.session.messages;
|
|
5123
|
+
for (const payload of inFlightRunningToolsSnapshot(
|
|
5124
|
+
runtime.session.state.pendingToolCalls,
|
|
5125
|
+
scanMessages
|
|
5126
|
+
)) {
|
|
5127
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload });
|
|
5128
|
+
}
|
|
5129
|
+
const inFlight = inFlightAssistantSnapshot(streamingMessage);
|
|
2804
5130
|
if (inFlight) {
|
|
2805
5131
|
for (const payload of inFlight) {
|
|
2806
|
-
send(ws, {
|
|
2807
|
-
type: "event",
|
|
2808
|
-
workspaceId,
|
|
2809
|
-
sessionPath,
|
|
2810
|
-
payload
|
|
2811
|
-
});
|
|
5132
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload });
|
|
2812
5133
|
}
|
|
2813
5134
|
}
|
|
2814
5135
|
for (const payload of inFlightToolCallsSnapshot(sessionPath)) {
|
|
2815
|
-
send(ws, {
|
|
2816
|
-
type: "event",
|
|
2817
|
-
workspaceId,
|
|
2818
|
-
sessionPath,
|
|
2819
|
-
payload
|
|
2820
|
-
});
|
|
5136
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload });
|
|
2821
5137
|
}
|
|
2822
5138
|
sendContextUsage(ws, runtime, workspaceId, sessionPath);
|
|
2823
5139
|
}
|
|
5140
|
+
function demotePrimaryToBackground(ws, state) {
|
|
5141
|
+
const primary = state.primary;
|
|
5142
|
+
if (!primary) return;
|
|
5143
|
+
primary.unsubscribe();
|
|
5144
|
+
state.primary = void 0;
|
|
5145
|
+
const session = primary.runtime.session;
|
|
5146
|
+
const sessionPath = primary.sessionPath;
|
|
5147
|
+
const unsubscribeSession = session.subscribe((ev) => {
|
|
5148
|
+
const payload = translatePiEvent(ev);
|
|
5149
|
+
if (!payload) return;
|
|
5150
|
+
send(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
|
|
5151
|
+
if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
|
|
5152
|
+
sendContextUsage(ws, primary.runtime, primary.workspaceId, sessionPath);
|
|
5153
|
+
}
|
|
5154
|
+
});
|
|
5155
|
+
state.background.set(primary.runtimeKey, {
|
|
5156
|
+
workspaceId: primary.workspaceId,
|
|
5157
|
+
sessionPath,
|
|
5158
|
+
unsubscribeSession
|
|
5159
|
+
});
|
|
5160
|
+
while (state.background.size > BACKGROUND_CAP) {
|
|
5161
|
+
const oldestKey = state.background.keys().next().value;
|
|
5162
|
+
if (oldestKey === void 0) break;
|
|
5163
|
+
const evicted = state.background.get(oldestKey);
|
|
5164
|
+
teardownBackground(state, oldestKey, ws);
|
|
5165
|
+
if (evicted) {
|
|
5166
|
+
send(ws, {
|
|
5167
|
+
type: "background_evicted",
|
|
5168
|
+
workspaceId: evicted.workspaceId,
|
|
5169
|
+
sessionPath: evicted.sessionPath
|
|
5170
|
+
});
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
function teardownBackground(state, runtimeKey, ws) {
|
|
5175
|
+
const bg = state.background.get(runtimeKey);
|
|
5176
|
+
if (!bg) return;
|
|
5177
|
+
bg.unsubscribeSession();
|
|
5178
|
+
state.background.delete(runtimeKey);
|
|
5179
|
+
if (ws) unrefWorkspaceSubscriber(state, bg.workspaceId, ws);
|
|
5180
|
+
}
|
|
5181
|
+
function sendSubscribed(ws, workspaceId, runtime) {
|
|
5182
|
+
send(ws, {
|
|
5183
|
+
type: "subscribed",
|
|
5184
|
+
workspaceId,
|
|
5185
|
+
sessionPath: runtime.session.sessionFile ?? null,
|
|
5186
|
+
sessionId: runtime.session.sessionId
|
|
5187
|
+
});
|
|
5188
|
+
}
|
|
5189
|
+
function unrefWorkspaceSubscriber(state, workspaceId, ws) {
|
|
5190
|
+
const stillUsed = state.primary?.workspaceId === workspaceId || [...state.background.values()].some((b) => b.workspaceId === workspaceId);
|
|
5191
|
+
if (!stillUsed) workspaceManager.removeSubscriber(workspaceId, ws);
|
|
5192
|
+
}
|
|
2824
5193
|
function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
|
|
2825
5194
|
const usage = runtime.session.getContextUsage();
|
|
2826
5195
|
if (!usage) return;
|
|
@@ -2830,22 +5199,20 @@ function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
|
|
|
2830
5199
|
contextWindow: usage.contextWindow,
|
|
2831
5200
|
percent: usage.percent
|
|
2832
5201
|
};
|
|
2833
|
-
send(ws, {
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
5202
|
+
send(ws, { type: "event", workspaceId, sessionPath, payload });
|
|
5203
|
+
}
|
|
5204
|
+
function detachPrimary(state, ws) {
|
|
5205
|
+
const primary = state.primary;
|
|
5206
|
+
if (!primary) return;
|
|
5207
|
+
primary.unsubscribe();
|
|
5208
|
+
state.primary = void 0;
|
|
5209
|
+
if (ws) unrefWorkspaceSubscriber(state, primary.workspaceId, ws);
|
|
2839
5210
|
}
|
|
2840
5211
|
function detach(state, ws) {
|
|
2841
|
-
state
|
|
2842
|
-
state.
|
|
2843
|
-
|
|
2844
|
-
state.unsubscribeRebind = void 0;
|
|
2845
|
-
if (state.workspaceId && ws) {
|
|
2846
|
-
workspaceManager.removeSubscriber(state.workspaceId, ws);
|
|
5212
|
+
detachPrimary(state, ws);
|
|
5213
|
+
for (const runtimeKey of [...state.background.keys()]) {
|
|
5214
|
+
teardownBackground(state, runtimeKey, ws);
|
|
2847
5215
|
}
|
|
2848
|
-
state.workspaceId = void 0;
|
|
2849
5216
|
}
|
|
2850
5217
|
function send(ws, msg) {
|
|
2851
5218
|
if (ws.readyState !== ws.OPEN) return;
|
|
@@ -2854,10 +5221,10 @@ function send(ws, msg) {
|
|
|
2854
5221
|
|
|
2855
5222
|
// src/index.ts
|
|
2856
5223
|
configureHttpProxy();
|
|
2857
|
-
var app = new
|
|
2858
|
-
var distDir = dirname6(
|
|
2859
|
-
var webRoot =
|
|
2860
|
-
var webIndexPath =
|
|
5224
|
+
var app = new Hono6();
|
|
5225
|
+
var distDir = dirname6(fileURLToPath2(import.meta.url));
|
|
5226
|
+
var webRoot = resolve7(process.env.PI_PILOT_WEB_ROOT ?? join16(distDir, "..", "public"));
|
|
5227
|
+
var webIndexPath = join16(webRoot, "index.html");
|
|
2861
5228
|
var mimeTypes = {
|
|
2862
5229
|
".css": "text/css; charset=utf-8",
|
|
2863
5230
|
".html": "text/html; charset=utf-8",
|
|
@@ -2883,7 +5250,7 @@ function safeResolveWebPath(pathname) {
|
|
|
2883
5250
|
return void 0;
|
|
2884
5251
|
}
|
|
2885
5252
|
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
2886
|
-
const candidate =
|
|
5253
|
+
const candidate = resolve7(webRoot, relativePath);
|
|
2887
5254
|
if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
|
|
2888
5255
|
return void 0;
|
|
2889
5256
|
}
|
|
@@ -2891,11 +5258,11 @@ function safeResolveWebPath(pathname) {
|
|
|
2891
5258
|
}
|
|
2892
5259
|
async function readWebFile(path) {
|
|
2893
5260
|
try {
|
|
2894
|
-
return await
|
|
2895
|
-
} catch (
|
|
2896
|
-
const code =
|
|
5261
|
+
return await readFile9(path);
|
|
5262
|
+
} catch (err2) {
|
|
5263
|
+
const code = err2.code;
|
|
2897
5264
|
if (code === "ENOENT" || code === "EISDIR") return void 0;
|
|
2898
|
-
throw
|
|
5265
|
+
throw err2;
|
|
2899
5266
|
}
|
|
2900
5267
|
}
|
|
2901
5268
|
async function serveWeb(c) {
|
|
@@ -2904,7 +5271,7 @@ async function serveWeb(c) {
|
|
|
2904
5271
|
const assetPath = safeResolveWebPath(pathname);
|
|
2905
5272
|
if (!assetPath) return c.text("invalid asset path", 400);
|
|
2906
5273
|
const asset = await readWebFile(assetPath);
|
|
2907
|
-
const body = asset ?? await
|
|
5274
|
+
const body = asset ?? await readFile9(webIndexPath);
|
|
2908
5275
|
const filePath = asset ? assetPath : webIndexPath;
|
|
2909
5276
|
const headers = {
|
|
2910
5277
|
"Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",
|
|
@@ -2912,6 +5279,12 @@ async function serveWeb(c) {
|
|
|
2912
5279
|
};
|
|
2913
5280
|
return new Response(body, { headers });
|
|
2914
5281
|
}
|
|
5282
|
+
app.use("*", async (c, next) => {
|
|
5283
|
+
if (!isAllowedHost(c.req.header("host"))) {
|
|
5284
|
+
return c.text("Forbidden", 403);
|
|
5285
|
+
}
|
|
5286
|
+
await next();
|
|
5287
|
+
});
|
|
2915
5288
|
app.use(
|
|
2916
5289
|
"/api/*",
|
|
2917
5290
|
cors({
|
|
@@ -2923,7 +5296,8 @@ app.get("/api/health", (c) => c.json({ ok: true }));
|
|
|
2923
5296
|
app.route("/api/workspaces", workspacesRoute);
|
|
2924
5297
|
app.route("/api/fs", fsRoute);
|
|
2925
5298
|
app.route("/api/model-configs", modelConfigsRoute);
|
|
2926
|
-
|
|
5299
|
+
app.route("/api/web-search", webSearchRoute);
|
|
5300
|
+
if (existsSync2(webIndexPath)) {
|
|
2927
5301
|
app.get("*", serveWeb);
|
|
2928
5302
|
} else {
|
|
2929
5303
|
app.get(
|
|
@@ -2935,6 +5309,9 @@ if (existsSync(webIndexPath)) {
|
|
|
2935
5309
|
);
|
|
2936
5310
|
}
|
|
2937
5311
|
await loadBuiltinPrefs();
|
|
5312
|
+
await loadSessionToolPrefs();
|
|
5313
|
+
await loadWebSearchPrefs();
|
|
5314
|
+
await sweepOrphanedChildrenOnBoot();
|
|
2938
5315
|
var server = serve(
|
|
2939
5316
|
{
|
|
2940
5317
|
fetch: app.fetch,
|
|
@@ -2953,9 +5330,20 @@ async function shutdown(reason) {
|
|
|
2953
5330
|
} catch (e) {
|
|
2954
5331
|
console.error("[pi-pilot] disposeAll error:", e);
|
|
2955
5332
|
}
|
|
5333
|
+
const sweptChildren = killAllChildren();
|
|
5334
|
+
if (sweptChildren > 0) {
|
|
5335
|
+
console.warn(`[pi-pilot] killed ${sweptChildren} lingering subagent child(ren)`);
|
|
5336
|
+
}
|
|
2956
5337
|
server.close(() => process.exit(0));
|
|
2957
5338
|
setTimeout(() => process.exit(1), 3e3).unref();
|
|
2958
5339
|
}
|
|
5340
|
+
process.on("unhandledRejection", (reason) => {
|
|
5341
|
+
console.error("[pi-pilot] unhandled rejection (process kept alive):", reason);
|
|
5342
|
+
});
|
|
5343
|
+
process.on("uncaughtException", (err2) => {
|
|
5344
|
+
console.error("[pi-pilot] uncaught exception:", err2);
|
|
5345
|
+
void shutdown("uncaughtException");
|
|
5346
|
+
});
|
|
2959
5347
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2960
5348
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2961
5349
|
//# sourceMappingURL=index.js.map
|