@jyork0828/pi-pilot 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/index.js +2395 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/public/assets/index-Bqpj-esU.css +1 -0
- package/public/assets/index-e1hZMoPP.js +218 -0
- package/public/index.html +21 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
6
|
+
import { dirname as dirname5, extname, join as join8, resolve as resolve5, sep as sep3 } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { serve } from "@hono/node-server";
|
|
9
|
+
import { Hono as Hono4 } from "hono";
|
|
10
|
+
import { cors } from "hono/cors";
|
|
11
|
+
|
|
12
|
+
// src/config.ts
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var config = {
|
|
16
|
+
/** HTTP + WS port. */
|
|
17
|
+
port: Number(process.env.PI_PILOT_PORT ?? 5174),
|
|
18
|
+
/** Bind address. Hard-coded localhost; do NOT make this configurable
|
|
19
|
+
* without first reviewing the security implications of exposing bash. */
|
|
20
|
+
host: "127.0.0.1",
|
|
21
|
+
/** Where pi-pilot stores its own (non-pi) state. */
|
|
22
|
+
dataDir: process.env.PI_PILOT_DATA_DIR ?? join(homedir(), ".pi", "webui"),
|
|
23
|
+
/** Dev origin allowed by CORS for /api. The Vite dev server. */
|
|
24
|
+
corsOrigin: process.env.PI_PILOT_CORS_ORIGIN ?? "http://localhost:5173"
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/api/workspaces.ts
|
|
28
|
+
import { stat as stat2 } from "fs/promises";
|
|
29
|
+
import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
|
|
30
|
+
import { Hono } from "hono";
|
|
31
|
+
|
|
32
|
+
// src/storage/resource-writer.ts
|
|
33
|
+
import {
|
|
34
|
+
mkdir,
|
|
35
|
+
readFile,
|
|
36
|
+
rm,
|
|
37
|
+
stat,
|
|
38
|
+
unlink,
|
|
39
|
+
writeFile
|
|
40
|
+
} from "fs/promises";
|
|
41
|
+
import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
42
|
+
var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
43
|
+
var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
44
|
+
function ensureSkillName(name) {
|
|
45
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
46
|
+
throw new HttpError(400, "skill name must be lowercase a-z/0-9/hyphens, 1-64 chars, no leading/trailing/double hyphens");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function ensurePromptName(name) {
|
|
50
|
+
if (!PROMPT_NAME_RE.test(name)) {
|
|
51
|
+
throw new HttpError(400, "prompt name must be lowercase a-z/0-9/hyphens/underscores, 1-64 chars");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function assertUnder(target, roots) {
|
|
55
|
+
const t = resolve(target);
|
|
56
|
+
const ok = roots.some((root) => {
|
|
57
|
+
const r = resolve(root);
|
|
58
|
+
return t === r || t.startsWith(r + sep);
|
|
59
|
+
});
|
|
60
|
+
if (!ok) {
|
|
61
|
+
throw new HttpError(500, `refusing to touch path outside managed roots: ${target}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function skillDirFor(scope, name, roots) {
|
|
65
|
+
ensureSkillName(name);
|
|
66
|
+
const base = scope === "user" ? roots.userSkills : roots.projectSkills;
|
|
67
|
+
return join2(base, name);
|
|
68
|
+
}
|
|
69
|
+
function promptFileFor(scope, name, roots) {
|
|
70
|
+
ensurePromptName(name);
|
|
71
|
+
const base = scope === "user" ? roots.userPrompts : roots.projectPrompts;
|
|
72
|
+
return join2(base, `${name}.md`);
|
|
73
|
+
}
|
|
74
|
+
function resolveResourceRoots(opts) {
|
|
75
|
+
return {
|
|
76
|
+
userSkills: join2(opts.agentDir, "skills"),
|
|
77
|
+
projectSkills: join2(opts.workspaceCwd, ".pi", "skills"),
|
|
78
|
+
userPrompts: join2(opts.agentDir, "prompts"),
|
|
79
|
+
projectPrompts: join2(opts.workspaceCwd, ".pi", "prompts")
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function scopeFor(absPath, roots) {
|
|
83
|
+
const p = resolve(absPath);
|
|
84
|
+
for (const [root, scope] of [
|
|
85
|
+
[roots.userSkills, "user"],
|
|
86
|
+
[roots.projectSkills, "project"],
|
|
87
|
+
[roots.userPrompts, "user"],
|
|
88
|
+
[roots.projectPrompts, "project"]
|
|
89
|
+
]) {
|
|
90
|
+
const r = resolve(root);
|
|
91
|
+
if (p === r || p.startsWith(r + sep)) return scope;
|
|
92
|
+
}
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
function parseFile(content) {
|
|
96
|
+
const rawLines = content.split(/\r?\n/);
|
|
97
|
+
if (rawLines[0] !== "---") {
|
|
98
|
+
return { frontmatter: {}, lines: [], body: content };
|
|
99
|
+
}
|
|
100
|
+
const fm = {};
|
|
101
|
+
const lines = [];
|
|
102
|
+
let i = 1;
|
|
103
|
+
while (i < rawLines.length && rawLines[i] !== "---") {
|
|
104
|
+
const line = rawLines[i] ?? "";
|
|
105
|
+
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/);
|
|
106
|
+
if (m) {
|
|
107
|
+
const key = m[1];
|
|
108
|
+
const rawVal = m[2] ?? "";
|
|
109
|
+
const value = parseScalar(rawVal);
|
|
110
|
+
fm[key] = value;
|
|
111
|
+
lines.push({ key, value, raw: line });
|
|
112
|
+
}
|
|
113
|
+
i++;
|
|
114
|
+
}
|
|
115
|
+
if (i >= rawLines.length) {
|
|
116
|
+
return { frontmatter: {}, lines: [], body: content };
|
|
117
|
+
}
|
|
118
|
+
const body = rawLines.slice(i + 1).join("\n");
|
|
119
|
+
return { frontmatter: fm, lines, body };
|
|
120
|
+
}
|
|
121
|
+
function extraLines(lines, knownKeys) {
|
|
122
|
+
const known = new Set(knownKeys);
|
|
123
|
+
return lines.filter((l) => !known.has(l.key)).map((l) => l.raw);
|
|
124
|
+
}
|
|
125
|
+
function parseScalar(raw) {
|
|
126
|
+
if (raw === "true") return true;
|
|
127
|
+
if (raw === "false") return false;
|
|
128
|
+
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
|
129
|
+
return raw.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
130
|
+
}
|
|
131
|
+
if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
|
|
132
|
+
return raw.slice(1, -1).replace(/''/g, "'");
|
|
133
|
+
}
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
function buildFile(fields, body, extras = []) {
|
|
137
|
+
const out = ["---"];
|
|
138
|
+
for (const [key, value] of fields) {
|
|
139
|
+
if (value === void 0 || value === "") continue;
|
|
140
|
+
if (typeof value === "boolean") {
|
|
141
|
+
out.push(`${key}: ${value ? "true" : "false"}`);
|
|
142
|
+
} else {
|
|
143
|
+
out.push(`${key}: ${formatString(value)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const raw of extras) out.push(raw);
|
|
147
|
+
out.push("---");
|
|
148
|
+
const trimmedBody = body.replace(/\s+$/, "");
|
|
149
|
+
const text = `${out.join("\n")}
|
|
150
|
+
${trimmedBody}
|
|
151
|
+
`;
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
var SKILL_KNOWN_KEYS = ["name", "description", "disable-model-invocation"];
|
|
155
|
+
var PROMPT_KNOWN_KEYS = ["description", "argument-hint"];
|
|
156
|
+
function formatString(value) {
|
|
157
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
158
|
+
return `"${escaped}"`;
|
|
159
|
+
}
|
|
160
|
+
async function createSkill(opts) {
|
|
161
|
+
const dir = skillDirFor(opts.scope, opts.name, opts.roots);
|
|
162
|
+
assertUnder(dir, [opts.roots.userSkills, opts.roots.projectSkills]);
|
|
163
|
+
if (await exists(dir)) {
|
|
164
|
+
throw new HttpError(409, `skill already exists at ${dir}`);
|
|
165
|
+
}
|
|
166
|
+
const file = join2(dir, "SKILL.md");
|
|
167
|
+
const text = buildFile(
|
|
168
|
+
[
|
|
169
|
+
["name", opts.name],
|
|
170
|
+
["description", opts.description],
|
|
171
|
+
["disable-model-invocation", opts.disableModelInvocation]
|
|
172
|
+
],
|
|
173
|
+
opts.body
|
|
174
|
+
);
|
|
175
|
+
await mkdir(dir, { recursive: true });
|
|
176
|
+
await writeFile(file, text, "utf8");
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
async function updateSkill(opts) {
|
|
180
|
+
if (!isAbsolute(opts.filePath)) {
|
|
181
|
+
throw new HttpError(400, "filePath must be absolute");
|
|
182
|
+
}
|
|
183
|
+
assertUnder(opts.filePath, [opts.roots.userSkills, opts.roots.projectSkills]);
|
|
184
|
+
if (!await exists(opts.filePath)) {
|
|
185
|
+
throw new HttpError(404, `skill file not found: ${opts.filePath}`);
|
|
186
|
+
}
|
|
187
|
+
const existing = await readFile(opts.filePath, "utf8");
|
|
188
|
+
const extras = extraLines(parseFile(existing).lines, SKILL_KNOWN_KEYS);
|
|
189
|
+
const text = buildFile(
|
|
190
|
+
[
|
|
191
|
+
["name", opts.name],
|
|
192
|
+
["description", opts.description],
|
|
193
|
+
["disable-model-invocation", opts.disableModelInvocation]
|
|
194
|
+
],
|
|
195
|
+
opts.body,
|
|
196
|
+
extras
|
|
197
|
+
);
|
|
198
|
+
await writeFile(opts.filePath, text, "utf8");
|
|
199
|
+
return opts.filePath;
|
|
200
|
+
}
|
|
201
|
+
async function deleteSkill(filePath, roots) {
|
|
202
|
+
if (!isAbsolute(filePath)) {
|
|
203
|
+
throw new HttpError(400, "filePath must be absolute");
|
|
204
|
+
}
|
|
205
|
+
assertUnder(filePath, [roots.userSkills, roots.projectSkills]);
|
|
206
|
+
if (!await exists(filePath)) {
|
|
207
|
+
throw new HttpError(404, `skill file not found: ${filePath}`);
|
|
208
|
+
}
|
|
209
|
+
const dir = dirname(filePath);
|
|
210
|
+
const parentIsRoot = resolve(dir) === resolve(roots.userSkills) || resolve(dir) === resolve(roots.projectSkills);
|
|
211
|
+
if (parentIsRoot) {
|
|
212
|
+
await unlink(filePath);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
assertUnder(dir, [roots.userSkills, roots.projectSkills]);
|
|
216
|
+
await rm(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
async function createPrompt(opts) {
|
|
219
|
+
const file = promptFileFor(opts.scope, opts.name, opts.roots);
|
|
220
|
+
assertUnder(file, [opts.roots.userPrompts, opts.roots.projectPrompts]);
|
|
221
|
+
if (await exists(file)) {
|
|
222
|
+
throw new HttpError(409, `prompt already exists at ${file}`);
|
|
223
|
+
}
|
|
224
|
+
const text = buildFile(
|
|
225
|
+
[
|
|
226
|
+
["description", opts.description],
|
|
227
|
+
["argument-hint", opts.argumentHint]
|
|
228
|
+
],
|
|
229
|
+
opts.body
|
|
230
|
+
);
|
|
231
|
+
await mkdir(dirname(file), { recursive: true });
|
|
232
|
+
await writeFile(file, text, "utf8");
|
|
233
|
+
return file;
|
|
234
|
+
}
|
|
235
|
+
async function updatePrompt(opts) {
|
|
236
|
+
if (!isAbsolute(opts.filePath)) {
|
|
237
|
+
throw new HttpError(400, "filePath must be absolute");
|
|
238
|
+
}
|
|
239
|
+
assertUnder(opts.filePath, [opts.roots.userPrompts, opts.roots.projectPrompts]);
|
|
240
|
+
if (!await exists(opts.filePath)) {
|
|
241
|
+
throw new HttpError(404, `prompt file not found: ${opts.filePath}`);
|
|
242
|
+
}
|
|
243
|
+
ensurePromptName(opts.name);
|
|
244
|
+
const dir = dirname(opts.filePath);
|
|
245
|
+
const newPath = join2(dir, `${opts.name}.md`);
|
|
246
|
+
assertUnder(newPath, [opts.roots.userPrompts, opts.roots.projectPrompts]);
|
|
247
|
+
const existing = await readFile(opts.filePath, "utf8");
|
|
248
|
+
const extras = extraLines(parseFile(existing).lines, PROMPT_KNOWN_KEYS);
|
|
249
|
+
const text = buildFile(
|
|
250
|
+
[
|
|
251
|
+
["description", opts.description],
|
|
252
|
+
["argument-hint", opts.argumentHint]
|
|
253
|
+
],
|
|
254
|
+
opts.body,
|
|
255
|
+
extras
|
|
256
|
+
);
|
|
257
|
+
if (resolve(newPath) !== resolve(opts.filePath)) {
|
|
258
|
+
if (await exists(newPath)) {
|
|
259
|
+
throw new HttpError(409, `prompt already exists at ${newPath}`);
|
|
260
|
+
}
|
|
261
|
+
await writeFile(newPath, text, "utf8");
|
|
262
|
+
try {
|
|
263
|
+
await unlink(opts.filePath);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
await unlink(newPath).catch(() => void 0);
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
return newPath;
|
|
269
|
+
}
|
|
270
|
+
await writeFile(opts.filePath, text, "utf8");
|
|
271
|
+
return opts.filePath;
|
|
272
|
+
}
|
|
273
|
+
async function deletePrompt(filePath, roots) {
|
|
274
|
+
if (!isAbsolute(filePath)) {
|
|
275
|
+
throw new HttpError(400, "filePath must be absolute");
|
|
276
|
+
}
|
|
277
|
+
assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
|
|
278
|
+
if (!await exists(filePath)) {
|
|
279
|
+
throw new HttpError(404, `prompt file not found: ${filePath}`);
|
|
280
|
+
}
|
|
281
|
+
await unlink(filePath);
|
|
282
|
+
}
|
|
283
|
+
async function readSkillFile(filePath, roots) {
|
|
284
|
+
assertUnder(filePath, [roots.userSkills, roots.projectSkills]);
|
|
285
|
+
const text = await readFile(filePath, "utf8");
|
|
286
|
+
const { frontmatter, body } = parseFile(text);
|
|
287
|
+
return {
|
|
288
|
+
body,
|
|
289
|
+
name: stringOr(frontmatter.name, ""),
|
|
290
|
+
description: stringOr(frontmatter.description, ""),
|
|
291
|
+
disableModelInvocation: booleanOr(frontmatter["disable-model-invocation"], void 0)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async function readPromptFile(filePath, roots) {
|
|
295
|
+
assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
|
|
296
|
+
const text = await readFile(filePath, "utf8");
|
|
297
|
+
const { frontmatter, body } = parseFile(text);
|
|
298
|
+
const stem = basename(filePath).replace(/\.md$/, "");
|
|
299
|
+
return {
|
|
300
|
+
body,
|
|
301
|
+
name: stem,
|
|
302
|
+
description: stringOr(frontmatter.description, ""),
|
|
303
|
+
argumentHint: stringOr(frontmatter["argument-hint"], void 0)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function basename(p) {
|
|
307
|
+
const parts = p.split(sep);
|
|
308
|
+
return parts.at(-1) || p;
|
|
309
|
+
}
|
|
310
|
+
function stringOr(value, fallback) {
|
|
311
|
+
return typeof value === "string" ? value : fallback;
|
|
312
|
+
}
|
|
313
|
+
function booleanOr(value, fallback) {
|
|
314
|
+
return typeof value === "boolean" ? value : fallback;
|
|
315
|
+
}
|
|
316
|
+
async function exists(p) {
|
|
317
|
+
try {
|
|
318
|
+
await stat(p);
|
|
319
|
+
return true;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
var HttpError = class extends Error {
|
|
325
|
+
constructor(status, message) {
|
|
326
|
+
super(message);
|
|
327
|
+
this.status = status;
|
|
328
|
+
}
|
|
329
|
+
status;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// src/storage/workspace-registry.ts
|
|
333
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
334
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
335
|
+
import { randomUUID } from "crypto";
|
|
336
|
+
var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
|
|
337
|
+
var cache;
|
|
338
|
+
async function load() {
|
|
339
|
+
if (cache) return cache;
|
|
340
|
+
try {
|
|
341
|
+
const raw = await readFile2(REGISTRY_PATH, "utf8");
|
|
342
|
+
cache = JSON.parse(raw);
|
|
343
|
+
if (!Array.isArray(cache.workspaces)) cache = { workspaces: [] };
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err.code === "ENOENT") {
|
|
346
|
+
cache = { workspaces: [] };
|
|
347
|
+
} else {
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return cache;
|
|
352
|
+
}
|
|
353
|
+
async function save() {
|
|
354
|
+
if (!cache) return;
|
|
355
|
+
await mkdir2(dirname2(REGISTRY_PATH), { recursive: true });
|
|
356
|
+
await writeFile2(REGISTRY_PATH, JSON.stringify(cache, null, 2), "utf8");
|
|
357
|
+
}
|
|
358
|
+
async function listWorkspaces() {
|
|
359
|
+
const r = await load();
|
|
360
|
+
return [...r.workspaces];
|
|
361
|
+
}
|
|
362
|
+
async function getWorkspace(id) {
|
|
363
|
+
const r = await load();
|
|
364
|
+
return r.workspaces.find((w) => w.id === id);
|
|
365
|
+
}
|
|
366
|
+
async function addWorkspace(input) {
|
|
367
|
+
const r = await load();
|
|
368
|
+
const existing = r.workspaces.find((w) => w.path === input.path);
|
|
369
|
+
if (existing) return existing;
|
|
370
|
+
const ws = {
|
|
371
|
+
id: randomUUID(),
|
|
372
|
+
name: input.name,
|
|
373
|
+
path: input.path,
|
|
374
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
375
|
+
};
|
|
376
|
+
r.workspaces.push(ws);
|
|
377
|
+
await save();
|
|
378
|
+
return ws;
|
|
379
|
+
}
|
|
380
|
+
async function removeWorkspace(id) {
|
|
381
|
+
const r = await load();
|
|
382
|
+
const before = r.workspaces.length;
|
|
383
|
+
r.workspaces = r.workspaces.filter((w) => w.id !== id);
|
|
384
|
+
if (r.workspaces.length === before) return false;
|
|
385
|
+
await save();
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/storage/workspace-stats.ts
|
|
390
|
+
import { execFile } from "child_process";
|
|
391
|
+
import { promisify } from "util";
|
|
392
|
+
var exec = promisify(execFile);
|
|
393
|
+
var CACHE_TTL_MS = 3e4;
|
|
394
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
395
|
+
var inflight = /* @__PURE__ */ new Map();
|
|
396
|
+
async function enrichWorkspace(ws) {
|
|
397
|
+
const stats = await getStats(ws.path);
|
|
398
|
+
return {
|
|
399
|
+
id: ws.id,
|
|
400
|
+
name: ws.name,
|
|
401
|
+
path: ws.path,
|
|
402
|
+
addedAt: ws.addedAt,
|
|
403
|
+
gitBranch: stats.gitBranch,
|
|
404
|
+
fileCount: stats.fileCount
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function getStats(path) {
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
const cached = cache2.get(path);
|
|
410
|
+
if (cached && cached.expiresAt > now) return cached;
|
|
411
|
+
const pending = inflight.get(path);
|
|
412
|
+
if (pending) return pending;
|
|
413
|
+
const probe = probeStats(path).then((stats) => {
|
|
414
|
+
const entry = {
|
|
415
|
+
...stats,
|
|
416
|
+
expiresAt: Date.now() + CACHE_TTL_MS
|
|
417
|
+
};
|
|
418
|
+
cache2.set(path, entry);
|
|
419
|
+
return entry;
|
|
420
|
+
}).finally(() => {
|
|
421
|
+
inflight.delete(path);
|
|
422
|
+
});
|
|
423
|
+
inflight.set(path, probe);
|
|
424
|
+
return probe;
|
|
425
|
+
}
|
|
426
|
+
async function probeStats(path) {
|
|
427
|
+
const [branchResult, filesResult] = await Promise.allSettled([
|
|
428
|
+
runGit(path, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
429
|
+
runGit(path, [
|
|
430
|
+
"ls-files",
|
|
431
|
+
"--cached",
|
|
432
|
+
"--others",
|
|
433
|
+
"--exclude-standard"
|
|
434
|
+
])
|
|
435
|
+
]);
|
|
436
|
+
let gitBranch = null;
|
|
437
|
+
if (branchResult.status === "fulfilled") {
|
|
438
|
+
const out = branchResult.value.trim();
|
|
439
|
+
if (out && out !== "HEAD") {
|
|
440
|
+
gitBranch = out;
|
|
441
|
+
} else if (out === "HEAD") {
|
|
442
|
+
try {
|
|
443
|
+
const sha = (await runGit(path, ["rev-parse", "--short", "HEAD"])).trim();
|
|
444
|
+
gitBranch = sha ? `@${sha}` : null;
|
|
445
|
+
} catch {
|
|
446
|
+
gitBranch = null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
let fileCount = null;
|
|
451
|
+
if (filesResult.status === "fulfilled") {
|
|
452
|
+
const out = filesResult.value;
|
|
453
|
+
if (out) {
|
|
454
|
+
let n = 0;
|
|
455
|
+
for (let i = 0; i < out.length; i++) {
|
|
456
|
+
if (out.charCodeAt(i) === 10) n++;
|
|
457
|
+
}
|
|
458
|
+
if (out.length > 0 && out.charCodeAt(out.length - 1) !== 10) n++;
|
|
459
|
+
fileCount = n;
|
|
460
|
+
} else {
|
|
461
|
+
fileCount = 0;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { gitBranch, fileCount };
|
|
465
|
+
}
|
|
466
|
+
async function runGit(cwd, args) {
|
|
467
|
+
const { stdout } = await exec("git", args, {
|
|
468
|
+
cwd,
|
|
469
|
+
timeout: 2e3,
|
|
470
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
471
|
+
// Don't inherit GIT_DIR / GIT_WORK_TREE from the server process —
|
|
472
|
+
// could leak the server's own repo into a workspace probe.
|
|
473
|
+
env: { ...process.env, GIT_DIR: void 0, GIT_WORK_TREE: void 0 }
|
|
474
|
+
});
|
|
475
|
+
return stdout;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/workspace-manager.ts
|
|
479
|
+
import { unlink as unlink2 } from "fs/promises";
|
|
480
|
+
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
481
|
+
import {
|
|
482
|
+
createAgentSessionFromServices,
|
|
483
|
+
createAgentSessionRuntime,
|
|
484
|
+
createAgentSessionServices,
|
|
485
|
+
getAgentDir,
|
|
486
|
+
SessionManager
|
|
487
|
+
} from "@earendil-works/pi-coding-agent";
|
|
488
|
+
|
|
489
|
+
// src/ws/extension-ui.ts
|
|
490
|
+
var ExtensionUIBridge = class {
|
|
491
|
+
/** Symmetric with the old bridge so workspace-manager's dispose path
|
|
492
|
+
* can call it uniformly. There is no state to release. */
|
|
493
|
+
dispose() {
|
|
494
|
+
}
|
|
495
|
+
// ============== dialog methods (resolve to default) ==============
|
|
496
|
+
select() {
|
|
497
|
+
return Promise.resolve(void 0);
|
|
498
|
+
}
|
|
499
|
+
confirm() {
|
|
500
|
+
return Promise.resolve(false);
|
|
501
|
+
}
|
|
502
|
+
input() {
|
|
503
|
+
return Promise.resolve(void 0);
|
|
504
|
+
}
|
|
505
|
+
editor() {
|
|
506
|
+
return Promise.resolve(void 0);
|
|
507
|
+
}
|
|
508
|
+
// ============== fire-and-forget methods (no-op) ==============
|
|
509
|
+
notify() {
|
|
510
|
+
}
|
|
511
|
+
setStatus() {
|
|
512
|
+
}
|
|
513
|
+
setWidget() {
|
|
514
|
+
}
|
|
515
|
+
setTitle() {
|
|
516
|
+
}
|
|
517
|
+
setEditorText() {
|
|
518
|
+
}
|
|
519
|
+
pasteToEditor() {
|
|
520
|
+
}
|
|
521
|
+
// ============== TUI-only methods (no-op / defaults) ==============
|
|
522
|
+
onTerminalInput() {
|
|
523
|
+
return () => {
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
setWorkingMessage() {
|
|
527
|
+
}
|
|
528
|
+
setWorkingVisible() {
|
|
529
|
+
}
|
|
530
|
+
setWorkingIndicator() {
|
|
531
|
+
}
|
|
532
|
+
setHiddenThinkingLabel() {
|
|
533
|
+
}
|
|
534
|
+
setFooter() {
|
|
535
|
+
}
|
|
536
|
+
setHeader() {
|
|
537
|
+
}
|
|
538
|
+
async custom() {
|
|
539
|
+
return void 0;
|
|
540
|
+
}
|
|
541
|
+
getEditorText() {
|
|
542
|
+
return "";
|
|
543
|
+
}
|
|
544
|
+
addAutocompleteProvider() {
|
|
545
|
+
}
|
|
546
|
+
setEditorComponent() {
|
|
547
|
+
}
|
|
548
|
+
getEditorComponent() {
|
|
549
|
+
return void 0;
|
|
550
|
+
}
|
|
551
|
+
get theme() {
|
|
552
|
+
return void 0;
|
|
553
|
+
}
|
|
554
|
+
getAllThemes() {
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
getTheme() {
|
|
558
|
+
return void 0;
|
|
559
|
+
}
|
|
560
|
+
setTheme() {
|
|
561
|
+
return { success: false, error: "Theme switching not supported in pi-pilot" };
|
|
562
|
+
}
|
|
563
|
+
getToolsExpanded() {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
setToolsExpanded() {
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// src/workspace-manager.ts
|
|
571
|
+
var EXTENSIONS_ENABLED = process.env.PI_PILOT_ENABLE_EXTENSIONS === "1";
|
|
572
|
+
var createRuntime = async ({
|
|
573
|
+
cwd,
|
|
574
|
+
sessionManager,
|
|
575
|
+
sessionStartEvent
|
|
576
|
+
}) => {
|
|
577
|
+
const services = await createAgentSessionServices({
|
|
578
|
+
cwd,
|
|
579
|
+
resourceLoaderOptions: EXTENSIONS_ENABLED ? void 0 : { noExtensions: true }
|
|
580
|
+
});
|
|
581
|
+
const sessionResult = await createAgentSessionFromServices({
|
|
582
|
+
services,
|
|
583
|
+
sessionManager,
|
|
584
|
+
sessionStartEvent
|
|
585
|
+
});
|
|
586
|
+
return {
|
|
587
|
+
...sessionResult,
|
|
588
|
+
services,
|
|
589
|
+
diagnostics: services.diagnostics
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
var WorkspaceManager = class {
|
|
593
|
+
states = /* @__PURE__ */ new Map();
|
|
594
|
+
/**
|
|
595
|
+
* Subscribers live independently of `states` so the hub can register a
|
|
596
|
+
* WebSocket *before* `getOrCreate` triggers a runtime build (which may
|
|
597
|
+
* fire `session_start` synchronously, and any UI request from a
|
|
598
|
+
* session_start handler would otherwise broadcast to an empty set).
|
|
599
|
+
*/
|
|
600
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
601
|
+
/** Per-workspace lock to serialize concurrent creations. */
|
|
602
|
+
pending = /* @__PURE__ */ new Map();
|
|
603
|
+
rebindListeners = /* @__PURE__ */ new Map();
|
|
604
|
+
getOrCreateSubscriberSet(workspaceId) {
|
|
605
|
+
let set = this.subscribers.get(workspaceId);
|
|
606
|
+
if (!set) {
|
|
607
|
+
set = /* @__PURE__ */ new Set();
|
|
608
|
+
this.subscribers.set(workspaceId, set);
|
|
609
|
+
}
|
|
610
|
+
return set;
|
|
611
|
+
}
|
|
612
|
+
async getOrCreate(workspaceId) {
|
|
613
|
+
const existing = this.states.get(workspaceId);
|
|
614
|
+
if (existing) return existing.runtime;
|
|
615
|
+
const inflight3 = this.pending.get(workspaceId);
|
|
616
|
+
if (inflight3) return (await inflight3).runtime;
|
|
617
|
+
const p = this.build(workspaceId);
|
|
618
|
+
this.pending.set(workspaceId, p);
|
|
619
|
+
try {
|
|
620
|
+
const state = await p;
|
|
621
|
+
this.states.set(workspaceId, state);
|
|
622
|
+
return state.runtime;
|
|
623
|
+
} finally {
|
|
624
|
+
this.pending.delete(workspaceId);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async build(workspaceId) {
|
|
628
|
+
const ws = await getWorkspace(workspaceId);
|
|
629
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
630
|
+
const sessionManager = SessionManager.continueRecent(ws.path);
|
|
631
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
632
|
+
cwd: ws.path,
|
|
633
|
+
agentDir: getAgentDir(),
|
|
634
|
+
sessionManager
|
|
635
|
+
});
|
|
636
|
+
const subscribers = this.getOrCreateSubscriberSet(workspaceId);
|
|
637
|
+
const bridge = new ExtensionUIBridge();
|
|
638
|
+
const onError = (err) => {
|
|
639
|
+
const msg = {
|
|
640
|
+
type: "extension_error",
|
|
641
|
+
workspaceId,
|
|
642
|
+
extensionPath: err.extensionPath,
|
|
643
|
+
event: err.event,
|
|
644
|
+
message: err.error
|
|
645
|
+
};
|
|
646
|
+
broadcastTo(subscribers, msg);
|
|
647
|
+
console.error(
|
|
648
|
+
`[ext-error] ${workspaceId} ${err.extensionPath}@${err.event}: ${err.error}` + (err.stack ? `
|
|
649
|
+
${err.stack}` : "")
|
|
650
|
+
);
|
|
651
|
+
};
|
|
652
|
+
await runtime.session.bindExtensions({ uiContext: bridge, onError });
|
|
653
|
+
runtime.setRebindSession(async () => {
|
|
654
|
+
await runtime.session.bindExtensions({ uiContext: bridge, onError });
|
|
655
|
+
this.notifySessionReplaced(workspaceId);
|
|
656
|
+
});
|
|
657
|
+
return { runtime, bridge };
|
|
658
|
+
}
|
|
659
|
+
get(workspaceId) {
|
|
660
|
+
return this.states.get(workspaceId)?.runtime;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Register a WS connection as a subscriber for `workspaceId`. Safe to
|
|
664
|
+
* call before `getOrCreate`; the set is lazily created so the bridge,
|
|
665
|
+
* when later built, sees the same Set instance and any pre-existing
|
|
666
|
+
* subscribers.
|
|
667
|
+
*/
|
|
668
|
+
addSubscriber(workspaceId, ws) {
|
|
669
|
+
this.getOrCreateSubscriberSet(workspaceId).add(ws);
|
|
670
|
+
}
|
|
671
|
+
removeSubscriber(workspaceId, ws) {
|
|
672
|
+
const set = this.subscribers.get(workspaceId);
|
|
673
|
+
if (!set) return;
|
|
674
|
+
set.delete(ws);
|
|
675
|
+
if (set.size === 0) this.subscribers.delete(workspaceId);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Fan a server-initiated message out to every WS subscribed to the
|
|
679
|
+
* workspace. Used by API handlers that mutate runtime state and need
|
|
680
|
+
* to refresh derived snapshots (e.g. `context_usage` after `setModel`,
|
|
681
|
+
* which pi's event stream doesn't surface unless thinking-level also
|
|
682
|
+
* clamps).
|
|
683
|
+
*/
|
|
684
|
+
broadcast(workspaceId, msg) {
|
|
685
|
+
const set = this.subscribers.get(workspaceId);
|
|
686
|
+
if (!set || set.size === 0) return;
|
|
687
|
+
broadcastTo(set, msg);
|
|
688
|
+
}
|
|
689
|
+
onSessionReplaced(workspaceId, listener) {
|
|
690
|
+
let listeners = this.rebindListeners.get(workspaceId);
|
|
691
|
+
if (!listeners) {
|
|
692
|
+
listeners = /* @__PURE__ */ new Set();
|
|
693
|
+
this.rebindListeners.set(workspaceId, listeners);
|
|
694
|
+
}
|
|
695
|
+
listeners.add(listener);
|
|
696
|
+
return () => {
|
|
697
|
+
const current = this.rebindListeners.get(workspaceId);
|
|
698
|
+
if (!current) return;
|
|
699
|
+
current.delete(listener);
|
|
700
|
+
if (current.size === 0) {
|
|
701
|
+
this.rebindListeners.delete(workspaceId);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
notifySessionReplaced(workspaceId) {
|
|
706
|
+
const listeners = this.rebindListeners.get(workspaceId);
|
|
707
|
+
if (!listeners) return;
|
|
708
|
+
for (const listener of [...listeners]) {
|
|
709
|
+
try {
|
|
710
|
+
listener();
|
|
711
|
+
} catch (e) {
|
|
712
|
+
console.error(`[wm] rebind listener for ${workspaceId} failed:`, e);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async listSessions(workspaceId) {
|
|
717
|
+
const ws = await getWorkspace(workspaceId);
|
|
718
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
719
|
+
const sessions = await SessionManager.list(ws.path);
|
|
720
|
+
return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map(toSessionSummary);
|
|
721
|
+
}
|
|
722
|
+
getSessionHistory(workspaceId, sessionPath) {
|
|
723
|
+
const runtime = this.states.get(workspaceId)?.runtime;
|
|
724
|
+
if (!runtime) return { items: [], isStreaming: false };
|
|
725
|
+
if (sessionPath) {
|
|
726
|
+
const activeFile = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
|
|
727
|
+
if (activeFile !== resolve2(sessionPath)) {
|
|
728
|
+
return { items: [], isStreaming: false };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const isStreaming = runtime.session.isStreaming ?? false;
|
|
732
|
+
const branch = runtime.session.sessionManager.getBranch();
|
|
733
|
+
const items = [];
|
|
734
|
+
const argsByCallId = /* @__PURE__ */ new Map();
|
|
735
|
+
for (const entry of branch) {
|
|
736
|
+
if (entry.type !== "message") continue;
|
|
737
|
+
const msg = entry.message;
|
|
738
|
+
const role = msg.role;
|
|
739
|
+
if (role === "user") {
|
|
740
|
+
const text = extractUserText(msg);
|
|
741
|
+
if (text) items.push({ kind: "user", text });
|
|
742
|
+
} else if (role === "assistant") {
|
|
743
|
+
const { text, thinking, toolCalls } = extractAssistantContent(
|
|
744
|
+
msg
|
|
745
|
+
);
|
|
746
|
+
for (const tc of toolCalls) {
|
|
747
|
+
argsByCallId.set(tc.id, tc.args);
|
|
748
|
+
}
|
|
749
|
+
if (text || thinking) items.push({ kind: "assistant", text, thinking });
|
|
750
|
+
} else if (role === "toolResult") {
|
|
751
|
+
const tr = msg;
|
|
752
|
+
items.push({
|
|
753
|
+
kind: "tool",
|
|
754
|
+
toolCallId: tr.toolCallId,
|
|
755
|
+
toolName: tr.toolName,
|
|
756
|
+
args: argsByCallId.get(tr.toolCallId) ?? "",
|
|
757
|
+
text: extractContentText(tr.content),
|
|
758
|
+
isError: tr.isError
|
|
759
|
+
});
|
|
760
|
+
} else if (role === "bashExecution") {
|
|
761
|
+
const be = msg;
|
|
762
|
+
items.push({
|
|
763
|
+
kind: "bash",
|
|
764
|
+
command: be.command,
|
|
765
|
+
output: be.output,
|
|
766
|
+
exitCode: be.exitCode
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { items, isStreaming };
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Delete a session JSONL file belonging to this workspace.
|
|
774
|
+
*
|
|
775
|
+
* Errors are tagged with HTTP semantics via HttpError so the route layer
|
|
776
|
+
* can map them to the right status code:
|
|
777
|
+
* - 400: sessionPath not absolute
|
|
778
|
+
* - 404: workspace gone, or session not in this workspace's list
|
|
779
|
+
* - 409: file is the currently-active session (caller must switch first)
|
|
780
|
+
*
|
|
781
|
+
* Idempotent on ENOENT: if the file is missing at unlink time (e.g. a
|
|
782
|
+
* concurrent external delete between list and unlink), we treat it as
|
|
783
|
+
* success — the goal state has been reached.
|
|
784
|
+
*/
|
|
785
|
+
async deleteSession(workspaceId, sessionPath) {
|
|
786
|
+
const ws = await getWorkspace(workspaceId);
|
|
787
|
+
if (!ws) throw new HttpError(404, `Workspace not found: ${workspaceId}`);
|
|
788
|
+
if (!isAbsolute2(sessionPath)) {
|
|
789
|
+
throw new HttpError(400, "Session path must be absolute");
|
|
790
|
+
}
|
|
791
|
+
const sessions = await SessionManager.list(ws.path);
|
|
792
|
+
const resolved = resolve2(sessionPath);
|
|
793
|
+
const target = sessions.find((session) => resolve2(session.path) === resolved);
|
|
794
|
+
if (!target) {
|
|
795
|
+
throw new HttpError(404, `Session not found: ${sessionPath}`);
|
|
796
|
+
}
|
|
797
|
+
const runtime = this.states.get(workspaceId)?.runtime;
|
|
798
|
+
const activePath = runtime?.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
|
|
799
|
+
if (activePath === resolved) {
|
|
800
|
+
throw new HttpError(
|
|
801
|
+
409,
|
|
802
|
+
"Cannot delete the currently active session \u2014 switch to another session first"
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
await unlink2(resolved);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
if (err?.code === "ENOENT") {
|
|
809
|
+
console.warn(
|
|
810
|
+
`[wm] deleteSession: ${resolved} was already gone at unlink time`
|
|
811
|
+
);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
throw err;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async switchSession(workspaceId, sessionPath) {
|
|
818
|
+
const ws = await getWorkspace(workspaceId);
|
|
819
|
+
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
820
|
+
if (!isAbsolute2(sessionPath)) {
|
|
821
|
+
throw new Error("Session path must be absolute");
|
|
822
|
+
}
|
|
823
|
+
const sessions = await SessionManager.list(ws.path);
|
|
824
|
+
const resolved = resolve2(sessionPath);
|
|
825
|
+
const target = sessions.find((session) => resolve2(session.path) === resolved);
|
|
826
|
+
if (!target) {
|
|
827
|
+
throw new Error(`Session not found: ${sessionPath}`);
|
|
828
|
+
}
|
|
829
|
+
const runtime = await this.getOrCreate(workspaceId);
|
|
830
|
+
const currentPath = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
|
|
831
|
+
if (currentPath === resolved) return false;
|
|
832
|
+
if (runtime.session.isStreaming) {
|
|
833
|
+
throw new Error("Cannot switch sessions while the agent is streaming");
|
|
834
|
+
}
|
|
835
|
+
const result = await runtime.switchSession(resolved, { cwdOverride: ws.path });
|
|
836
|
+
return !result.cancelled;
|
|
837
|
+
}
|
|
838
|
+
async dispose(workspaceId) {
|
|
839
|
+
const state = this.states.get(workspaceId);
|
|
840
|
+
if (!state) return;
|
|
841
|
+
this.states.delete(workspaceId);
|
|
842
|
+
this.rebindListeners.delete(workspaceId);
|
|
843
|
+
this.subscribers.delete(workspaceId);
|
|
844
|
+
try {
|
|
845
|
+
state.bridge.dispose();
|
|
846
|
+
} catch (e) {
|
|
847
|
+
console.error(`[wm] dispose bridge ${workspaceId} failed:`, e);
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
state.runtime.session.dispose();
|
|
851
|
+
} catch (e) {
|
|
852
|
+
console.error(`[wm] dispose ${workspaceId} failed:`, e);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async disposeAll() {
|
|
856
|
+
const ids = [...this.states.keys()];
|
|
857
|
+
await Promise.all(ids.map((id) => this.dispose(id)));
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
function toSessionSummary(info) {
|
|
861
|
+
const preview = info.firstMessage.replace(/\s+/g, " ").trim();
|
|
862
|
+
return {
|
|
863
|
+
path: info.path,
|
|
864
|
+
name: info.name,
|
|
865
|
+
updatedAt: info.modified.toISOString(),
|
|
866
|
+
preview: preview ? preview.slice(0, 160) : void 0
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function extractUserText(msg) {
|
|
870
|
+
if (typeof msg.content === "string") return msg.content;
|
|
871
|
+
return extractContentText(msg.content);
|
|
872
|
+
}
|
|
873
|
+
function extractAssistantContent(msg) {
|
|
874
|
+
const textParts = [];
|
|
875
|
+
const thinkingParts = [];
|
|
876
|
+
const toolCalls = [];
|
|
877
|
+
for (const block of msg.content ?? []) {
|
|
878
|
+
if (!block || typeof block !== "object") continue;
|
|
879
|
+
const b = block;
|
|
880
|
+
if (b.type === "text" && typeof b.text === "string") textParts.push(b.text);
|
|
881
|
+
else if (b.type === "thinking" && typeof b.thinking === "string") thinkingParts.push(b.thinking);
|
|
882
|
+
else if (b.type === "toolCall" && typeof b.id === "string") {
|
|
883
|
+
toolCalls.push({
|
|
884
|
+
id: b.id,
|
|
885
|
+
args: b.arguments != null ? JSON.stringify(b.arguments) : ""
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { text: textParts.join(""), thinking: thinkingParts.join(""), toolCalls };
|
|
890
|
+
}
|
|
891
|
+
function extractContentText(content) {
|
|
892
|
+
if (!Array.isArray(content)) return "";
|
|
893
|
+
const parts = [];
|
|
894
|
+
for (const block of content) {
|
|
895
|
+
if (block && typeof block === "object" && block.type === "text") {
|
|
896
|
+
const text = block.text;
|
|
897
|
+
if (typeof text === "string") parts.push(text);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return parts.join("");
|
|
901
|
+
}
|
|
902
|
+
var workspaceManager = new WorkspaceManager();
|
|
903
|
+
function broadcastTo(subscribers, msg) {
|
|
904
|
+
const wire = JSON.stringify(msg);
|
|
905
|
+
for (const ws of subscribers) {
|
|
906
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
907
|
+
try {
|
|
908
|
+
ws.send(wire);
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/api/config.ts
|
|
915
|
+
function buildConfigResponse(workspaceId) {
|
|
916
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
917
|
+
if (!runtime) throw new Error("runtime not initialized");
|
|
918
|
+
const session = runtime.session;
|
|
919
|
+
const model = session.model;
|
|
920
|
+
const currentModel = model ? {
|
|
921
|
+
provider: model.provider,
|
|
922
|
+
modelId: model.id,
|
|
923
|
+
name: model.name,
|
|
924
|
+
reasoning: model.reasoning
|
|
925
|
+
} : null;
|
|
926
|
+
const availableModels = session.modelRegistry.getAvailable().map((m) => ({
|
|
927
|
+
provider: m.provider,
|
|
928
|
+
modelId: m.id,
|
|
929
|
+
name: m.name,
|
|
930
|
+
reasoning: m.reasoning
|
|
931
|
+
}));
|
|
932
|
+
const allTools = session.getAllTools().map((t) => ({
|
|
933
|
+
name: t.name,
|
|
934
|
+
description: t.description
|
|
935
|
+
}));
|
|
936
|
+
return {
|
|
937
|
+
currentModel,
|
|
938
|
+
thinkingLevel: session.thinkingLevel,
|
|
939
|
+
availableThinkingLevels: session.getAvailableThinkingLevels(),
|
|
940
|
+
activeTools: session.getActiveToolNames(),
|
|
941
|
+
availableModels,
|
|
942
|
+
allTools
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
async function requireWorkspace(c, id) {
|
|
946
|
+
const ws = await getWorkspace(id);
|
|
947
|
+
if (!ws) {
|
|
948
|
+
c.status(404);
|
|
949
|
+
c.header("Content-Type", "application/json");
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
function rejectIfStreaming(c, workspaceId) {
|
|
955
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
956
|
+
if (runtime?.session.isStreaming) {
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
return false;
|
|
960
|
+
}
|
|
961
|
+
function broadcastContextUsage(workspaceId, runtime) {
|
|
962
|
+
const usage = runtime.session.getContextUsage();
|
|
963
|
+
if (!usage) return;
|
|
964
|
+
const payload = {
|
|
965
|
+
kind: "context_usage",
|
|
966
|
+
tokens: usage.tokens,
|
|
967
|
+
contextWindow: usage.contextWindow,
|
|
968
|
+
percent: usage.percent
|
|
969
|
+
};
|
|
970
|
+
workspaceManager.broadcast(workspaceId, {
|
|
971
|
+
type: "event",
|
|
972
|
+
workspaceId,
|
|
973
|
+
sessionPath: runtime.session.sessionFile ?? null,
|
|
974
|
+
payload
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
function mountConfigRoutes(app2) {
|
|
978
|
+
app2.get("/:id/config", async (c) => {
|
|
979
|
+
const id = c.req.param("id");
|
|
980
|
+
const exists2 = await requireWorkspace(c, id);
|
|
981
|
+
if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
|
|
982
|
+
try {
|
|
983
|
+
await workspaceManager.getOrCreate(id);
|
|
984
|
+
return c.json(buildConfigResponse(id));
|
|
985
|
+
} catch (err) {
|
|
986
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
987
|
+
return c.json({ ok: false, error: message }, 500);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
app2.put("/:id/config/model", async (c) => {
|
|
991
|
+
const id = c.req.param("id");
|
|
992
|
+
const exists2 = await requireWorkspace(c, id);
|
|
993
|
+
if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
|
|
994
|
+
const body = await c.req.json();
|
|
995
|
+
if (!body?.provider || !body?.modelId) {
|
|
996
|
+
return c.json({ ok: false, error: "provider and modelId are required" }, 400);
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
const runtime = await workspaceManager.getOrCreate(id);
|
|
1000
|
+
if (rejectIfStreaming(c, id)) {
|
|
1001
|
+
return c.json({ ok: false, error: "cannot change model while the agent is streaming" }, 409);
|
|
1002
|
+
}
|
|
1003
|
+
const model = runtime.session.modelRegistry.getAvailable().find(
|
|
1004
|
+
(m) => m.provider === body.provider && m.id === body.modelId
|
|
1005
|
+
);
|
|
1006
|
+
if (!model) {
|
|
1007
|
+
return c.json({ ok: false, error: `model not found or no auth: ${body.provider}/${body.modelId}` }, 404);
|
|
1008
|
+
}
|
|
1009
|
+
await runtime.session.setModel(model);
|
|
1010
|
+
broadcastContextUsage(id, runtime);
|
|
1011
|
+
return c.json(buildConfigResponse(id));
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1014
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
app2.put("/:id/config/thinking-level", async (c) => {
|
|
1018
|
+
const id = c.req.param("id");
|
|
1019
|
+
const exists2 = await requireWorkspace(c, id);
|
|
1020
|
+
if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
|
|
1021
|
+
const body = await c.req.json();
|
|
1022
|
+
const validLevels = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
1023
|
+
if (!body?.level || !validLevels.includes(body.level)) {
|
|
1024
|
+
return c.json({ ok: false, error: `level must be one of: ${validLevels.join(", ")}` }, 400);
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
await workspaceManager.getOrCreate(id);
|
|
1028
|
+
if (rejectIfStreaming(c, id)) {
|
|
1029
|
+
return c.json({ ok: false, error: "cannot change thinking level while the agent is streaming" }, 409);
|
|
1030
|
+
}
|
|
1031
|
+
const runtime = workspaceManager.get(id);
|
|
1032
|
+
if (!runtime) {
|
|
1033
|
+
return c.json({ ok: false, error: "runtime not initialized" }, 500);
|
|
1034
|
+
}
|
|
1035
|
+
runtime.session.setThinkingLevel(body.level);
|
|
1036
|
+
return c.json(buildConfigResponse(id));
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1039
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
app2.put("/:id/config/tools", async (c) => {
|
|
1043
|
+
const id = c.req.param("id");
|
|
1044
|
+
const exists2 = await requireWorkspace(c, id);
|
|
1045
|
+
if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
|
|
1046
|
+
const body = await c.req.json();
|
|
1047
|
+
if (!body?.tools || !Array.isArray(body.tools)) {
|
|
1048
|
+
return c.json({ ok: false, error: "tools must be an array of tool names" }, 400);
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
await workspaceManager.getOrCreate(id);
|
|
1052
|
+
if (rejectIfStreaming(c, id)) {
|
|
1053
|
+
return c.json({ ok: false, error: "cannot change tools while the agent is streaming" }, 409);
|
|
1054
|
+
}
|
|
1055
|
+
const runtime = workspaceManager.get(id);
|
|
1056
|
+
if (!runtime) {
|
|
1057
|
+
return c.json({ ok: false, error: "runtime not initialized" }, 500);
|
|
1058
|
+
}
|
|
1059
|
+
runtime.session.setActiveToolsByName(body.tools);
|
|
1060
|
+
return c.json(buildConfigResponse(id));
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1063
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/api/files.ts
|
|
1069
|
+
import { execFile as execFile2 } from "child_process";
|
|
1070
|
+
import { readdir } from "fs/promises";
|
|
1071
|
+
import { join as join4, relative, sep as sep2 } from "path";
|
|
1072
|
+
import { promisify as promisify2 } from "util";
|
|
1073
|
+
var exec2 = promisify2(execFile2);
|
|
1074
|
+
var LIST_TTL_MS = 1e4;
|
|
1075
|
+
var MAX_CACHED_WORKSPACES = 16;
|
|
1076
|
+
var MAX_FILES_TRACKED = 2e4;
|
|
1077
|
+
var DEFAULT_LIMIT = 30;
|
|
1078
|
+
var MAX_LIMIT = 100;
|
|
1079
|
+
var WALK_MAX_DEPTH = 8;
|
|
1080
|
+
var WALK_IGNORES = /* @__PURE__ */ new Set([
|
|
1081
|
+
".git",
|
|
1082
|
+
"node_modules",
|
|
1083
|
+
"dist",
|
|
1084
|
+
"build",
|
|
1085
|
+
".next",
|
|
1086
|
+
".turbo",
|
|
1087
|
+
".cache",
|
|
1088
|
+
".vite",
|
|
1089
|
+
"out",
|
|
1090
|
+
"coverage",
|
|
1091
|
+
"target",
|
|
1092
|
+
"venv",
|
|
1093
|
+
".venv",
|
|
1094
|
+
"__pycache__",
|
|
1095
|
+
".DS_Store"
|
|
1096
|
+
]);
|
|
1097
|
+
var listCache = /* @__PURE__ */ new Map();
|
|
1098
|
+
var inflight2 = /* @__PURE__ */ new Map();
|
|
1099
|
+
async function getFileList(workspacePath) {
|
|
1100
|
+
const now = Date.now();
|
|
1101
|
+
const cached = listCache.get(workspacePath);
|
|
1102
|
+
if (cached && cached.expiresAt > now) return cached.files;
|
|
1103
|
+
const pending = inflight2.get(workspacePath);
|
|
1104
|
+
if (pending) return (await pending).files;
|
|
1105
|
+
const probe = probeFileList(workspacePath).then((files) => {
|
|
1106
|
+
const entry = {
|
|
1107
|
+
files,
|
|
1108
|
+
expiresAt: Date.now() + LIST_TTL_MS
|
|
1109
|
+
};
|
|
1110
|
+
listCache.delete(workspacePath);
|
|
1111
|
+
listCache.set(workspacePath, entry);
|
|
1112
|
+
while (listCache.size > MAX_CACHED_WORKSPACES) {
|
|
1113
|
+
const oldest = listCache.keys().next().value;
|
|
1114
|
+
if (!oldest) break;
|
|
1115
|
+
listCache.delete(oldest);
|
|
1116
|
+
}
|
|
1117
|
+
return entry;
|
|
1118
|
+
}).finally(() => inflight2.delete(workspacePath));
|
|
1119
|
+
inflight2.set(workspacePath, probe);
|
|
1120
|
+
return (await probe).files;
|
|
1121
|
+
}
|
|
1122
|
+
async function probeFileList(workspacePath) {
|
|
1123
|
+
try {
|
|
1124
|
+
const { stdout } = await exec2(
|
|
1125
|
+
"git",
|
|
1126
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
1127
|
+
{
|
|
1128
|
+
cwd: workspacePath,
|
|
1129
|
+
timeout: 3e3,
|
|
1130
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1131
|
+
env: { ...process.env, GIT_DIR: void 0, GIT_WORK_TREE: void 0 }
|
|
1132
|
+
}
|
|
1133
|
+
);
|
|
1134
|
+
const lines = stdout.split("\n");
|
|
1135
|
+
const out2 = [];
|
|
1136
|
+
for (const line of lines) {
|
|
1137
|
+
if (!line) continue;
|
|
1138
|
+
out2.push(line);
|
|
1139
|
+
if (out2.length >= MAX_FILES_TRACKED) break;
|
|
1140
|
+
}
|
|
1141
|
+
return out2;
|
|
1142
|
+
} catch {
|
|
1143
|
+
}
|
|
1144
|
+
const out = [];
|
|
1145
|
+
await walkDir(workspacePath, workspacePath, 0, out);
|
|
1146
|
+
return out;
|
|
1147
|
+
}
|
|
1148
|
+
async function walkDir(root, dir, depth, out) {
|
|
1149
|
+
if (out.length >= MAX_FILES_TRACKED) return;
|
|
1150
|
+
if (depth > WALK_MAX_DEPTH) return;
|
|
1151
|
+
let dirents;
|
|
1152
|
+
try {
|
|
1153
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
1154
|
+
} catch {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
for (const d of dirents) {
|
|
1158
|
+
if (out.length >= MAX_FILES_TRACKED) return;
|
|
1159
|
+
if (WALK_IGNORES.has(d.name)) continue;
|
|
1160
|
+
const abs = join4(dir, d.name);
|
|
1161
|
+
if (d.isDirectory()) {
|
|
1162
|
+
await walkDir(root, abs, depth + 1, out);
|
|
1163
|
+
} else if (d.isFile()) {
|
|
1164
|
+
out.push(relative(root, abs).split(sep2).join("/"));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
function scoreMatch(relPath, q) {
|
|
1169
|
+
const lower = relPath.toLowerCase();
|
|
1170
|
+
const slash = lower.lastIndexOf("/");
|
|
1171
|
+
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
|
|
1172
|
+
if (base.startsWith(q)) return 1e3 - relPath.length;
|
|
1173
|
+
const baseIdx = base.indexOf(q);
|
|
1174
|
+
if (baseIdx >= 0) return 800 - baseIdx - relPath.length * 0.01;
|
|
1175
|
+
const pathIdx = lower.indexOf(q);
|
|
1176
|
+
if (pathIdx >= 0) return 500 - pathIdx - relPath.length * 0.01;
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
async function ensureWorkspaceExists(id) {
|
|
1180
|
+
const ws = await getWorkspace(id);
|
|
1181
|
+
return ws ? ws.path : null;
|
|
1182
|
+
}
|
|
1183
|
+
function mountFilesRoute(app2) {
|
|
1184
|
+
app2.get("/:id/files/search", async (c) => {
|
|
1185
|
+
const id = c.req.param("id");
|
|
1186
|
+
const workspacePath = await ensureWorkspaceExists(id);
|
|
1187
|
+
if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
|
|
1188
|
+
const qRaw = (c.req.query("q") ?? "").trim();
|
|
1189
|
+
const limitRaw = c.req.query("limit");
|
|
1190
|
+
let limit = limitRaw ? Number(limitRaw) : DEFAULT_LIMIT;
|
|
1191
|
+
if (!Number.isFinite(limit) || limit <= 0) limit = DEFAULT_LIMIT;
|
|
1192
|
+
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
|
1193
|
+
try {
|
|
1194
|
+
const all = await getFileList(workspacePath);
|
|
1195
|
+
let entries;
|
|
1196
|
+
let truncated = false;
|
|
1197
|
+
if (!qRaw) {
|
|
1198
|
+
const slice = all.slice(0, limit);
|
|
1199
|
+
entries = slice.map((relPath) => ({
|
|
1200
|
+
path: join4(workspacePath, relPath),
|
|
1201
|
+
relPath
|
|
1202
|
+
}));
|
|
1203
|
+
truncated = all.length > limit;
|
|
1204
|
+
} else {
|
|
1205
|
+
const q = qRaw.toLowerCase();
|
|
1206
|
+
const scored = [];
|
|
1207
|
+
let matchCount = 0;
|
|
1208
|
+
for (const relPath of all) {
|
|
1209
|
+
const score = scoreMatch(relPath, q);
|
|
1210
|
+
if (score === null) continue;
|
|
1211
|
+
matchCount++;
|
|
1212
|
+
scored.push({ relPath, score });
|
|
1213
|
+
}
|
|
1214
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1215
|
+
const top = scored.slice(0, limit);
|
|
1216
|
+
entries = top.map((e) => ({
|
|
1217
|
+
path: join4(workspacePath, e.relPath),
|
|
1218
|
+
relPath: e.relPath
|
|
1219
|
+
}));
|
|
1220
|
+
truncated = matchCount > limit;
|
|
1221
|
+
}
|
|
1222
|
+
const body = { workspacePath, entries, truncated };
|
|
1223
|
+
return c.json(body);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1226
|
+
console.error(`[api/files] search for ${id} failed:`, err);
|
|
1227
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/api/resources.ts
|
|
1233
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
1234
|
+
import { join as join5 } from "path";
|
|
1235
|
+
import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
|
|
1236
|
+
function toResourceSource(info) {
|
|
1237
|
+
return {
|
|
1238
|
+
scope: info.scope,
|
|
1239
|
+
label: info.source,
|
|
1240
|
+
path: info.path
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
async function scanExtensionDirs(workspaceCwd) {
|
|
1244
|
+
const dirs = [join5(getAgentDir2(), "extensions"), join5(workspaceCwd, ".pi", "extensions")];
|
|
1245
|
+
const found = [];
|
|
1246
|
+
for (const dir of dirs) {
|
|
1247
|
+
try {
|
|
1248
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
1249
|
+
for (const entry of entries) {
|
|
1250
|
+
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
1251
|
+
found.push(join5(dir, entry.name));
|
|
1252
|
+
} else if (entry.isDirectory()) {
|
|
1253
|
+
found.push(join5(dir, entry.name));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
} catch {
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return found;
|
|
1260
|
+
}
|
|
1261
|
+
async function snapshot(workspaceId, roots, workspaceCwd) {
|
|
1262
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
1263
|
+
if (!runtime) throw new HttpError(500, "runtime not initialized");
|
|
1264
|
+
const loader = runtime.services.resourceLoader;
|
|
1265
|
+
const { skills } = loader.getSkills();
|
|
1266
|
+
const { prompts } = loader.getPrompts();
|
|
1267
|
+
const extResult = loader.getExtensions();
|
|
1268
|
+
const skillsOut = skills.map((s) => ({
|
|
1269
|
+
name: s.name,
|
|
1270
|
+
description: s.description,
|
|
1271
|
+
filePath: s.filePath,
|
|
1272
|
+
disableModelInvocation: s.disableModelInvocation,
|
|
1273
|
+
source: toResourceSource(s.sourceInfo),
|
|
1274
|
+
managed: scopeFor(s.filePath, roots) !== void 0
|
|
1275
|
+
}));
|
|
1276
|
+
const promptsOut = prompts.map((p) => ({
|
|
1277
|
+
name: p.name,
|
|
1278
|
+
description: p.description,
|
|
1279
|
+
argumentHint: p.argumentHint,
|
|
1280
|
+
filePath: p.filePath,
|
|
1281
|
+
content: p.content,
|
|
1282
|
+
source: toResourceSource(p.sourceInfo),
|
|
1283
|
+
managed: scopeFor(p.filePath, roots) !== void 0
|
|
1284
|
+
}));
|
|
1285
|
+
const extensionsOut = extResult.extensions.map((e) => ({
|
|
1286
|
+
path: e.path,
|
|
1287
|
+
resolvedPath: e.resolvedPath,
|
|
1288
|
+
source: toResourceSource(e.sourceInfo),
|
|
1289
|
+
tools: [...e.tools.keys()],
|
|
1290
|
+
commands: [...e.commands.keys()],
|
|
1291
|
+
flags: [...e.flags.keys()],
|
|
1292
|
+
shortcuts: [...e.shortcuts.keys()]
|
|
1293
|
+
}));
|
|
1294
|
+
const extensionErrors = extResult.errors.map((err) => ({
|
|
1295
|
+
path: err.path,
|
|
1296
|
+
error: err.error
|
|
1297
|
+
}));
|
|
1298
|
+
const disabledExtensions = EXTENSIONS_ENABLED ? [] : await scanExtensionDirs(workspaceCwd);
|
|
1299
|
+
return {
|
|
1300
|
+
skills: skillsOut,
|
|
1301
|
+
prompts: promptsOut,
|
|
1302
|
+
extensionsEnabled: EXTENSIONS_ENABLED,
|
|
1303
|
+
extensions: extensionsOut,
|
|
1304
|
+
extensionErrors,
|
|
1305
|
+
disabledExtensions
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
async function rootsFor(workspaceId) {
|
|
1309
|
+
const ws = await getWorkspace(workspaceId);
|
|
1310
|
+
if (!ws) throw new HttpError(404, "workspace not found");
|
|
1311
|
+
const roots = resolveResourceRoots({ agentDir: getAgentDir2(), workspaceCwd: ws.path });
|
|
1312
|
+
return { roots, workspaceCwd: ws.path };
|
|
1313
|
+
}
|
|
1314
|
+
function respondError(c, err) {
|
|
1315
|
+
if (err instanceof HttpError) {
|
|
1316
|
+
return c.json({ ok: false, error: err.message }, err.status);
|
|
1317
|
+
}
|
|
1318
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1319
|
+
console.error(`[api/resources] unexpected error:`, err);
|
|
1320
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1321
|
+
}
|
|
1322
|
+
async function reload(workspaceId) {
|
|
1323
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
1324
|
+
if (!runtime) throw new HttpError(500, "runtime not initialized");
|
|
1325
|
+
await runtime.services.resourceLoader.reload();
|
|
1326
|
+
}
|
|
1327
|
+
function mountResourcesRoute(app2) {
|
|
1328
|
+
app2.get("/:id/resources", async (c) => {
|
|
1329
|
+
const id = c.req.param("id");
|
|
1330
|
+
const ws = await getWorkspace(id);
|
|
1331
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1332
|
+
try {
|
|
1333
|
+
await workspaceManager.getOrCreate(id);
|
|
1334
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1335
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
return respondError(c, err);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
app2.post("/:id/resources/reload", async (c) => {
|
|
1341
|
+
const id = c.req.param("id");
|
|
1342
|
+
const ws = await getWorkspace(id);
|
|
1343
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1344
|
+
try {
|
|
1345
|
+
await workspaceManager.getOrCreate(id);
|
|
1346
|
+
await reload(id);
|
|
1347
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1348
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
return respondError(c, err);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
app2.get("/:id/resources/skill", async (c) => {
|
|
1354
|
+
const id = c.req.param("id");
|
|
1355
|
+
const ws = await getWorkspace(id);
|
|
1356
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1357
|
+
const filePath = c.req.query("path");
|
|
1358
|
+
if (!filePath) return c.json({ ok: false, error: "path query is required" }, 400);
|
|
1359
|
+
try {
|
|
1360
|
+
const { roots } = await rootsFor(id);
|
|
1361
|
+
const scope = scopeFor(filePath, roots);
|
|
1362
|
+
if (scope === void 0) {
|
|
1363
|
+
return c.json({ ok: false, error: "skill file is not under a managed root" }, 400);
|
|
1364
|
+
}
|
|
1365
|
+
const data = await readSkillFile(filePath, roots);
|
|
1366
|
+
const body = {
|
|
1367
|
+
filePath,
|
|
1368
|
+
scope,
|
|
1369
|
+
name: data.name,
|
|
1370
|
+
description: data.description,
|
|
1371
|
+
disableModelInvocation: data.disableModelInvocation,
|
|
1372
|
+
body: data.body
|
|
1373
|
+
};
|
|
1374
|
+
return c.json(body);
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
return respondError(c, err);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
app2.post("/:id/resources/skills", async (c) => {
|
|
1380
|
+
const id = c.req.param("id");
|
|
1381
|
+
const ws = await getWorkspace(id);
|
|
1382
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1383
|
+
const body = await c.req.json().catch(() => null);
|
|
1384
|
+
if (!body || !isScope(body.scope) || typeof body.name !== "string" || typeof body.description !== "string" || typeof body.body !== "string") {
|
|
1385
|
+
return c.json({ ok: false, error: "scope, name, description, body are required" }, 400);
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
await workspaceManager.getOrCreate(id);
|
|
1389
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1390
|
+
await createSkill({
|
|
1391
|
+
roots,
|
|
1392
|
+
scope: body.scope,
|
|
1393
|
+
name: body.name,
|
|
1394
|
+
description: body.description,
|
|
1395
|
+
body: body.body,
|
|
1396
|
+
disableModelInvocation: body.disableModelInvocation
|
|
1397
|
+
});
|
|
1398
|
+
await reload(id);
|
|
1399
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
return respondError(c, err);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
app2.put("/:id/resources/skills", async (c) => {
|
|
1405
|
+
const id = c.req.param("id");
|
|
1406
|
+
const ws = await getWorkspace(id);
|
|
1407
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1408
|
+
const body = await c.req.json().catch(() => null);
|
|
1409
|
+
if (!body || typeof body.filePath !== "string" || typeof body.name !== "string" || typeof body.description !== "string" || typeof body.body !== "string") {
|
|
1410
|
+
return c.json({ ok: false, error: "filePath, name, description, body are required" }, 400);
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
await workspaceManager.getOrCreate(id);
|
|
1414
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1415
|
+
await updateSkill({
|
|
1416
|
+
roots,
|
|
1417
|
+
filePath: body.filePath,
|
|
1418
|
+
name: body.name,
|
|
1419
|
+
description: body.description,
|
|
1420
|
+
body: body.body,
|
|
1421
|
+
disableModelInvocation: body.disableModelInvocation
|
|
1422
|
+
});
|
|
1423
|
+
await reload(id);
|
|
1424
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
return respondError(c, err);
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
app2.delete("/:id/resources/skills", async (c) => {
|
|
1430
|
+
const id = c.req.param("id");
|
|
1431
|
+
const ws = await getWorkspace(id);
|
|
1432
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1433
|
+
const filePath = c.req.query("path");
|
|
1434
|
+
if (!filePath) return c.json({ ok: false, error: "path query is required" }, 400);
|
|
1435
|
+
try {
|
|
1436
|
+
await workspaceManager.getOrCreate(id);
|
|
1437
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1438
|
+
await deleteSkill(filePath, roots);
|
|
1439
|
+
await reload(id);
|
|
1440
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
return respondError(c, err);
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
app2.get("/:id/resources/prompt", async (c) => {
|
|
1446
|
+
const id = c.req.param("id");
|
|
1447
|
+
const ws = await getWorkspace(id);
|
|
1448
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1449
|
+
const filePath = c.req.query("path");
|
|
1450
|
+
if (!filePath) return c.json({ ok: false, error: "path query is required" }, 400);
|
|
1451
|
+
try {
|
|
1452
|
+
const { roots } = await rootsFor(id);
|
|
1453
|
+
const scope = scopeFor(filePath, roots);
|
|
1454
|
+
if (scope === void 0) {
|
|
1455
|
+
return c.json({ ok: false, error: "prompt file is not under a managed root" }, 400);
|
|
1456
|
+
}
|
|
1457
|
+
const data = await readPromptFile(filePath, roots);
|
|
1458
|
+
const body = {
|
|
1459
|
+
filePath,
|
|
1460
|
+
scope,
|
|
1461
|
+
name: data.name,
|
|
1462
|
+
description: data.description,
|
|
1463
|
+
argumentHint: data.argumentHint,
|
|
1464
|
+
body: data.body
|
|
1465
|
+
};
|
|
1466
|
+
return c.json(body);
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
return respondError(c, err);
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
app2.post("/:id/resources/prompts", async (c) => {
|
|
1472
|
+
const id = c.req.param("id");
|
|
1473
|
+
const ws = await getWorkspace(id);
|
|
1474
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1475
|
+
const body = await c.req.json().catch(() => null);
|
|
1476
|
+
if (!body || !isScope(body.scope) || typeof body.name !== "string" || typeof body.description !== "string" || typeof body.body !== "string") {
|
|
1477
|
+
return c.json({ ok: false, error: "scope, name, description, body are required" }, 400);
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
await workspaceManager.getOrCreate(id);
|
|
1481
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1482
|
+
await createPrompt({
|
|
1483
|
+
roots,
|
|
1484
|
+
scope: body.scope,
|
|
1485
|
+
name: body.name,
|
|
1486
|
+
description: body.description,
|
|
1487
|
+
argumentHint: body.argumentHint,
|
|
1488
|
+
body: body.body
|
|
1489
|
+
});
|
|
1490
|
+
await reload(id);
|
|
1491
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
return respondError(c, err);
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
app2.put("/:id/resources/prompts", async (c) => {
|
|
1497
|
+
const id = c.req.param("id");
|
|
1498
|
+
const ws = await getWorkspace(id);
|
|
1499
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1500
|
+
const body = await c.req.json().catch(() => null);
|
|
1501
|
+
if (!body || typeof body.filePath !== "string" || typeof body.name !== "string" || typeof body.description !== "string" || typeof body.body !== "string") {
|
|
1502
|
+
return c.json({ ok: false, error: "filePath, name, description, body are required" }, 400);
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
await workspaceManager.getOrCreate(id);
|
|
1506
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1507
|
+
await updatePrompt({
|
|
1508
|
+
roots,
|
|
1509
|
+
filePath: body.filePath,
|
|
1510
|
+
name: body.name,
|
|
1511
|
+
description: body.description,
|
|
1512
|
+
argumentHint: body.argumentHint,
|
|
1513
|
+
body: body.body
|
|
1514
|
+
});
|
|
1515
|
+
await reload(id);
|
|
1516
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
return respondError(c, err);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
app2.delete("/:id/resources/prompts", async (c) => {
|
|
1522
|
+
const id = c.req.param("id");
|
|
1523
|
+
const ws = await getWorkspace(id);
|
|
1524
|
+
if (!ws) return c.json({ ok: false, error: "not found" }, 404);
|
|
1525
|
+
const filePath = c.req.query("path");
|
|
1526
|
+
if (!filePath) return c.json({ ok: false, error: "path query is required" }, 400);
|
|
1527
|
+
try {
|
|
1528
|
+
await workspaceManager.getOrCreate(id);
|
|
1529
|
+
const { roots, workspaceCwd } = await rootsFor(id);
|
|
1530
|
+
await deletePrompt(filePath, roots);
|
|
1531
|
+
await reload(id);
|
|
1532
|
+
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
return respondError(c, err);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
function isScope(value) {
|
|
1539
|
+
return value === "user" || value === "project";
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// src/api/workspaces.ts
|
|
1543
|
+
var workspacesRoute = new Hono();
|
|
1544
|
+
workspacesRoute.get("/", async (c) => {
|
|
1545
|
+
const raw = await listWorkspaces();
|
|
1546
|
+
const workspaces = await Promise.all(raw.map(enrichWorkspace));
|
|
1547
|
+
const body = { workspaces };
|
|
1548
|
+
return c.json(body);
|
|
1549
|
+
});
|
|
1550
|
+
workspacesRoute.get("/:id/sessions", async (c) => {
|
|
1551
|
+
const id = c.req.param("id");
|
|
1552
|
+
const existed = await getWorkspace(id);
|
|
1553
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
1554
|
+
try {
|
|
1555
|
+
const sessions = await workspaceManager.listSessions(id);
|
|
1556
|
+
const body = { sessions };
|
|
1557
|
+
return c.json(body);
|
|
1558
|
+
} catch (err) {
|
|
1559
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1560
|
+
console.error(`[api] list sessions for ${id} failed:`, err);
|
|
1561
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
workspacesRoute.delete("/:id/sessions", async (c) => {
|
|
1565
|
+
const id = c.req.param("id");
|
|
1566
|
+
const existed = await getWorkspace(id);
|
|
1567
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
1568
|
+
const sessionPath = c.req.query("path");
|
|
1569
|
+
if (!sessionPath) {
|
|
1570
|
+
return c.json({ ok: false, error: "path query is required" }, 400);
|
|
1571
|
+
}
|
|
1572
|
+
try {
|
|
1573
|
+
await workspaceManager.deleteSession(id, sessionPath);
|
|
1574
|
+
const body = { ok: true };
|
|
1575
|
+
return c.json(body);
|
|
1576
|
+
} catch (err) {
|
|
1577
|
+
if (err instanceof HttpError) {
|
|
1578
|
+
return c.json(
|
|
1579
|
+
{ ok: false, error: err.message },
|
|
1580
|
+
err.status
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1584
|
+
console.error(`[api] delete session for ${id} failed:`, err);
|
|
1585
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
workspacesRoute.get("/:id/fork-points", async (c) => {
|
|
1589
|
+
const id = c.req.param("id");
|
|
1590
|
+
const existed = await getWorkspace(id);
|
|
1591
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
1592
|
+
try {
|
|
1593
|
+
const runtime = await workspaceManager.getOrCreate(id);
|
|
1594
|
+
const raw = runtime.session.getUserMessagesForForking();
|
|
1595
|
+
const points = raw.map((p) => ({
|
|
1596
|
+
entryId: p.entryId,
|
|
1597
|
+
text: p.text.length > 120 ? p.text.slice(0, 120) + "\u2026" : p.text
|
|
1598
|
+
}));
|
|
1599
|
+
const body = { points };
|
|
1600
|
+
return c.json(body);
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1603
|
+
console.error(`[api] fork-points for ${id} failed:`, err);
|
|
1604
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
workspacesRoute.get("/:id/history", async (c) => {
|
|
1608
|
+
const id = c.req.param("id");
|
|
1609
|
+
const existed = await getWorkspace(id);
|
|
1610
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
1611
|
+
try {
|
|
1612
|
+
await workspaceManager.getOrCreate(id);
|
|
1613
|
+
const sessionPath = c.req.query("sessionPath");
|
|
1614
|
+
const body = workspaceManager.getSessionHistory(id, sessionPath);
|
|
1615
|
+
return c.json(body);
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1618
|
+
console.error(`[api] history for ${id} failed:`, err);
|
|
1619
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
workspacesRoute.post("/", async (c) => {
|
|
1623
|
+
const body = await c.req.json();
|
|
1624
|
+
if (!body?.path || typeof body.path !== "string") {
|
|
1625
|
+
return c.json({ ok: false, error: "path is required" }, 400);
|
|
1626
|
+
}
|
|
1627
|
+
if (!isAbsolute3(body.path)) {
|
|
1628
|
+
return c.json({ ok: false, error: "path must be absolute" }, 400);
|
|
1629
|
+
}
|
|
1630
|
+
const resolved = resolve3(body.path);
|
|
1631
|
+
try {
|
|
1632
|
+
const st = await stat2(resolved);
|
|
1633
|
+
if (!st.isDirectory()) {
|
|
1634
|
+
return c.json({ ok: false, error: "path is not a directory" }, 400);
|
|
1635
|
+
}
|
|
1636
|
+
} catch {
|
|
1637
|
+
return c.json({ ok: false, error: "path does not exist" }, 400);
|
|
1638
|
+
}
|
|
1639
|
+
const stored = await addWorkspace({
|
|
1640
|
+
path: resolved,
|
|
1641
|
+
name: body.name?.trim() || basename2(resolved) || resolved
|
|
1642
|
+
});
|
|
1643
|
+
const ws = await enrichWorkspace(stored);
|
|
1644
|
+
const res = { workspace: ws };
|
|
1645
|
+
return c.json(res);
|
|
1646
|
+
});
|
|
1647
|
+
workspacesRoute.delete("/:id", async (c) => {
|
|
1648
|
+
const id = c.req.param("id");
|
|
1649
|
+
const existed = await getWorkspace(id);
|
|
1650
|
+
if (!existed) return c.json({ ok: false, error: "not found" }, 404);
|
|
1651
|
+
await workspaceManager.dispose(id);
|
|
1652
|
+
await removeWorkspace(id);
|
|
1653
|
+
const body = { ok: true };
|
|
1654
|
+
return c.json(body);
|
|
1655
|
+
});
|
|
1656
|
+
mountConfigRoutes(workspacesRoute);
|
|
1657
|
+
mountResourcesRoute(workspacesRoute);
|
|
1658
|
+
mountFilesRoute(workspacesRoute);
|
|
1659
|
+
|
|
1660
|
+
// src/api/fs.ts
|
|
1661
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
1662
|
+
import { homedir as homedir2 } from "os";
|
|
1663
|
+
import { dirname as dirname3, isAbsolute as isAbsolute4, join as join6, resolve as resolve4 } from "path";
|
|
1664
|
+
import { Hono as Hono2 } from "hono";
|
|
1665
|
+
var fsRoute = new Hono2();
|
|
1666
|
+
fsRoute.get("/browse", async (c) => {
|
|
1667
|
+
const rawPath = c.req.query("path");
|
|
1668
|
+
const showHidden = c.req.query("showHidden") === "1";
|
|
1669
|
+
const target = rawPath && isAbsolute4(rawPath) ? resolve4(rawPath) : homedir2();
|
|
1670
|
+
let dirents;
|
|
1671
|
+
try {
|
|
1672
|
+
dirents = await readdir3(target, { withFileTypes: true });
|
|
1673
|
+
} catch (err) {
|
|
1674
|
+
const code = err.code;
|
|
1675
|
+
const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
|
|
1676
|
+
return c.json({ ok: false, error: msg, path: target }, 400);
|
|
1677
|
+
}
|
|
1678
|
+
const entries = dirents.filter((d) => d.isDirectory()).filter((d) => showHidden || !d.name.startsWith(".")).map((d) => ({
|
|
1679
|
+
name: d.name,
|
|
1680
|
+
path: join6(target, d.name),
|
|
1681
|
+
type: "dir"
|
|
1682
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
1683
|
+
const parent = (() => {
|
|
1684
|
+
const p = dirname3(target);
|
|
1685
|
+
return p === target ? null : p;
|
|
1686
|
+
})();
|
|
1687
|
+
const body = { path: target, parent, entries };
|
|
1688
|
+
return c.json(body);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// src/api/model-configs.ts
|
|
1692
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
1693
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1694
|
+
import { Hono as Hono3 } from "hono";
|
|
1695
|
+
import {
|
|
1696
|
+
getAgentDir as getAgentDir3
|
|
1697
|
+
} from "@earendil-works/pi-coding-agent";
|
|
1698
|
+
var modelConfigsRoute = new Hono3();
|
|
1699
|
+
var writeLock = Promise.resolve();
|
|
1700
|
+
function withWriteLock(fn) {
|
|
1701
|
+
const next = writeLock.then(fn, fn);
|
|
1702
|
+
writeLock = next.then(() => {
|
|
1703
|
+
}, () => {
|
|
1704
|
+
});
|
|
1705
|
+
return next;
|
|
1706
|
+
}
|
|
1707
|
+
function modelsPath() {
|
|
1708
|
+
return join7(getAgentDir3(), "models.json");
|
|
1709
|
+
}
|
|
1710
|
+
async function readModelsJson() {
|
|
1711
|
+
try {
|
|
1712
|
+
const raw = await readFile3(modelsPath(), "utf-8");
|
|
1713
|
+
return JSON.parse(raw);
|
|
1714
|
+
} catch (err) {
|
|
1715
|
+
if (err?.code === "ENOENT") {
|
|
1716
|
+
return { providers: {} };
|
|
1717
|
+
}
|
|
1718
|
+
throw err;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async function writeModelsJson(config2) {
|
|
1722
|
+
const p = modelsPath();
|
|
1723
|
+
await mkdir3(dirname4(p), { recursive: true });
|
|
1724
|
+
await writeFile3(p, JSON.stringify(config2, null, 2), "utf-8");
|
|
1725
|
+
}
|
|
1726
|
+
var ValidationError = class extends Error {
|
|
1727
|
+
constructor(message, status) {
|
|
1728
|
+
super(message);
|
|
1729
|
+
this.status = status;
|
|
1730
|
+
}
|
|
1731
|
+
status;
|
|
1732
|
+
};
|
|
1733
|
+
function refreshRegistry(workspaceId) {
|
|
1734
|
+
if (!workspaceId) return;
|
|
1735
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
1736
|
+
if (runtime) {
|
|
1737
|
+
try {
|
|
1738
|
+
runtime.session.modelRegistry.refresh();
|
|
1739
|
+
} catch (e) {
|
|
1740
|
+
console.error(`[model-configs] refresh registry for ${workspaceId} failed:`, e);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
modelConfigsRoute.get("/", async (c) => {
|
|
1745
|
+
try {
|
|
1746
|
+
const config2 = await readModelsJson();
|
|
1747
|
+
const body = { config: config2 };
|
|
1748
|
+
return c.json(body);
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1751
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
modelConfigsRoute.put("/", async (c) => {
|
|
1755
|
+
const body = await c.req.json();
|
|
1756
|
+
if (!body?.config?.providers) {
|
|
1757
|
+
return c.json({ ok: false, error: "config.providers is required" }, 400);
|
|
1758
|
+
}
|
|
1759
|
+
try {
|
|
1760
|
+
await withWriteLock(async () => {
|
|
1761
|
+
await writeModelsJson(body.config);
|
|
1762
|
+
});
|
|
1763
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1764
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1765
|
+
const resp = { config: body.config };
|
|
1766
|
+
return c.json(resp);
|
|
1767
|
+
} catch (err) {
|
|
1768
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1769
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
modelConfigsRoute.post("/providers", async (c) => {
|
|
1773
|
+
const body = await c.req.json();
|
|
1774
|
+
if (!body?.name || !body?.provider) {
|
|
1775
|
+
return c.json({ ok: false, error: "name and provider are required" }, 400);
|
|
1776
|
+
}
|
|
1777
|
+
if (!body.provider.baseUrl || !body.provider.api || !body.provider.apiKey) {
|
|
1778
|
+
return c.json({ ok: false, error: "provider must have baseUrl, api, and apiKey" }, 400);
|
|
1779
|
+
}
|
|
1780
|
+
if (!Array.isArray(body.provider.models)) {
|
|
1781
|
+
return c.json({ ok: false, error: "provider.models must be an array" }, 400);
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
const config2 = await withWriteLock(async () => {
|
|
1785
|
+
const cfg = await readModelsJson();
|
|
1786
|
+
cfg.providers[body.name] = body.provider;
|
|
1787
|
+
await writeModelsJson(cfg);
|
|
1788
|
+
return cfg;
|
|
1789
|
+
});
|
|
1790
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1791
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1792
|
+
const resp = { config: config2 };
|
|
1793
|
+
return c.json(resp);
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1796
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
modelConfigsRoute.delete("/providers", async (c) => {
|
|
1800
|
+
const name = c.req.query("name");
|
|
1801
|
+
if (!name) {
|
|
1802
|
+
return c.json({ ok: false, error: "name query param is required" }, 400);
|
|
1803
|
+
}
|
|
1804
|
+
try {
|
|
1805
|
+
const config2 = await withWriteLock(async () => {
|
|
1806
|
+
const cfg = await readModelsJson();
|
|
1807
|
+
if (!cfg.providers[name]) {
|
|
1808
|
+
throw new ValidationError(`provider "${name}" not found`, 404);
|
|
1809
|
+
}
|
|
1810
|
+
delete cfg.providers[name];
|
|
1811
|
+
await writeModelsJson(cfg);
|
|
1812
|
+
return cfg;
|
|
1813
|
+
});
|
|
1814
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1815
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1816
|
+
const resp = { config: config2 };
|
|
1817
|
+
return c.json(resp);
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
if (err instanceof ValidationError) {
|
|
1820
|
+
return c.json({ ok: false, error: err.message }, err.status);
|
|
1821
|
+
}
|
|
1822
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1823
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
modelConfigsRoute.post("/providers/:provider/models", async (c) => {
|
|
1827
|
+
const provider = c.req.param("provider");
|
|
1828
|
+
const body = await c.req.json();
|
|
1829
|
+
if (!body?.model?.id || !body?.model?.name) {
|
|
1830
|
+
return c.json({ ok: false, error: "model.id and model.name are required" }, 400);
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
const config2 = await withWriteLock(async () => {
|
|
1834
|
+
const cfg = await readModelsJson();
|
|
1835
|
+
if (!cfg.providers[provider]) {
|
|
1836
|
+
throw new ValidationError(`provider "${provider}" not found`, 404);
|
|
1837
|
+
}
|
|
1838
|
+
const existing = cfg.providers[provider].models.find((m) => m.id === body.model.id);
|
|
1839
|
+
if (existing) {
|
|
1840
|
+
throw new ValidationError(`model "${body.model.id}" already exists in provider "${provider}"`, 409);
|
|
1841
|
+
}
|
|
1842
|
+
cfg.providers[provider].models.push(body.model);
|
|
1843
|
+
await writeModelsJson(cfg);
|
|
1844
|
+
return cfg;
|
|
1845
|
+
});
|
|
1846
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1847
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1848
|
+
const resp = { config: config2 };
|
|
1849
|
+
return c.json(resp);
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
if (err instanceof ValidationError) {
|
|
1852
|
+
return c.json({ ok: false, error: err.message }, err.status);
|
|
1853
|
+
}
|
|
1854
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1855
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
|
|
1859
|
+
const provider = c.req.param("provider");
|
|
1860
|
+
const modelId = c.req.param("modelId");
|
|
1861
|
+
const body = await c.req.json();
|
|
1862
|
+
if (!body?.model) {
|
|
1863
|
+
return c.json({ ok: false, error: "model is required" }, 400);
|
|
1864
|
+
}
|
|
1865
|
+
if (body.model.id !== modelId) {
|
|
1866
|
+
return c.json({ ok: false, error: "model.id does not match URL parameter" }, 400);
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
const config2 = await withWriteLock(async () => {
|
|
1870
|
+
const cfg = await readModelsJson();
|
|
1871
|
+
if (!cfg.providers[provider]) {
|
|
1872
|
+
throw new ValidationError(`provider "${provider}" not found`, 404);
|
|
1873
|
+
}
|
|
1874
|
+
const idx = cfg.providers[provider].models.findIndex((m) => m.id === modelId);
|
|
1875
|
+
if (idx === -1) {
|
|
1876
|
+
throw new ValidationError(`model "${modelId}" not found in provider "${provider}"`, 404);
|
|
1877
|
+
}
|
|
1878
|
+
cfg.providers[provider].models[idx] = body.model;
|
|
1879
|
+
await writeModelsJson(cfg);
|
|
1880
|
+
return cfg;
|
|
1881
|
+
});
|
|
1882
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1883
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1884
|
+
const resp = { config: config2 };
|
|
1885
|
+
return c.json(resp);
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
if (err instanceof ValidationError) {
|
|
1888
|
+
return c.json({ ok: false, error: err.message }, err.status);
|
|
1889
|
+
}
|
|
1890
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1891
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
|
|
1895
|
+
const provider = c.req.param("provider");
|
|
1896
|
+
const modelId = c.req.param("modelId");
|
|
1897
|
+
try {
|
|
1898
|
+
const config2 = await withWriteLock(async () => {
|
|
1899
|
+
const cfg = await readModelsJson();
|
|
1900
|
+
if (!cfg.providers[provider]) {
|
|
1901
|
+
throw new ValidationError(`provider "${provider}" not found`, 404);
|
|
1902
|
+
}
|
|
1903
|
+
const idx = cfg.providers[provider].models.findIndex((m) => m.id === modelId);
|
|
1904
|
+
if (idx === -1) {
|
|
1905
|
+
throw new ValidationError(`model "${modelId}" not found in provider "${provider}"`, 404);
|
|
1906
|
+
}
|
|
1907
|
+
cfg.providers[provider].models.splice(idx, 1);
|
|
1908
|
+
await writeModelsJson(cfg);
|
|
1909
|
+
return cfg;
|
|
1910
|
+
});
|
|
1911
|
+
const workspaceId = c.req.query("workspaceId");
|
|
1912
|
+
refreshRegistry(workspaceId ?? void 0);
|
|
1913
|
+
const resp = { config: config2 };
|
|
1914
|
+
return c.json(resp);
|
|
1915
|
+
} catch (err) {
|
|
1916
|
+
if (err instanceof ValidationError) {
|
|
1917
|
+
return c.json({ ok: false, error: err.message }, err.status);
|
|
1918
|
+
}
|
|
1919
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1920
|
+
return c.json({ ok: false, error: message }, 500);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
// src/ws/hub.ts
|
|
1925
|
+
import { WebSocketServer } from "ws";
|
|
1926
|
+
|
|
1927
|
+
// src/ws/bridge.ts
|
|
1928
|
+
function translatePiEvent(ev) {
|
|
1929
|
+
switch (ev.type) {
|
|
1930
|
+
case "agent_start":
|
|
1931
|
+
return { kind: "agent_start" };
|
|
1932
|
+
case "agent_end":
|
|
1933
|
+
return { kind: "agent_end", willRetry: ev.willRetry };
|
|
1934
|
+
case "turn_start":
|
|
1935
|
+
return { kind: "turn_start" };
|
|
1936
|
+
case "turn_end":
|
|
1937
|
+
return { kind: "turn_end" };
|
|
1938
|
+
case "message_start":
|
|
1939
|
+
return { kind: "message_start", role: roleOf(ev.message) };
|
|
1940
|
+
case "message_end":
|
|
1941
|
+
return { kind: "message_end", role: roleOf(ev.message) };
|
|
1942
|
+
case "message_update": {
|
|
1943
|
+
const ame = ev.assistantMessageEvent;
|
|
1944
|
+
if (ame.type === "text_delta") {
|
|
1945
|
+
return {
|
|
1946
|
+
kind: "message_update",
|
|
1947
|
+
delta: { kind: "text", contentIndex: ame.contentIndex, text: ame.delta }
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
if (ame.type === "thinking_delta") {
|
|
1951
|
+
return {
|
|
1952
|
+
kind: "message_update",
|
|
1953
|
+
delta: { kind: "thinking", contentIndex: ame.contentIndex, text: ame.delta }
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
return { kind: "message_update", delta: { kind: "other" } };
|
|
1957
|
+
}
|
|
1958
|
+
case "tool_execution_start":
|
|
1959
|
+
return {
|
|
1960
|
+
kind: "tool_execution_start",
|
|
1961
|
+
toolCallId: ev.toolCallId,
|
|
1962
|
+
toolName: ev.toolName,
|
|
1963
|
+
args: ev.args
|
|
1964
|
+
};
|
|
1965
|
+
case "tool_execution_update":
|
|
1966
|
+
return {
|
|
1967
|
+
kind: "tool_execution_update",
|
|
1968
|
+
toolCallId: ev.toolCallId,
|
|
1969
|
+
toolName: ev.toolName,
|
|
1970
|
+
partialText: extractText(ev.partialResult)
|
|
1971
|
+
};
|
|
1972
|
+
case "tool_execution_end":
|
|
1973
|
+
return {
|
|
1974
|
+
kind: "tool_execution_end",
|
|
1975
|
+
toolCallId: ev.toolCallId,
|
|
1976
|
+
toolName: ev.toolName,
|
|
1977
|
+
isError: ev.isError,
|
|
1978
|
+
text: extractText(ev.result)
|
|
1979
|
+
};
|
|
1980
|
+
case "queue_update":
|
|
1981
|
+
return {
|
|
1982
|
+
kind: "queue_update",
|
|
1983
|
+
steering: [...ev.steering],
|
|
1984
|
+
followUp: [...ev.followUp]
|
|
1985
|
+
};
|
|
1986
|
+
case "auto_retry_start":
|
|
1987
|
+
return {
|
|
1988
|
+
kind: "auto_retry_start",
|
|
1989
|
+
attempt: ev.attempt,
|
|
1990
|
+
maxAttempts: ev.maxAttempts,
|
|
1991
|
+
delayMs: ev.delayMs,
|
|
1992
|
+
errorMessage: ev.errorMessage
|
|
1993
|
+
};
|
|
1994
|
+
case "auto_retry_end":
|
|
1995
|
+
return {
|
|
1996
|
+
kind: "auto_retry_end",
|
|
1997
|
+
success: ev.success,
|
|
1998
|
+
attempt: ev.attempt,
|
|
1999
|
+
finalError: ev.finalError
|
|
2000
|
+
};
|
|
2001
|
+
case "compaction_start":
|
|
2002
|
+
return { kind: "compaction_start", reason: ev.reason };
|
|
2003
|
+
case "compaction_end":
|
|
2004
|
+
return {
|
|
2005
|
+
kind: "compaction_end",
|
|
2006
|
+
reason: ev.reason,
|
|
2007
|
+
aborted: ev.aborted,
|
|
2008
|
+
willRetry: ev.willRetry,
|
|
2009
|
+
errorMessage: ev.errorMessage
|
|
2010
|
+
};
|
|
2011
|
+
case "session_info_changed":
|
|
2012
|
+
return { kind: "session_info_changed", name: ev.name };
|
|
2013
|
+
case "thinking_level_changed":
|
|
2014
|
+
return { kind: "thinking_level_changed", level: ev.level };
|
|
2015
|
+
default:
|
|
2016
|
+
return void 0;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
function roleOf(message) {
|
|
2020
|
+
const role = message?.role;
|
|
2021
|
+
if (role === "user" || role === "assistant" || role === "toolResult" || role === "bashExecution") {
|
|
2022
|
+
return role;
|
|
2023
|
+
}
|
|
2024
|
+
return "assistant";
|
|
2025
|
+
}
|
|
2026
|
+
function extractText(result) {
|
|
2027
|
+
if (!result || typeof result !== "object") return void 0;
|
|
2028
|
+
const content = result.content;
|
|
2029
|
+
if (!Array.isArray(content)) return void 0;
|
|
2030
|
+
const parts = [];
|
|
2031
|
+
for (const c of content) {
|
|
2032
|
+
if (c && typeof c === "object" && c.type === "text") {
|
|
2033
|
+
const text = c.text;
|
|
2034
|
+
if (typeof text === "string") parts.push(text);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return parts.length === 0 ? void 0 : parts.join("");
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// src/ws/hub.ts
|
|
2041
|
+
var replacementLocks = /* @__PURE__ */ new Map();
|
|
2042
|
+
function withReplacementLock(workspaceId, fn) {
|
|
2043
|
+
const prev = replacementLocks.get(workspaceId) ?? Promise.resolve();
|
|
2044
|
+
const next = prev.then(fn, fn);
|
|
2045
|
+
replacementLocks.set(workspaceId, next);
|
|
2046
|
+
const cleanup = () => {
|
|
2047
|
+
if (replacementLocks.get(workspaceId) === next) {
|
|
2048
|
+
replacementLocks.delete(workspaceId);
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
next.then(cleanup, cleanup);
|
|
2052
|
+
return next;
|
|
2053
|
+
}
|
|
2054
|
+
function attachWsHub(httpServer) {
|
|
2055
|
+
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
|
2056
|
+
wss.on("connection", (ws) => {
|
|
2057
|
+
const state = {};
|
|
2058
|
+
ws.on("message", async (raw) => {
|
|
2059
|
+
let msg;
|
|
2060
|
+
try {
|
|
2061
|
+
msg = JSON.parse(raw.toString());
|
|
2062
|
+
} catch {
|
|
2063
|
+
send(ws, { type: "error", message: "invalid JSON" });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
await handle(ws, state, msg);
|
|
2068
|
+
} catch (err) {
|
|
2069
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2070
|
+
send(ws, { type: "error", message, command: msg.type });
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
ws.on("close", () => {
|
|
2074
|
+
detach(state, ws);
|
|
2075
|
+
});
|
|
2076
|
+
});
|
|
2077
|
+
return wss;
|
|
2078
|
+
}
|
|
2079
|
+
async function handle(ws, state, msg) {
|
|
2080
|
+
switch (msg.type) {
|
|
2081
|
+
case "subscribe": {
|
|
2082
|
+
const hadCurrentSubscription = state.workspaceId === msg.workspaceId && !!state.unsubscribeSession;
|
|
2083
|
+
ensureRebindListener(ws, state, msg.workspaceId);
|
|
2084
|
+
await workspaceManager.getOrCreate(msg.workspaceId);
|
|
2085
|
+
let switched = false;
|
|
2086
|
+
let switchError;
|
|
2087
|
+
if (msg.sessionPath) {
|
|
2088
|
+
await withReplacementLock(msg.workspaceId, async () => {
|
|
2089
|
+
try {
|
|
2090
|
+
switched = await workspaceManager.switchSession(msg.workspaceId, msg.sessionPath);
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
switchError = err instanceof Error ? err.message : String(err);
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
if (!switched && !hadCurrentSubscription) {
|
|
2097
|
+
bindCurrentSession(ws, state, msg.workspaceId);
|
|
2098
|
+
}
|
|
2099
|
+
if (switchError) {
|
|
2100
|
+
send(ws, { type: "error", message: switchError, command: "subscribe" });
|
|
2101
|
+
}
|
|
2102
|
+
send(ws, { type: "ack", command: "subscribe" });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
case "prompt": {
|
|
2106
|
+
const wsId = state.workspaceId;
|
|
2107
|
+
if (!wsId) {
|
|
2108
|
+
send(ws, { type: "error", message: "not subscribed", command: "prompt" });
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (replacementLocks.has(wsId)) {
|
|
2112
|
+
send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
const runtime = workspaceManager.get(wsId);
|
|
2116
|
+
if (!runtime) {
|
|
2117
|
+
send(ws, { type: "error", message: "runtime gone", command: "prompt" });
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
void runtime.session.prompt(msg.message, {
|
|
2121
|
+
streamingBehavior: msg.streamingBehavior
|
|
2122
|
+
}).catch((err) => {
|
|
2123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2124
|
+
send(ws, { type: "error", message, command: "prompt" });
|
|
2125
|
+
});
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
case "abort": {
|
|
2129
|
+
const wsId = state.workspaceId;
|
|
2130
|
+
if (!wsId) {
|
|
2131
|
+
send(ws, { type: "error", message: "not subscribed", command: "abort" });
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (replacementLocks.has(wsId)) {
|
|
2135
|
+
send(ws, { type: "error", message: "session switching in progress", command: "abort" });
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const runtime = workspaceManager.get(wsId);
|
|
2139
|
+
if (!runtime) return;
|
|
2140
|
+
await runtime.session.abort();
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
case "new_session": {
|
|
2144
|
+
const wsId = state.workspaceId;
|
|
2145
|
+
if (!wsId) {
|
|
2146
|
+
send(ws, { type: "error", message: "not subscribed", command: "new_session" });
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
await withReplacementLock(msg.workspaceId, async () => {
|
|
2150
|
+
const runtime = workspaceManager.get(wsId);
|
|
2151
|
+
if (!runtime) {
|
|
2152
|
+
send(ws, { type: "error", message: "runtime gone", command: "new_session" });
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (runtime.session.isStreaming) {
|
|
2156
|
+
send(ws, { type: "error", message: "cannot create session while streaming", command: "new_session" });
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
const result = await runtime.newSession();
|
|
2160
|
+
if (result.cancelled) {
|
|
2161
|
+
send(ws, { type: "error", message: "new session cancelled", command: "new_session" });
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
case "fork": {
|
|
2167
|
+
const wsId = state.workspaceId;
|
|
2168
|
+
if (!wsId) {
|
|
2169
|
+
send(ws, { type: "error", message: "not subscribed", command: "fork" });
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
await withReplacementLock(msg.workspaceId, async () => {
|
|
2173
|
+
const runtime = workspaceManager.get(wsId);
|
|
2174
|
+
if (!runtime) {
|
|
2175
|
+
send(ws, { type: "error", message: "runtime gone", command: "fork" });
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (runtime.session.isStreaming) {
|
|
2179
|
+
send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const result = await runtime.fork(msg.entryId);
|
|
2183
|
+
if (result.cancelled) {
|
|
2184
|
+
send(ws, { type: "error", message: "fork cancelled", command: "fork" });
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
default: {
|
|
2190
|
+
const _ = msg;
|
|
2191
|
+
void _;
|
|
2192
|
+
send(ws, { type: "error", message: "unknown command" });
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
function ensureRebindListener(ws, state, workspaceId) {
|
|
2197
|
+
if (state.workspaceId === workspaceId && state.unsubscribeRebind) return;
|
|
2198
|
+
detach(state, ws);
|
|
2199
|
+
state.workspaceId = workspaceId;
|
|
2200
|
+
workspaceManager.addSubscriber(workspaceId, ws);
|
|
2201
|
+
state.unsubscribeRebind = workspaceManager.onSessionReplaced(workspaceId, () => {
|
|
2202
|
+
if (state.workspaceId !== workspaceId) return;
|
|
2203
|
+
bindCurrentSession(ws, state, workspaceId);
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
function bindCurrentSession(ws, state, workspaceId) {
|
|
2207
|
+
const runtime = workspaceManager.get(workspaceId);
|
|
2208
|
+
if (!runtime) {
|
|
2209
|
+
send(ws, { type: "error", message: "runtime gone", command: "subscribe" });
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
state.unsubscribeSession?.();
|
|
2213
|
+
const session = runtime.session;
|
|
2214
|
+
const sessionPath = session.sessionFile ?? null;
|
|
2215
|
+
let assistantStartAt;
|
|
2216
|
+
let assistantFirstTokenAt;
|
|
2217
|
+
state.unsubscribeSession = session.subscribe((ev) => {
|
|
2218
|
+
const payload = translatePiEvent(ev);
|
|
2219
|
+
if (!payload) return;
|
|
2220
|
+
if (payload.kind === "message_start" && payload.role === "assistant") {
|
|
2221
|
+
assistantStartAt = performance.now();
|
|
2222
|
+
assistantFirstTokenAt = void 0;
|
|
2223
|
+
} else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
|
|
2224
|
+
assistantFirstTokenAt = performance.now();
|
|
2225
|
+
}
|
|
2226
|
+
send(ws, {
|
|
2227
|
+
type: "event",
|
|
2228
|
+
workspaceId,
|
|
2229
|
+
sessionPath,
|
|
2230
|
+
payload
|
|
2231
|
+
});
|
|
2232
|
+
if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
|
|
2233
|
+
const now = performance.now();
|
|
2234
|
+
const timing = {
|
|
2235
|
+
kind: "assistant_timing",
|
|
2236
|
+
firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
|
|
2237
|
+
totalMs: Math.round(now - assistantStartAt)
|
|
2238
|
+
};
|
|
2239
|
+
send(ws, {
|
|
2240
|
+
type: "event",
|
|
2241
|
+
workspaceId,
|
|
2242
|
+
sessionPath,
|
|
2243
|
+
payload: timing
|
|
2244
|
+
});
|
|
2245
|
+
assistantStartAt = void 0;
|
|
2246
|
+
assistantFirstTokenAt = void 0;
|
|
2247
|
+
}
|
|
2248
|
+
if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
|
|
2249
|
+
sendContextUsage(ws, runtime, workspaceId, sessionPath);
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
send(ws, {
|
|
2253
|
+
type: "subscribed",
|
|
2254
|
+
workspaceId,
|
|
2255
|
+
sessionPath,
|
|
2256
|
+
sessionId: session.sessionId
|
|
2257
|
+
});
|
|
2258
|
+
sendContextUsage(ws, runtime, workspaceId, sessionPath);
|
|
2259
|
+
}
|
|
2260
|
+
function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
|
|
2261
|
+
const usage = runtime.session.getContextUsage();
|
|
2262
|
+
if (!usage) return;
|
|
2263
|
+
const payload = {
|
|
2264
|
+
kind: "context_usage",
|
|
2265
|
+
tokens: usage.tokens,
|
|
2266
|
+
contextWindow: usage.contextWindow,
|
|
2267
|
+
percent: usage.percent
|
|
2268
|
+
};
|
|
2269
|
+
send(ws, {
|
|
2270
|
+
type: "event",
|
|
2271
|
+
workspaceId,
|
|
2272
|
+
sessionPath,
|
|
2273
|
+
payload
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
function detach(state, ws) {
|
|
2277
|
+
state.unsubscribeSession?.();
|
|
2278
|
+
state.unsubscribeSession = void 0;
|
|
2279
|
+
state.unsubscribeRebind?.();
|
|
2280
|
+
state.unsubscribeRebind = void 0;
|
|
2281
|
+
if (state.workspaceId && ws) {
|
|
2282
|
+
workspaceManager.removeSubscriber(state.workspaceId, ws);
|
|
2283
|
+
}
|
|
2284
|
+
state.workspaceId = void 0;
|
|
2285
|
+
}
|
|
2286
|
+
function send(ws, msg) {
|
|
2287
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
2288
|
+
ws.send(JSON.stringify(msg));
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// src/index.ts
|
|
2292
|
+
var app = new Hono4();
|
|
2293
|
+
var distDir = dirname5(fileURLToPath(import.meta.url));
|
|
2294
|
+
var webRoot = resolve5(process.env.PI_PILOT_WEB_ROOT ?? join8(distDir, "..", "public"));
|
|
2295
|
+
var webIndexPath = join8(webRoot, "index.html");
|
|
2296
|
+
var mimeTypes = {
|
|
2297
|
+
".css": "text/css; charset=utf-8",
|
|
2298
|
+
".html": "text/html; charset=utf-8",
|
|
2299
|
+
".ico": "image/x-icon",
|
|
2300
|
+
".js": "text/javascript; charset=utf-8",
|
|
2301
|
+
".json": "application/json; charset=utf-8",
|
|
2302
|
+
".map": "application/json; charset=utf-8",
|
|
2303
|
+
".png": "image/png",
|
|
2304
|
+
".svg": "image/svg+xml",
|
|
2305
|
+
".txt": "text/plain; charset=utf-8",
|
|
2306
|
+
".webp": "image/webp",
|
|
2307
|
+
".woff": "font/woff",
|
|
2308
|
+
".woff2": "font/woff2"
|
|
2309
|
+
};
|
|
2310
|
+
function isApiOrWsPath(pathname) {
|
|
2311
|
+
return pathname === "/api" || pathname.startsWith("/api/") || pathname === "/ws";
|
|
2312
|
+
}
|
|
2313
|
+
function safeResolveWebPath(pathname) {
|
|
2314
|
+
let decoded;
|
|
2315
|
+
try {
|
|
2316
|
+
decoded = decodeURIComponent(pathname);
|
|
2317
|
+
} catch {
|
|
2318
|
+
return void 0;
|
|
2319
|
+
}
|
|
2320
|
+
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
2321
|
+
const candidate = resolve5(webRoot, relativePath);
|
|
2322
|
+
if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
|
|
2323
|
+
return void 0;
|
|
2324
|
+
}
|
|
2325
|
+
return candidate;
|
|
2326
|
+
}
|
|
2327
|
+
async function readWebFile(path) {
|
|
2328
|
+
try {
|
|
2329
|
+
return await readFile4(path);
|
|
2330
|
+
} catch (err) {
|
|
2331
|
+
const code = err.code;
|
|
2332
|
+
if (code === "ENOENT" || code === "EISDIR") return void 0;
|
|
2333
|
+
throw err;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
async function serveWeb(c) {
|
|
2337
|
+
const pathname = new URL(c.req.url).pathname;
|
|
2338
|
+
if (isApiOrWsPath(pathname)) return c.notFound();
|
|
2339
|
+
const assetPath = safeResolveWebPath(pathname);
|
|
2340
|
+
if (!assetPath) return c.text("invalid asset path", 400);
|
|
2341
|
+
const asset = await readWebFile(assetPath);
|
|
2342
|
+
const body = asset ?? await readFile4(webIndexPath);
|
|
2343
|
+
const filePath = asset ? assetPath : webIndexPath;
|
|
2344
|
+
const headers = {
|
|
2345
|
+
"Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",
|
|
2346
|
+
"Cache-Control": asset && pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
|
|
2347
|
+
};
|
|
2348
|
+
return new Response(body, { headers });
|
|
2349
|
+
}
|
|
2350
|
+
app.use(
|
|
2351
|
+
"/api/*",
|
|
2352
|
+
cors({
|
|
2353
|
+
origin: config.corsOrigin,
|
|
2354
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE"]
|
|
2355
|
+
})
|
|
2356
|
+
);
|
|
2357
|
+
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
2358
|
+
app.route("/api/workspaces", workspacesRoute);
|
|
2359
|
+
app.route("/api/fs", fsRoute);
|
|
2360
|
+
app.route("/api/model-configs", modelConfigsRoute);
|
|
2361
|
+
if (existsSync(webIndexPath)) {
|
|
2362
|
+
app.get("*", serveWeb);
|
|
2363
|
+
} else {
|
|
2364
|
+
app.get(
|
|
2365
|
+
"/",
|
|
2366
|
+
(c) => c.text(
|
|
2367
|
+
"pi-pilot server is running, but the web UI assets were not found. Run `pnpm build` from the repository root, or set PI_PILOT_WEB_ROOT to a built web dist directory.",
|
|
2368
|
+
500
|
|
2369
|
+
)
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
var server = serve(
|
|
2373
|
+
{
|
|
2374
|
+
fetch: app.fetch,
|
|
2375
|
+
hostname: config.host,
|
|
2376
|
+
port: config.port
|
|
2377
|
+
},
|
|
2378
|
+
(info) => {
|
|
2379
|
+
console.log(`[pi-pilot] http://${info.address}:${info.port}`);
|
|
2380
|
+
}
|
|
2381
|
+
);
|
|
2382
|
+
attachWsHub(server);
|
|
2383
|
+
async function shutdown(reason) {
|
|
2384
|
+
console.log(`[pi-pilot] shutting down (${reason})`);
|
|
2385
|
+
try {
|
|
2386
|
+
await workspaceManager.disposeAll();
|
|
2387
|
+
} catch (e) {
|
|
2388
|
+
console.error("[pi-pilot] disposeAll error:", e);
|
|
2389
|
+
}
|
|
2390
|
+
server.close(() => process.exit(0));
|
|
2391
|
+
setTimeout(() => process.exit(1), 3e3).unref();
|
|
2392
|
+
}
|
|
2393
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2394
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2395
|
+
//# sourceMappingURL=index.js.map
|