pi-feats 0.1.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/LICENSE +21 -0
- package/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAgentSession,
|
|
3
|
+
DefaultResourceLoader,
|
|
4
|
+
ModelRuntime,
|
|
5
|
+
SessionManager,
|
|
6
|
+
SettingsManager,
|
|
7
|
+
type AgentSession,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
10
|
+
import websocket from "@fastify/websocket";
|
|
11
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
|
|
14
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { ProfileStore, RESOURCE_KINDS, type ProfileSettings, type ResourceKind } from "./profile-store.ts";
|
|
18
|
+
import { ApplicationRuntime, type Settings as ApplicationSettings } from "./application-runtime.ts";
|
|
19
|
+
import { ApplicationStore, type ApplicationRecord } from "./application-store.ts";
|
|
20
|
+
import { applicationHandlerTemplate } from "../lib/application-handler-templates.ts";
|
|
21
|
+
import { isSandboxEnabled } from "../lib/profile-sandbox.ts";
|
|
22
|
+
import { handlerEnvironment, HOST_SSH_CREDENTIAL_KEYS, parseProfileEnv, profileEnvironment } from "../lib/profile-env.ts";
|
|
23
|
+
import { PulseStore } from "../pulse/store.ts";
|
|
24
|
+
import { SkillSourceStore } from "../skill-sources/store.ts";
|
|
25
|
+
|
|
26
|
+
type ApiConfig = {
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
apiToken: string;
|
|
30
|
+
cors: { origin: string };
|
|
31
|
+
publicBaseUrl?: string;
|
|
32
|
+
};
|
|
33
|
+
type SessionHandle = { session: AgentSession; busy: boolean; profile: string; sessionId: string };
|
|
34
|
+
type ServerOptions = { agentDir: string; cwd: string };
|
|
35
|
+
type ChatBody = { message: string };
|
|
36
|
+
type ChatParams = { profile?: string; sessionId: string };
|
|
37
|
+
|
|
38
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
39
|
+
let serverPromise: Promise<FastifyInstance> | undefined;
|
|
40
|
+
// Some third-party extensions read PI_CODING_AGENT_DIR while loading.
|
|
41
|
+
// Only bootstrap is serialized so each runtime captures its own profile.
|
|
42
|
+
let profileBootstrap = Promise.resolve();
|
|
43
|
+
|
|
44
|
+
async function loadConfig(agentDir: string): Promise<ApiConfig> {
|
|
45
|
+
const api = JSON.parse(await readFile(join(agentDir, "api-server.json"), "utf8")) as Partial<ApiConfig>;
|
|
46
|
+
if (!api.apiToken || typeof api.apiToken !== "string") throw new Error("apiToken is missing from api-server.json");
|
|
47
|
+
return {
|
|
48
|
+
host: typeof api.host === "string" && api.host ? api.host : "0.0.0.0",
|
|
49
|
+
port: typeof api.port === "number" ? api.port : 8767,
|
|
50
|
+
apiToken: api.apiToken,
|
|
51
|
+
cors: { origin: typeof process.env.PI_API_CORS_ORIGIN === "string" && process.env.PI_API_CORS_ORIGIN ? process.env.PI_API_CORS_ORIGIN : api.cors?.origin ?? "*" },
|
|
52
|
+
publicBaseUrl: typeof process.env.PI_PUBLIC_BASE_URL === "string" && process.env.PI_PUBLIC_BASE_URL ? process.env.PI_PUBLIC_BASE_URL : typeof api.publicBaseUrl === "string" && api.publicBaseUrl ? api.publicBaseUrl : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function apiError(reply: FastifyReply, status: number, code: string, message: string): FastifyReply {
|
|
57
|
+
return reply.code(status).send({ error: { code, message } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function authorized(request: FastifyRequest, token: string): boolean {
|
|
61
|
+
const value = request.headers.authorization;
|
|
62
|
+
if (!value?.startsWith("Bearer ")) return false;
|
|
63
|
+
const supplied = Buffer.from(value.slice(7));
|
|
64
|
+
const expected = Buffer.from(token);
|
|
65
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function chatBody(input: unknown): ChatBody {
|
|
69
|
+
if (!input || typeof input !== "object" || typeof (input as { message?: unknown }).message !== "string" || !(input as ChatBody).message.trim()) {
|
|
70
|
+
throw Object.assign(new Error("The 'message' field must be a non-empty string."), { status: 400 });
|
|
71
|
+
}
|
|
72
|
+
return { message: (input as ChatBody).message };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function objectBody(input: unknown): Record<string, unknown> {
|
|
76
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw Object.assign(new Error("The request body must be a JSON object."), { status: 400 });
|
|
77
|
+
return input as Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resourceKind(value: string): ResourceKind {
|
|
81
|
+
if (!(RESOURCE_KINDS as readonly string[]).includes(value)) throw Object.assign(new Error("Unknown resource type."), { status: 404 });
|
|
82
|
+
return value as ResourceKind;
|
|
83
|
+
}
|
|
84
|
+
function skillSourceIdentifier(value: string): string { return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").replace(/^-+|-+$/g, "").slice(0, 64).replace(/-+$/g, ""); }
|
|
85
|
+
|
|
86
|
+
function textFromMessage(message: unknown): string {
|
|
87
|
+
const content = (message as { content?: unknown })?.content;
|
|
88
|
+
if (typeof content === "string") return content;
|
|
89
|
+
if (!Array.isArray(content)) return "";
|
|
90
|
+
return content
|
|
91
|
+
.filter((part): part is { type?: unknown; text?: unknown } => !!part && typeof part === "object")
|
|
92
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
93
|
+
.map((part) => part.text as string)
|
|
94
|
+
.join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class ApiServer {
|
|
98
|
+
private readonly sessions = new Map<string, SessionHandle>();
|
|
99
|
+
private readonly sandboxBusy = new Set<string>();
|
|
100
|
+
private readonly runtimes = new Map<string, Promise<ModelRuntime>>();
|
|
101
|
+
readonly profiles: ProfileStore;
|
|
102
|
+
readonly pulses: PulseStore;
|
|
103
|
+
readonly applications: ApplicationStore;
|
|
104
|
+
readonly skillSources: SkillSourceStore;
|
|
105
|
+
|
|
106
|
+
constructor(private readonly options: ServerOptions, private readonly config: ApiConfig) {
|
|
107
|
+
this.profiles = new ProfileStore(options.agentDir);
|
|
108
|
+
this.pulses = new PulseStore(join(options.agentDir, "pulse.db"));
|
|
109
|
+
this.applications = new ApplicationStore(join(options.agentDir, "applications.db"));
|
|
110
|
+
this.skillSources = new SkillSourceStore(options.agentDir);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private profileDirectory(profile: string): string {
|
|
114
|
+
return this.profiles.directory(profile);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async agentSettings(): Promise<Record<string, unknown>> {
|
|
118
|
+
try { const value = JSON.parse(await readFile(join(this.options.agentDir, "settings.json"), "utf8")); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("settings.json must contain a JSON object."); return value as Record<string, unknown>; }
|
|
119
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async withProfileEnvironment<T>(agentDir: string, operation: () => Promise<T>, injected: NodeJS.ProcessEnv = {}): Promise<T> {
|
|
123
|
+
const previous = profileBootstrap;
|
|
124
|
+
let release!: () => void;
|
|
125
|
+
profileBootstrap = new Promise<void>((resolve) => { release = resolve; });
|
|
126
|
+
await previous;
|
|
127
|
+
const old = { ...process.env };
|
|
128
|
+
try {
|
|
129
|
+
const environment = await profileEnvironment(agentDir, old);
|
|
130
|
+
Object.assign(environment, injected);
|
|
131
|
+
environment.PI_CODING_AGENT_DIR = agentDir;
|
|
132
|
+
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
133
|
+
Object.assign(process.env, environment);
|
|
134
|
+
return await operation();
|
|
135
|
+
} finally {
|
|
136
|
+
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
137
|
+
Object.assign(process.env, old);
|
|
138
|
+
release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> {
|
|
143
|
+
await this.profiles.readSettings(profile);
|
|
144
|
+
return handlerEnvironment(this.profileDirectory(profile));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getIdentityMapping(application: string, identityKey: string) { return this.applications.identityMapping(application, identityKey); }
|
|
148
|
+
async automaticApplicationSessionPrefix(application: string, identityKey: string) { return this.applications.automaticPrefix(application, identityKey); }
|
|
149
|
+
async getApplicationSession(application: string, profile: string, prefix: string) { return this.applications.session(application, profile, prefix); }
|
|
150
|
+
async getActiveApplicationSession(application: string, profile: string, prefix: string) { return this.applications.activeSession(application, profile, prefix); }
|
|
151
|
+
async nextApplicationSessionId(application: string, profile: string, prefix: string) { return this.applications.nextSessionId(application, profile, prefix); }
|
|
152
|
+
async recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { this.applications.createSession(application, profile, prefix, sessionId, rollover); }
|
|
153
|
+
async touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { this.applications.touchSession(application, profile, prefix, sessionId, identityKey); }
|
|
154
|
+
async getApplicationWorkingSessions(profile: string, application: string) { return this.profiles.readApplicationWorkingSessions(profile, application); }
|
|
155
|
+
async setApplicationWorkingSession(profile: string, application: string, key: string, session: { sessionId: string; updatedAt: string; reason: "message" | "rollover" }): Promise<void> { await this.profiles.setApplicationWorkingSession(profile, application, key, session); }
|
|
156
|
+
async retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void> { this.pulses.retarget(profile, fromSessionId, toSessionId); }
|
|
157
|
+
|
|
158
|
+
private async getSession(profile: string, id: string): Promise<SessionHandle> {
|
|
159
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) throw Object.assign(new Error("Invalid sessionId"), { status: 400 });
|
|
160
|
+
const key = `${profile}:${id}`;
|
|
161
|
+
const current = this.sessions.get(key);
|
|
162
|
+
if (current) return current;
|
|
163
|
+
|
|
164
|
+
const agentDir = this.profileDirectory(profile);
|
|
165
|
+
await this.profiles.readSettings(profile);
|
|
166
|
+
const sessionDir = profile === "default" ? undefined : join(agentDir, "sessions");
|
|
167
|
+
const known = await this.listProfileSessions(profile);
|
|
168
|
+
const existing = known.find((session) => session.id === id);
|
|
169
|
+
const sessionManager = existing
|
|
170
|
+
? SessionManager.open(existing.path, sessionDir, this.options.cwd)
|
|
171
|
+
: SessionManager.create(this.options.cwd, sessionDir, { id });
|
|
172
|
+
const session = await this.withProfileEnvironment(agentDir, async () => {
|
|
173
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
|
|
174
|
+
const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
|
|
175
|
+
await loader.reload();
|
|
176
|
+
let runtime = this.runtimes.get(agentDir);
|
|
177
|
+
if (!runtime) {
|
|
178
|
+
runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
179
|
+
this.runtimes.set(agentDir, runtime);
|
|
180
|
+
}
|
|
181
|
+
return (await createAgentSession({ cwd: this.options.cwd, agentDir, sessionManager, settingsManager, resourceLoader: loader, modelRuntime: await runtime })).session;
|
|
182
|
+
});
|
|
183
|
+
const handle = { session, busy: false, profile, sessionId: id };
|
|
184
|
+
this.sessions.set(key, handle);
|
|
185
|
+
return handle;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private sessionDirectory(profile: string): string | undefined {
|
|
189
|
+
return profile === "default" ? undefined : join(this.profileDirectory(profile), "sessions");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Profile session directories contain conversations from several working
|
|
193
|
+
// directories. The Console is profile-scoped, not cwd-scoped.
|
|
194
|
+
private async listProfileSessions(profile: string) {
|
|
195
|
+
const sessionDir = this.sessionDirectory(profile);
|
|
196
|
+
return SessionManager.listAll(sessionDir);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
|
|
200
|
+
await this.profiles.readSettings(profile);
|
|
201
|
+
const sessions = await this.listProfileSessions(profile);
|
|
202
|
+
return sessions.map((session) => ({
|
|
203
|
+
id: session.id,
|
|
204
|
+
name: session.name,
|
|
205
|
+
cwd: session.cwd,
|
|
206
|
+
createdAt: session.created.toISOString(),
|
|
207
|
+
updatedAt: session.modified.toISOString(),
|
|
208
|
+
messageCount: session.messageCount,
|
|
209
|
+
preview: session.firstMessage,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async createSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
|
|
214
|
+
await this.profiles.readSettings(profile);
|
|
215
|
+
const body = objectBody(input);
|
|
216
|
+
if (body.name !== undefined && typeof body.name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
|
|
217
|
+
if (body.id !== undefined && (typeof body.id !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(body.id))) throw Object.assign(new Error("The 'id' field is invalid."), { status: 400 });
|
|
218
|
+
const id = typeof body.id === "string" ? body.id : randomUUID();
|
|
219
|
+
if ((await this.listProfileSessions(profile)).some((item) => item.id === id)) throw Object.assign(new Error("Session already exists."), { status: 409 });
|
|
220
|
+
// A profile session must use its profile directory as CWD. Pi resolves an
|
|
221
|
+
// ID relative to CWD, while API workers themselves run from `/` in Docker.
|
|
222
|
+
const session = SessionManager.create(this.profileDirectory(profile), this.sessionDirectory(profile), { id });
|
|
223
|
+
if (typeof body.name === "string" && body.name.trim()) session.appendSessionInfo(body.name.trim());
|
|
224
|
+
const file = session.getSessionFile();
|
|
225
|
+
const header = session.getHeader();
|
|
226
|
+
if (!file || !header) throw new Error("Unable to create a persistent session.");
|
|
227
|
+
// Pi normally defers writing until an assistant response. This endpoint explicitly
|
|
228
|
+
// registers an empty session by atomically writing its native session entries.
|
|
229
|
+
await writeFile(file, [...[header], ...session.getEntries()].map((entry) => JSON.stringify(entry)).join("\n") + "\n", { flag: "wx" });
|
|
230
|
+
return { id: session.getSessionId(), name: session.getSessionName() ?? null, createdAt: header.timestamp, updatedAt: header.timestamp, messageCount: 0, preview: "" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async renameSession(profile: string, id: string, name: string): Promise<Record<string, unknown>> {
|
|
234
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) throw Object.assign(new Error("Invalid sessionId"), { status: 400 });
|
|
235
|
+
const trimmed = name.trim();
|
|
236
|
+
if (!trimmed || trimmed.length > 200) throw Object.assign(new Error("Session name must contain 1 to 200 characters."), { status: 400 });
|
|
237
|
+
const key = `${profile}:${id}`, active = this.sessions.get(key);
|
|
238
|
+
if (active) active.session.setSessionName(trimmed);
|
|
239
|
+
else {
|
|
240
|
+
const existing = (await this.listProfileSessions(profile)).find((session) => session.id === id);
|
|
241
|
+
if (!existing) throw Object.assign(new Error("Session not found"), { status: 404 });
|
|
242
|
+
SessionManager.open(existing.path, this.sessionDirectory(profile), this.options.cwd).appendSessionInfo(trimmed);
|
|
243
|
+
}
|
|
244
|
+
return { id, name: trimmed };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async deleteSession(profile: string, id: string): Promise<void> {
|
|
248
|
+
await this.profiles.readSettings(profile);
|
|
249
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) throw Object.assign(new Error("Invalid sessionId"), { status: 400 });
|
|
250
|
+
const key = `${profile}:${id}`;
|
|
251
|
+
const active = this.sessions.get(key);
|
|
252
|
+
if (active?.busy) throw Object.assign(new Error("The session is busy."), { status: 409 });
|
|
253
|
+
const sessions = await this.listProfileSessions(profile);
|
|
254
|
+
const existing = sessions.find((session) => session.id === id);
|
|
255
|
+
if (!existing) throw Object.assign(new Error("Session not found"), { status: 404 });
|
|
256
|
+
active?.session.dispose();
|
|
257
|
+
this.sessions.delete(key);
|
|
258
|
+
await rm(existing.path);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async getSessionConversation(profile: string, id: string, before?: string, requestedLimit = 16): Promise<Record<string, unknown>> {
|
|
262
|
+
const sessions = await this.listProfileSessions(profile);
|
|
263
|
+
const info = sessions.find((session) => session.id === id);
|
|
264
|
+
if (!info) throw Object.assign(new Error("Session not found"), { status: 404 });
|
|
265
|
+
const session = SessionManager.open(info.path, this.sessionDirectory(profile), this.options.cwd);
|
|
266
|
+
const entries = session.getBranch().filter((entry) => entry.type !== "session_info").map((entry) => {
|
|
267
|
+
if (entry.type === "message") {
|
|
268
|
+
const message = entry.message as { role?: string; content?: unknown; toolName?: string; isError?: boolean; timestamp?: number };
|
|
269
|
+
const blocks = Array.isArray(message.content) ? message.content : typeof message.content === "string" ? [{ type: "text", text: message.content }] : [];
|
|
270
|
+
return {
|
|
271
|
+
id: entry.id, type: "message", timestamp: entry.timestamp, role: message.role,
|
|
272
|
+
content: blocks.filter((block: any) => block?.type === "text" || block?.type === "thinking").map((block: any) => ({ type: block.type, text: block.text ?? block.thinking ?? "" })),
|
|
273
|
+
tools: blocks.filter((block: any) => block?.type === "toolCall").map((block: any) => ({ name: block.name, arguments: block.arguments })),
|
|
274
|
+
toolName: message.toolName, isError: message.isError,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") return { id: entry.id, type: entry.type, timestamp: entry.timestamp, summary: (entry as any).summary };
|
|
278
|
+
return { id: entry.id, type: entry.type, timestamp: entry.timestamp };
|
|
279
|
+
});
|
|
280
|
+
const limit = Math.max(1, Math.min(16, Math.floor(requestedLimit) || 16));
|
|
281
|
+
const end = before ? entries.findIndex((entry) => entry.id === before) : entries.length;
|
|
282
|
+
if (before && end < 0) throw Object.assign(new Error("Conversation cursor not found."), { status: 400 });
|
|
283
|
+
const start = Math.max(0, end - limit), page = entries.slice(start, end);
|
|
284
|
+
return { session: { id: info.id, name: info.name, cwd: info.cwd, createdAt: info.created.toISOString(), updatedAt: info.modified.toISOString() }, entries: page, hasMore: start > 0, nextBefore: start > 0 ? page[0]?.id ?? null : null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async discoverSandboxedExtensionTools(profile: string): Promise<void> {
|
|
288
|
+
const agentDir = this.profileDirectory(profile);
|
|
289
|
+
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
|
|
290
|
+
const output = await new Promise<string>((resolveRun, reject) => {
|
|
291
|
+
delete environment.PI_API_WORKER;
|
|
292
|
+
const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", ""], {
|
|
293
|
+
cwd: agentDir,
|
|
294
|
+
env: environment,
|
|
295
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
296
|
+
});
|
|
297
|
+
let stdout = ""; let stderr = "";
|
|
298
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
299
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
300
|
+
child.once("error", reject);
|
|
301
|
+
child.once("exit", (code) => code === 0 ? resolveRun(stdout) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
|
|
302
|
+
});
|
|
303
|
+
const line = output.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
|
|
304
|
+
if (!line) throw new Error("Sandboxed tool discovery returned no tool list.");
|
|
305
|
+
const value = JSON.parse(line) as { tools?: unknown };
|
|
306
|
+
if (!Array.isArray(value.tools) || value.tools.some((name) => typeof name !== "string")) throw new Error("Sandboxed tool discovery returned an invalid tool list.");
|
|
307
|
+
this.profiles.registerExtensionTools(value.tools);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async complete(profile: string, prompt: string): Promise<string> {
|
|
311
|
+
const agentDir = this.profileDirectory(profile);
|
|
312
|
+
if (await this.sandboxed(profile)) {
|
|
313
|
+
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir };
|
|
314
|
+
delete environment.PI_API_WORKER;
|
|
315
|
+
return new Promise<string>((resolveRun, reject) => {
|
|
316
|
+
const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", prompt], { cwd: agentDir, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
317
|
+
let stdout = "", stderr = "";
|
|
318
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
319
|
+
child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Handoff completion exited with ${code ?? "unknown"}`)));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return this.withProfileEnvironment(agentDir, async () => {
|
|
323
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
|
|
324
|
+
const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager }); await loader.reload();
|
|
325
|
+
let runtime = this.runtimes.get(agentDir); if (!runtime) { runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") }); this.runtimes.set(agentDir, runtime); }
|
|
326
|
+
const { session } = await createAgentSession({ cwd: this.options.cwd, agentDir, sessionManager: SessionManager.inMemory(this.options.cwd), settingsManager, resourceLoader: loader, modelRuntime: await runtime });
|
|
327
|
+
try { await session.prompt(prompt, { source: "rpc" }); return textFromMessage([...session.messages].reverse().find((item) => item.role === "assistant")); } finally { session.dispose(); }
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async discoverExtensionTools(profile: string): Promise<void> {
|
|
332
|
+
const agentDir = this.profileDirectory(profile);
|
|
333
|
+
await this.profiles.readSettings(profile);
|
|
334
|
+
if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
|
|
335
|
+
await this.withProfileEnvironment(agentDir, async () => {
|
|
336
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
|
|
337
|
+
const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
|
|
338
|
+
await loader.reload();
|
|
339
|
+
let runtime = this.runtimes.get(agentDir);
|
|
340
|
+
if (!runtime) {
|
|
341
|
+
runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
342
|
+
this.runtimes.set(agentDir, runtime);
|
|
343
|
+
}
|
|
344
|
+
const { session } = await createAgentSession({
|
|
345
|
+
cwd: this.options.cwd,
|
|
346
|
+
agentDir,
|
|
347
|
+
sessionManager: SessionManager.inMemory(this.options.cwd),
|
|
348
|
+
settingsManager,
|
|
349
|
+
resourceLoader: loader,
|
|
350
|
+
modelRuntime: await runtime,
|
|
351
|
+
});
|
|
352
|
+
try {
|
|
353
|
+
this.profiles.registerExtensionTools(session.agent.state.tools.map((tool) => tool.name));
|
|
354
|
+
} finally {
|
|
355
|
+
session.dispose();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async sandboxed(profile: string): Promise<boolean> {
|
|
361
|
+
return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string): Promise<string> {
|
|
365
|
+
const key = `${profile}:${id}`;
|
|
366
|
+
if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
367
|
+
this.sandboxBusy.add(key);
|
|
368
|
+
try {
|
|
369
|
+
const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
|
|
370
|
+
if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
|
|
371
|
+
const workerEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}) };
|
|
372
|
+
// The HTTP gateway is an API worker itself. Its children are agent
|
|
373
|
+
// runtimes, not additional HTTP servers.
|
|
374
|
+
delete workerEnv.PI_API_WORKER;
|
|
375
|
+
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolveRun, reject) => {
|
|
376
|
+
// Supply the native file path, not only its ID. The session manager
|
|
377
|
+
// otherwise scopes an ID lookup to the API worker CWD (`/` in Docker).
|
|
378
|
+
const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--session", session.path, "--print", message], {
|
|
379
|
+
// The sandboxed agent sees its profile directory as CWD, never the
|
|
380
|
+
// HTTP server's CWD or the caller's project directory.
|
|
381
|
+
cwd: this.profileDirectory(profile),
|
|
382
|
+
env: workerEnv,
|
|
383
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
384
|
+
});
|
|
385
|
+
let stdout = ""; let stderr = "";
|
|
386
|
+
child.stdout.on("data", (chunk) => { const text = String(chunk); stdout += text; onData?.(text); });
|
|
387
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
388
|
+
child.once("error", reject);
|
|
389
|
+
child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
|
|
390
|
+
});
|
|
391
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || `Sandboxed Pi exited with ${result.code}`);
|
|
392
|
+
return result.stdout.trim();
|
|
393
|
+
} finally { this.sandboxBusy.delete(key); }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async prompt(profile: string, id: string, message: string, handoff?: string): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
397
|
+
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff) };
|
|
398
|
+
const handle = await this.getSession(profile, id);
|
|
399
|
+
if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
400
|
+
handle.busy = true;
|
|
401
|
+
try {
|
|
402
|
+
await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
|
|
403
|
+
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
404
|
+
return { profile, sessionId: id, response: textFromMessage(assistant) };
|
|
405
|
+
} finally { handle.busy = false; }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private async chat(profile: string, id: string, message: string, reply: FastifyReply, stream: boolean): Promise<void> {
|
|
409
|
+
if (await this.sandboxed(profile)) {
|
|
410
|
+
if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message) }); return; }
|
|
411
|
+
reply.hijack();
|
|
412
|
+
reply.raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
|
|
413
|
+
reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
414
|
+
try {
|
|
415
|
+
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`));
|
|
416
|
+
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
419
|
+
reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: message })}\n\n`);
|
|
420
|
+
}
|
|
421
|
+
reply.raw.end(); return;
|
|
422
|
+
}
|
|
423
|
+
const handle = await this.getSession(profile, id);
|
|
424
|
+
if (handle.busy || !handle.session.isIdle) {
|
|
425
|
+
apiError(reply, 409, "SESSION_BUSY", "The session is already processing a request.");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
handle.busy = true;
|
|
429
|
+
let unsubscribe = () => {};
|
|
430
|
+
let closed = false;
|
|
431
|
+
try {
|
|
432
|
+
if (stream) {
|
|
433
|
+
reply.hijack();
|
|
434
|
+
const response = reply.raw;
|
|
435
|
+
response.writeHead(200, {
|
|
436
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
437
|
+
"cache-control": "no-cache, no-transform",
|
|
438
|
+
connection: "keep-alive",
|
|
439
|
+
"x-accel-buffering": "no",
|
|
440
|
+
});
|
|
441
|
+
response.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
442
|
+
unsubscribe = handle.session.subscribe((event) => {
|
|
443
|
+
if (!closed && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
444
|
+
response.write(`event: token\ndata: ${JSON.stringify({ text: event.assistantMessageEvent.delta })}\n\n`);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
response.once("close", () => { closed = true; void handle.session.abort(); });
|
|
448
|
+
}
|
|
449
|
+
await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
|
|
450
|
+
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
451
|
+
const response = textFromMessage(assistant);
|
|
452
|
+
if (stream) {
|
|
453
|
+
if (!closed) {
|
|
454
|
+
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
455
|
+
reply.raw.end();
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
reply.send({ profile, sessionId: id, response });
|
|
459
|
+
}
|
|
460
|
+
} finally {
|
|
461
|
+
unsubscribe();
|
|
462
|
+
handle.busy = false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async handleChat(request: FastifyRequest<{ Params: ChatParams }>, reply: FastifyReply, stream: boolean): Promise<void> {
|
|
467
|
+
if (!authorized(request, this.config.apiToken)) {
|
|
468
|
+
apiError(reply, 401, "UNAUTHORIZED", "Bearer token is missing or invalid.");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const input = chatBody(request.body);
|
|
473
|
+
await this.chat(request.params.profile ?? "default", request.params.sessionId, input.message, reply, stream);
|
|
474
|
+
} catch (cause) {
|
|
475
|
+
const exception = cause as Error & { status?: number };
|
|
476
|
+
if (!reply.sent) apiError(reply, exception.status ?? 500, exception.status ? "BAD_REQUEST" : "INTERNAL_ERROR", exception.status ? exception.message : "Internal API error.");
|
|
477
|
+
else if (!reply.raw.writableEnded) reply.raw.end();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function startApiServer(options: ServerOptions): Promise<FastifyInstance> {
|
|
483
|
+
if (serverPromise) return serverPromise;
|
|
484
|
+
serverPromise = (async () => {
|
|
485
|
+
const config = await loadConfig(options.agentDir);
|
|
486
|
+
const api = new ApiServer(options, config);
|
|
487
|
+
const server = Fastify({ bodyLimit: MAX_BODY_BYTES, logger: false });
|
|
488
|
+
await server.register(websocket);
|
|
489
|
+
const applicationLogTickets = new Map<string, { slug: string; expiresAt: number }>();
|
|
490
|
+
const terminalTickets = new Map<string, { expiresAt: number }>();
|
|
491
|
+
const terminalEnabled = process.env.PI_CONSOLE_TERMINAL_ENABLED !== "false";
|
|
492
|
+
const terminalCwd = process.env.PI_CONSOLE_TERMINAL_CWD ?? options.agentDir;
|
|
493
|
+
const terminalShell = process.env.PI_CONSOLE_TERMINAL_SHELL ?? "/bin/bash";
|
|
494
|
+
const terminalUrl = (ticket: string) => { const base = new URL(config.publicBaseUrl ?? `http://127.0.0.1:${config.port}`); base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; base.pathname = "/api/terminal/stream"; base.search = `ticket=${encodeURIComponent(ticket)}`; return base.toString(); };
|
|
495
|
+
|
|
496
|
+
server.addHook("onRequest", async (_request, reply) => {
|
|
497
|
+
reply.header("access-control-allow-origin", config.cors.origin);
|
|
498
|
+
reply.header("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
499
|
+
reply.header("access-control-allow-headers", "Authorization, Content-Type");
|
|
500
|
+
});
|
|
501
|
+
server.options("/*", async (_request, reply) => reply.code(204).send());
|
|
502
|
+
server.get("/api/health", async () => ({ ok: true, service: "pi-api", status: "running" }));
|
|
503
|
+
|
|
504
|
+
const chat = (url: string, stream = false) => {
|
|
505
|
+
server.post<{ Params: ChatParams }>(url, async (request, reply) => api.handleChat(request, reply, stream));
|
|
506
|
+
};
|
|
507
|
+
chat("/api/sessions/:sessionId/chat");
|
|
508
|
+
chat("/api/sessions/:sessionId/chat/stream", true);
|
|
509
|
+
chat("/profile/:profile/api/sessions/:sessionId/chat");
|
|
510
|
+
chat("/profile/:profile/api/sessions/:sessionId/chat/stream", true);
|
|
511
|
+
// Compatibility routes retained for existing API clients.
|
|
512
|
+
chat("/profile/:profile/sessions/:sessionId/chat");
|
|
513
|
+
chat("/profile/:profile/sessions/:sessionId/chat/stream", true);
|
|
514
|
+
|
|
515
|
+
const guard = (request: FastifyRequest, reply: FastifyReply): boolean => {
|
|
516
|
+
if (authorized(request, config.apiToken)) return true;
|
|
517
|
+
apiError(reply, 401, "UNAUTHORIZED", "Bearer token is missing or invalid.");
|
|
518
|
+
return false;
|
|
519
|
+
};
|
|
520
|
+
const profileName = (request: FastifyRequest<{ Params: { profile: string } }>) => request.params.profile;
|
|
521
|
+
const pulseTick = async (action: "start" | "stop" | "status") => new Promise<string>((resolveRun, reject) => {
|
|
522
|
+
const child = spawn(process.execPath, [process.argv[1], "pulse", action], { cwd: options.cwd, env: { ...process.env, PI_API_WORKER: "" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
523
|
+
let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
|
|
524
|
+
child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Pulse command exited with ${code}`)));
|
|
525
|
+
});
|
|
526
|
+
const pulseTickStatus = async () => {
|
|
527
|
+
try { const state = JSON.parse(await readFile(join(options.agentDir, "pulse-tick.state.json"), "utf8")) as { pid?: unknown; startedAt?: unknown }; const pid = typeof state.pid === "number" ? state.pid : 0; let running = false; try { process.kill(pid, 0); running = pid > 0; } catch {} return { running, status: running ? `Pulse tick running (PID ${pid}).` : "Pulse tick stopped." }; }
|
|
528
|
+
catch { return { running: false, status: "Pulse tick stopped." }; }
|
|
529
|
+
};
|
|
530
|
+
server.get("/api/pulse/tick/status", async (request, reply) => {
|
|
531
|
+
if (!guard(request, reply)) return;
|
|
532
|
+
return pulseTickStatus();
|
|
533
|
+
});
|
|
534
|
+
server.post<{ Body: { enabled?: unknown } }>("/api/pulse/tick", async (request, reply) => {
|
|
535
|
+
if (!guard(request, reply)) return;
|
|
536
|
+
if (typeof request.body?.enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
|
|
537
|
+
await pulseTick(request.body.enabled ? "start" : "stop"); return pulseTickStatus();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
server.get("/api/profiles", async (request, reply) => {
|
|
541
|
+
if (!guard(request, reply)) return;
|
|
542
|
+
return { profiles: await api.profiles.list() };
|
|
543
|
+
});
|
|
544
|
+
server.post("/api/profiles", async (request, reply) => {
|
|
545
|
+
if (!guard(request, reply)) return;
|
|
546
|
+
const name = objectBody(request.body).name;
|
|
547
|
+
if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
|
|
548
|
+
const profile = await api.profiles.create(name);
|
|
549
|
+
return reply.code(201).send(profile);
|
|
550
|
+
});
|
|
551
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
552
|
+
if (!guard(request, reply)) return;
|
|
553
|
+
const settings = await api.profiles.readSettings(profileName(request));
|
|
554
|
+
return { name: profileName(request), path: api.profiles.directory(profileName(request)), hasSoul: true, hasRefine: true, policy: settings.profile ?? {} };
|
|
555
|
+
});
|
|
556
|
+
server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
557
|
+
if (!guard(request, reply)) return;
|
|
558
|
+
if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
|
|
559
|
+
await api.profiles.delete(profileName(request));
|
|
560
|
+
return reply.code(204).send();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
|
|
564
|
+
if (!guard(request, reply)) return;
|
|
565
|
+
return { settings: await api.profiles.readSettings(profileName(request)) };
|
|
566
|
+
});
|
|
567
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
|
|
568
|
+
if (!guard(request, reply)) return;
|
|
569
|
+
const settings = objectBody(request.body) as ProfileSettings;
|
|
570
|
+
return { settings: await api.profiles.writeSettings(profileName(request), settings) };
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const documentRoute = (name: "soul" | "refine", file: "SOUL.md" | "REFINE.md") => {
|
|
574
|
+
server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
|
|
575
|
+
if (!guard(request, reply)) return;
|
|
576
|
+
return { content: await api.profiles.readDocument(profileName(request), file) };
|
|
577
|
+
});
|
|
578
|
+
server.put<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
|
|
579
|
+
if (!guard(request, reply)) return;
|
|
580
|
+
const content = objectBody(request.body).content;
|
|
581
|
+
if (typeof content !== "string") throw Object.assign(new Error("The 'content' field must be a string."), { status: 400 });
|
|
582
|
+
await api.profiles.writeDocument(profileName(request), file, content);
|
|
583
|
+
return { content };
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
documentRoute("soul", "SOUL.md");
|
|
587
|
+
documentRoute("refine", "REFINE.md");
|
|
588
|
+
|
|
589
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/env", async (request, reply) => {
|
|
590
|
+
if (!guard(request, reply)) return;
|
|
591
|
+
const path = join(api.profiles.directory(profileName(request)), ".env");
|
|
592
|
+
try {
|
|
593
|
+
return { content: await readFile(path, "utf8"), exists: true };
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { content: "", exists: false };
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/env", async (request, reply) => {
|
|
600
|
+
if (!guard(request, reply)) return;
|
|
601
|
+
const content = objectBody(request.body).content;
|
|
602
|
+
if (typeof content !== "string") throw Object.assign(new Error("The 'content' field must be a string."), { status: 400 });
|
|
603
|
+
if (Buffer.byteLength(content, "utf8") > MAX_BODY_BYTES) throw Object.assign(new Error("The .env file exceeds the 1 MiB limit."), { status: 413 });
|
|
604
|
+
const path = join(api.profiles.directory(profileName(request)), ".env");
|
|
605
|
+
let values: Record<string, string>;
|
|
606
|
+
try {
|
|
607
|
+
values = parseProfileEnv(content, path);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 });
|
|
610
|
+
}
|
|
611
|
+
const blocked = Object.keys(values).filter((key) => HOST_SSH_CREDENTIAL_KEYS.includes(key as typeof HOST_SSH_CREDENTIAL_KEYS[number]));
|
|
612
|
+
if (blocked.length) throw Object.assign(new Error(`The following SSH credential variables are not allowed: ${blocked.join(", ")}.`), { status: 400 });
|
|
613
|
+
const temporary = join(dirname(path), `.${randomUUID()}.env.tmp`);
|
|
614
|
+
try {
|
|
615
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
616
|
+
await rename(temporary, path);
|
|
617
|
+
} finally {
|
|
618
|
+
await rm(temporary, { force: true });
|
|
619
|
+
}
|
|
620
|
+
return { content, exists: true };
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/guardrails", async (request, reply) => {
|
|
624
|
+
if (!guard(request, reply)) return;
|
|
625
|
+
return { content: await api.profiles.readGuardrailsConfig(profileName(request)) };
|
|
626
|
+
});
|
|
627
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/guardrails", async (request, reply) => {
|
|
628
|
+
if (!guard(request, reply)) return;
|
|
629
|
+
const content = objectBody(request.body).content;
|
|
630
|
+
if (typeof content !== "string") throw Object.assign(new Error("The 'content' field must be a string."), { status: 400 });
|
|
631
|
+
try { await api.profiles.writeGuardrailsConfig(profileName(request), content); }
|
|
632
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
633
|
+
return { content };
|
|
634
|
+
});
|
|
635
|
+
server.get<{ Params: { profile: string; file: string } }>("/api/profiles/:profile/guardrails/:file/document", async (request, reply) => {
|
|
636
|
+
if (!guard(request, reply)) return;
|
|
637
|
+
return { content: await api.profiles.readGuardrailDocument(profileName(request), request.params.file) };
|
|
638
|
+
});
|
|
639
|
+
server.put<{ Params: { profile: string; file: string } }>("/api/profiles/:profile/guardrails/:file/document", async (request, reply) => {
|
|
640
|
+
if (!guard(request, reply)) return;
|
|
641
|
+
const content = objectBody(request.body).content;
|
|
642
|
+
if (typeof content !== "string") throw Object.assign(new Error("The 'content' field must be a string."), { status: 400 });
|
|
643
|
+
await api.profiles.writeGuardrailDocument(profileName(request), request.params.file, content);
|
|
644
|
+
return { content };
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
648
|
+
if (!guard(request, reply)) return;
|
|
649
|
+
return { sessions: await api.listSessions(profileName(request)) };
|
|
650
|
+
});
|
|
651
|
+
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
652
|
+
if (!guard(request, reply)) return;
|
|
653
|
+
return reply.code(201).send(await api.createSession(profileName(request), request.body));
|
|
654
|
+
});
|
|
655
|
+
server.get<{ Params: { profile: string; sessionId: string }; Querystring: { before?: string; limit?: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
|
|
656
|
+
if (!guard(request, reply)) return;
|
|
657
|
+
const limit = request.query.limit === undefined ? 16 : Number(request.query.limit);
|
|
658
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 16) throw Object.assign(new Error("The 'limit' query parameter must be an integer from 1 to 16."), { status: 400 });
|
|
659
|
+
return api.getSessionConversation(profileName(request), request.params.sessionId, request.query.before, limit);
|
|
660
|
+
});
|
|
661
|
+
server.patch<{ Params: { profile: string; sessionId: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
|
|
662
|
+
if (!guard(request, reply)) return;
|
|
663
|
+
const name = objectBody(request.body).name;
|
|
664
|
+
if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
|
|
665
|
+
return api.renameSession(profileName(request), request.params.sessionId, name);
|
|
666
|
+
});
|
|
667
|
+
server.delete<{ Params: { profile: string; sessionId: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
|
|
668
|
+
if (!guard(request, reply)) return;
|
|
669
|
+
await api.deleteSession(profileName(request), request.params.sessionId);
|
|
670
|
+
return reply.code(204).send();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses", async (request, reply) => {
|
|
674
|
+
if (!guard(request, reply)) return;
|
|
675
|
+
await api.profiles.readSettings(profileName(request));
|
|
676
|
+
return { pulses: api.pulses.list(profileName(request)) };
|
|
677
|
+
});
|
|
678
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
|
|
679
|
+
if (!guard(request, reply)) return;
|
|
680
|
+
await api.profiles.readSettings(profileName(request));
|
|
681
|
+
return { pulses: api.pulses.history(profileName(request)) };
|
|
682
|
+
});
|
|
683
|
+
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/pulses", async (request, reply) => {
|
|
684
|
+
if (!guard(request, reply)) return;
|
|
685
|
+
await api.profiles.readSettings(profileName(request));
|
|
686
|
+
const body = objectBody(request.body), fields = ["name", "description", "schedule", "prompt"] as const;
|
|
687
|
+
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
688
|
+
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
689
|
+
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
690
|
+
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, profile: profileName(request) }) }); }
|
|
691
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
692
|
+
});
|
|
693
|
+
server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
694
|
+
if (!guard(request, reply)) return;
|
|
695
|
+
const enabled = objectBody(request.body).enabled; if (typeof enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
|
|
696
|
+
try { return { pulse: api.pulses.setEnabled(request.params.name, enabled, profileName(request)) }; } catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
697
|
+
});
|
|
698
|
+
server.put<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
699
|
+
if (!guard(request, reply)) return;
|
|
700
|
+
const body = objectBody(request.body), fields = ["description", "schedule", "prompt"] as const;
|
|
701
|
+
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
702
|
+
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
703
|
+
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
704
|
+
try { return { pulse: api.pulses.update(request.params.name, profileName(request), { description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId }) }; }
|
|
705
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
706
|
+
});
|
|
707
|
+
server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
708
|
+
if (!guard(request, reply)) return;
|
|
709
|
+
try { api.pulses.delete(request.params.name, profileName(request)); return reply.code(204).send(); } catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
server.get("/api/skill-sources", async (request, reply) => { if (!guard(request, reply)) return; return { sources: api.skillSources.list() }; });
|
|
713
|
+
server.post("/api/skill-sources", async (request, reply) => { if (!guard(request, reply)) return; const body=objectBody(request.body); if (typeof body.name !== "string" || typeof body.repoUrl !== "string") throw Object.assign(new Error("name and repoUrl are required."),{status:400}); const identifier=typeof body.identifier === "string" && body.identifier ? body.identifier : skillSourceIdentifier(body.name); if (typeof body.originalIdentifier === "string" && body.originalIdentifier && body.originalIdentifier !== identifier) await api.skillSources.rename(body.originalIdentifier, identifier); return reply.code(201).send({ source: api.skillSources.save({ identifier,name:body.name,repoUrl:body.repoUrl,branch:typeof body.branch === "string" ? body.branch : undefined,basePath:typeof body.basePath === "string" ? body.basePath : undefined,username:typeof body.username === "string" ? body.username : undefined,accessToken:typeof body.accessToken === "string" && body.accessToken ? body.accessToken : undefined,enabled:body.enabled !== false }) }); });
|
|
714
|
+
server.patch<{ Params: { identifier: string } }>("/api/skill-sources/:identifier", async (request, reply) => { if (!guard(request, reply)) return; const current=api.skillSources.get(request.params.identifier); if (!current) throw Object.assign(new Error("Skill Source not found."),{status:404}); const body=objectBody(request.body); return { source:api.skillSources.save({ ...current, identifier:current.identifier, name:typeof body.name === "string" ? body.name : current.name, repoUrl:typeof body.repoUrl === "string" ? body.repoUrl : current.repoUrl, branch:typeof body.branch === "string" ? body.branch : current.branch, basePath:typeof body.basePath === "string" ? body.basePath : current.basePath, username:typeof body.username === "string" ? body.username : current.username ?? undefined,accessToken:typeof body.accessToken === "string" && body.accessToken ? body.accessToken : undefined, enabled:typeof body.enabled === "boolean" ? body.enabled : current.enabled }) }; });
|
|
715
|
+
server.delete<{ Params: { identifier: string } }>("/api/skill-sources/:identifier", async (request, reply) => { if (!guard(request, reply)) return; await api.skillSources.remove(request.params.identifier); return reply.code(204).send(); });
|
|
716
|
+
server.post<{ Params: { identifier: string } }>("/api/skill-sources/:identifier/sync", async (request, reply) => { if (!guard(request, reply)) return; return { sync:await api.skillSources.sync(request.params.identifier) }; });
|
|
717
|
+
server.get<{ Params: { identifier: string } }>("/api/skill-sources/:identifier/skills", async (request, reply) => { if (!guard(request, reply)) return; return { skills:api.skillSources.skills(request.params.identifier) }; });
|
|
718
|
+
server.get<{ Params: { identifier: string; name: string } }>("/api/skill-sources/:identifier/skills/:name/document", async (request, reply) => { if (!guard(request, reply)) return; return api.skillSources.document(request.params.identifier, request.params.name); });
|
|
719
|
+
server.post<{ Params: { profile: string; identifier: string; name: string } }>("/api/profiles/:profile/skills/from-source/:identifier/:name", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request); await api.profiles.readSettings(profile); const destination=api.profiles.skillImportDirectory(profile); const skill=await api.skillSources.install(request.params.identifier,request.params.name,destination,profile); await api.profiles.refreshSkills(profile); return reply.code(201).send({ skill, destination }); });
|
|
720
|
+
server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/publish", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), body=objectBody(request.body), source=skillSource(request.query.source); if (typeof body.identifier !== "string" || !body.identifier) throw Object.assign(new Error("A Skill Source is required."), { status: 400 }); const skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); if (source === "shared" && profile !== "default") throw Object.assign(new Error("Shared Skills can only be published by the default profile."), { status: 403 }); const publication=await api.skillSources.publish(body.identifier, profile, request.params.name, skill.path); await api.profiles.refreshSkills(profile); return reply.code(publication.action === "created" ? 201 : 200).send({ publication }); });
|
|
721
|
+
server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/sync", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), source=skillSource(request.query.source), skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); const synchronization=await api.skillSources.syncInstallation(profile, request.params.name, skill.path); await api.profiles.refreshSkills(profile); return { synchronization }; });
|
|
722
|
+
|
|
723
|
+
const skillSource = (value: unknown): "shared" | "profile" | undefined => {
|
|
724
|
+
if (value === undefined) return undefined;
|
|
725
|
+
if (value === "shared" || value === "profile") return value;
|
|
726
|
+
throw Object.assign(new Error("Invalid skill source."), { status: 400 });
|
|
727
|
+
};
|
|
728
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/skills/sources", async (request, reply) => {
|
|
729
|
+
if (!guard(request, reply)) return;
|
|
730
|
+
return { sources: await api.profiles.readSkillSources(profileName(request)) };
|
|
731
|
+
});
|
|
732
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/skills/sources", async (request, reply) => {
|
|
733
|
+
if (!guard(request, reply)) return;
|
|
734
|
+
const sources = objectBody(request.body);
|
|
735
|
+
return { sources: await api.profiles.writeSkillSources(profileName(request), { shared: sources.shared as boolean, profile: sources.profile as boolean }) };
|
|
736
|
+
});
|
|
737
|
+
server.get<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/document", async (request, reply) => {
|
|
738
|
+
if (!guard(request, reply)) return;
|
|
739
|
+
return api.profiles.readSkillDocument(profileName(request), request.params.name, skillSource(request.query.source));
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/packages", async (request, reply) => {
|
|
743
|
+
if (!guard(request, reply)) return;
|
|
744
|
+
return { packages: await api.profiles.packages(profileName(request)) };
|
|
745
|
+
});
|
|
746
|
+
server.patch<{ Params: { profile: string; name: string }; Body: { enabled?: unknown } }>("/api/profiles/:profile/packages/:name", async (request, reply) => {
|
|
747
|
+
if (!guard(request, reply)) return;
|
|
748
|
+
if (typeof request.body?.enabled !== "boolean") return reply.code(400).send({ error: "'enabled' must be a boolean." });
|
|
749
|
+
return { package: await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled) };
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
server.get<{ Params: { profile: string; kind: string } }>("/api/profiles/:profile/resources/:kind", async (request, reply) => {
|
|
753
|
+
if (!guard(request, reply)) return;
|
|
754
|
+
const kind = resourceKind(request.params.kind);
|
|
755
|
+
if (kind === "tools") await api.discoverExtensionTools(profileName(request)).catch(() => {});
|
|
756
|
+
return { resources: await api.profiles.resources(profileName(request), kind) };
|
|
757
|
+
});
|
|
758
|
+
server.get<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
759
|
+
if (!guard(request, reply)) return;
|
|
760
|
+
const kind = resourceKind(request.params.kind);
|
|
761
|
+
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
762
|
+
return api.profiles.resource(profileName(request), kind, request.params.name, kind === "skills" ? skillSource(request.query.source) : undefined);
|
|
763
|
+
});
|
|
764
|
+
server.patch<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
765
|
+
if (!guard(request, reply)) return;
|
|
766
|
+
const kind = resourceKind(request.params.kind);
|
|
767
|
+
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
768
|
+
const enabled = objectBody(request.body).enabled;
|
|
769
|
+
if (typeof enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
|
|
770
|
+
return api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
server.delete<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
774
|
+
if (!guard(request, reply)) return;
|
|
775
|
+
const kind = resourceKind(request.params.kind);
|
|
776
|
+
if (kind !== "skills") throw Object.assign(new Error("Only Skills can be deleted from a profile."), { status: 405 });
|
|
777
|
+
const profile = profileName(request);
|
|
778
|
+
await api.profiles.deleteSkill(profile, request.params.name, skillSource(request.query.source));
|
|
779
|
+
api.skillSources.forgetInstallation(profile, request.params.name);
|
|
780
|
+
return reply.code(204).send();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const applicationStore = api.applications;
|
|
784
|
+
const applicationsRoot = join(options.agentDir, "applications");
|
|
785
|
+
const applicationBySlug = new Map<string, { record: ApplicationRecord; runtime: ApplicationRuntime }>();
|
|
786
|
+
const applicationSettings = (record: ApplicationRecord): ApplicationSettings => ({ ...record.settings, responseMode: record.responseMode, defaultProfile: record.defaultProfile }) as ApplicationSettings;
|
|
787
|
+
const ensureApplicationFiles = async (slug: string) => { const handlers = join(applicationsRoot, slug, "handlers"); await mkdir(join(handlers, "transforms"), { recursive: true }); const inbound = join(handlers, "inbound.ts"); if (!existsSync(inbound)) await writeFile(inbound, applicationHandlerTemplate("inbound"), "utf8"); };
|
|
788
|
+
const loadApplication = async (record: ApplicationRecord) => { const directory = join(applicationsRoot, record.slug); await ensureApplicationFiles(record.slug); const runtime = await ApplicationRuntime.load(directory, api, applicationSettings(record), async (settings) => { const saved = applicationStore.update(record.slug, { settings, responseMode: settings.responseMode, defaultProfile: settings.defaultProfile ?? null }); applicationBySlug.set(saved.slug, { record: saved, runtime }); }, record); applicationBySlug.set(record.slug, { record, runtime }); };
|
|
789
|
+
for (const record of applicationStore.list()) await loadApplication(record);
|
|
790
|
+
const registeredApplication = (slug: string) => { const application = applicationBySlug.get(slug); if (!application) throw Object.assign(new Error("Application not found."), { status: 404 }); return application; };
|
|
791
|
+
const handlerFiles = async (directory: string, prefix = ""): Promise<string[]> => { const entries = await readdir(directory, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const path = join(directory, entry.name), name = `${prefix}${entry.name}`; if (entry.isDirectory()) files.push(...await handlerFiles(path, `${name}/`)); else if (entry.isFile() && entry.name.endsWith(".ts")) files.push(name); } return files; };
|
|
792
|
+
const applicationHandlerPath = (slug: string, file: unknown) => {
|
|
793
|
+
if (typeof file !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_./-]*\.ts$/.test(file) || file.includes("..")) throw Object.assign(new Error("Invalid handler path."), { status: 400 });
|
|
794
|
+
return join(applicationsRoot, slug, "handlers", file);
|
|
795
|
+
};
|
|
796
|
+
const handlerTestPayloads = (settings: Record<string, unknown>): Record<string, unknown> => {
|
|
797
|
+
const value = settings.handlerTestPayloads;
|
|
798
|
+
return value && typeof value === "object" && !Array.isArray(value) ? { ...value as Record<string, unknown> } : {};
|
|
799
|
+
};
|
|
800
|
+
const updateApplicationSettings = (application: { record: ApplicationRecord }, settings: Record<string, unknown>) => applicationStore.update(application.record.slug, {
|
|
801
|
+
name: application.record.name,
|
|
802
|
+
enabled: application.record.enabled,
|
|
803
|
+
responseMode: application.record.responseMode,
|
|
804
|
+
defaultProfile: application.record.defaultProfile,
|
|
805
|
+
routingPolicy: application.record.routingPolicy,
|
|
806
|
+
settings,
|
|
807
|
+
});
|
|
808
|
+
server.get("/api/applications", async (request, reply) => { if (!guard(request, reply)) return; return { applications: applicationStore.list().map((record) => ({ ...record, mappingCount: Object.keys((record.settings as any).mappings?.remoteJid ?? {}).length })) }; });
|
|
809
|
+
server.post("/api/applications", async (request, reply) => {
|
|
810
|
+
if (!guard(request, reply)) return; const body = objectBody(request.body);
|
|
811
|
+
if (typeof body.name !== "string" || typeof body.slug !== "string") throw Object.assign(new Error("name and slug are required."), { status: 400 });
|
|
812
|
+
const responseMode = body.responseMode === "result" ? "result" : "ack"; const defaults: ApplicationSettings = { responseMode, inboundHandler: "inbound", transformHandlers: [] }; const record = applicationStore.create({ name: body.name, slug: body.slug, enabled: body.enabled !== false, responseMode, defaultProfile: typeof body.defaultProfile === "string" && body.defaultProfile ? body.defaultProfile : null, routingPolicy: body.routingPolicy === "drop" ? "drop" : "default_as_fallback", settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : defaults });
|
|
813
|
+
await loadApplication(record); return reply.code(201).send({ application: record });
|
|
814
|
+
});
|
|
815
|
+
server.get<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; return { application: registeredApplication(request.params.slug).record }; });
|
|
816
|
+
server.put<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => {
|
|
817
|
+
if (!guard(request, reply)) return; const current = registeredApplication(request.params.slug).record, body = objectBody(request.body);
|
|
818
|
+
const record = applicationStore.update(current.slug, { name: typeof body.name === "string" ? body.name : current.name, enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, responseMode: body.responseMode === "result" || body.responseMode === "ack" ? body.responseMode : current.responseMode, defaultProfile: typeof body.defaultProfile === "string" ? body.defaultProfile || null : current.defaultProfile, routingPolicy: body.routingPolicy === "drop" || body.routingPolicy === "default_as_fallback" ? body.routingPolicy : current.routingPolicy, settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : current.settings });
|
|
819
|
+
await loadApplication(record); return { application: record };
|
|
820
|
+
});
|
|
821
|
+
server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; applicationStore.delete(request.params.slug); applicationBySlug.delete(request.params.slug); await rm(join(applicationsRoot, request.params.slug), { recursive: true, force: true }); return reply.code(204).send(); });
|
|
822
|
+
server.get<{ Params: { slug: string } }>("/api/applications/:slug/identity-mappings", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); return { mappings: applicationStore.identityMappings(request.params.slug) }; });
|
|
823
|
+
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, automatic = identityKey === "*" || body.sessionMode === "automatic", prefix = automatic ? null : body.sessionPrefix; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || typeof body.profile !== "string" || !body.profile || (!automatic && typeof prefix !== "string")) throw Object.assign(new Error("profile and a fixed sessionPrefix are required."), { status: 400 }); if (typeof prefix === "string" && !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(prefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 400 }); await api.profiles.readSettings(body.profile); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) }; });
|
|
824
|
+
server.delete<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; try { applicationStore.deleteIdentityMapping(request.params.slug, request.params.identityKey); return reply.code(204).send(); } catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); } });
|
|
825
|
+
server.get<{ Querystring: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } }>("/api/application-sessions", async (request, reply) => { if (!guard(request, reply)) return; const filters = request.query; return { sessions: applicationStore.activeSessions(filters) }; });
|
|
826
|
+
server.delete<{ Params: { sessionId: string }; Querystring: { application?: string; profile?: string } }>("/api/application-sessions/:sessionId", async (request, reply) => { if (!guard(request, reply)) return; const { application, profile } = request.query; if (!application || !profile) throw Object.assign(new Error("application and profile are required."), { status: 400 }); applicationStore.removeActiveSession(application, profile, request.params.sessionId); return reply.code(204).send(); });
|
|
827
|
+
server.get<{ Params: { slug: string } }>("/api/applications/:slug/handlers", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); return { files: await handlerFiles(join(applicationsRoot, request.params.slug, "handlers")) }; });
|
|
828
|
+
server.get<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug); const path = applicationHandlerPath(request.params.slug, request.query.path), file = String(request.query.path); return { path: request.query.path, content: await readFile(path, "utf8"), testPayload: handlerTestPayloads(application.record.settings)[file] }; });
|
|
829
|
+
server.put<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const content = objectBody(request.body).content; if (typeof content !== "string") throw Object.assign(new Error("content must be a string."), { status: 400 }); const path = applicationHandlerPath(request.params.slug, request.query.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, "utf8"); return { ok: true }; });
|
|
830
|
+
server.patch<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), oldPath = applicationHandlerPath(request.params.slug, request.query.path), newFile = objectBody(request.body).newPath; const newPath = applicationHandlerPath(request.params.slug, newFile); if (dirname(oldPath) !== dirname(newPath)) throw Object.assign(new Error("Handlers must remain in their current folder."), { status: 400 }); if (existsSync(newPath)) throw Object.assign(new Error("A handler with this name already exists."), { status: 409 }); await rename(oldPath, newPath); const oldFile = String(request.query.path), oldName = oldFile.replace(/^transforms\//, "").replace(/\.ts$/, ""), newName = String(newFile).replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>, testPayloads = handlerTestPayloads(settings); if (oldFile in testPayloads) { testPayloads[String(newFile)] = testPayloads[oldFile]; delete testPayloads[oldFile]; } settings.handlerTestPayloads = testPayloads; if (settings.inboundHandler === oldName) settings.inboundHandler = newName; if (settings.outboundHandler === oldName) settings.outboundHandler = newName; settings.transformHandlers = settings.transformHandlers?.map((value) => value === oldName ? newName : value); const record = updateApplicationSettings(application, settings); await loadApplication(record); return { ok: true, path: newFile, settingsUpdated: true }; });
|
|
831
|
+
server.delete<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), path = applicationHandlerPath(request.params.slug, request.query.path), file = String(request.query.path), handlerName = file.replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>; if (settings.inboundHandler === handlerName || settings.outboundHandler === handlerName || settings.transformHandlers?.includes(handlerName)) throw Object.assign(new Error("This handler is active in Application Settings."), { status: 409 }); await rm(path); const testPayloads = handlerTestPayloads(settings); if (file in testPayloads) { delete testPayloads[file]; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); } return reply.code(204).send(); });
|
|
832
|
+
server.post<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler/test", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug); const body = objectBody(request.body); if (!body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) throw Object.assign(new Error("The 'payload' field must be a JSON object."), { status: 400 }); applicationHandlerPath(request.params.slug, request.query.path); const settings = structuredClone(application.record.settings) as Record<string, unknown>, testPayloads = handlerTestPayloads(settings); testPayloads[String(request.query.path)] = body.payload; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); return application.runtime.testHandler(applicationHandlerPath(request.params.slug, request.query.path), body.payload as Record<string, unknown>, typeof body.content === "string" ? body.content : undefined); });
|
|
833
|
+
server.get<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => { if (!guard(request, reply)) return; return { calls: registeredApplication(request.params.slug).runtime.logs.list() }; });
|
|
834
|
+
server.delete<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => { if (!guard(request, reply)) return; await registeredApplication(request.params.slug).runtime.logs.clear(); return reply.code(204).send(); });
|
|
835
|
+
server.post<{ Params: { slug: string } }>("/api/applications/:slug/logs/ticket", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), ticket = randomUUID(); applicationLogTickets.set(ticket, { slug: application.record.slug, expiresAt: Date.now() + 60_000 }); const base = new URL(config.publicBaseUrl ?? `http://127.0.0.1:${config.port}`); base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; base.pathname = `/api/applications/${encodeURIComponent(application.record.slug)}/logs/stream`; base.search = `ticket=${encodeURIComponent(ticket)}`; return { ticket, url: base.toString() }; });
|
|
836
|
+
server.get("/api/applications/:slug/logs/stream", { websocket: true }, (socket, request) => { const ticket = (request.query as { ticket?: string }).ticket; const value = ticket && applicationLogTickets.get(ticket); if (!value || value.slug !== (request.params as { slug: string }).slug || value.expiresAt < Date.now()) return socket.close(1008, "Invalid ticket"); applicationLogTickets.delete(ticket!); const unsubscribe = registeredApplication(value.slug).runtime.logs.on((event) => socket.send(JSON.stringify(event))); socket.on("close", unsubscribe); });
|
|
837
|
+
server.post("/api/terminal/ticket", async (request, reply) => { if (!guard(request, reply)) return; if (!terminalEnabled) throw Object.assign(new Error("Console terminal is disabled."), { status: 403 }); const ticket = randomUUID(); terminalTickets.set(ticket, { expiresAt: Date.now() + 30_000 }); return { url: terminalUrl(ticket), cwd: terminalCwd }; });
|
|
838
|
+
server.get("/api/terminal/stream", { websocket: true }, (socket, request) => { const ticket = (request.query as { ticket?: string }).ticket; const value = ticket && terminalTickets.get(ticket); if (!terminalEnabled || !value || value.expiresAt < Date.now()) return socket.close(1008, "Invalid ticket"); terminalTickets.delete(ticket!); let child: pty.IPty; try { child = pty.spawn(terminalShell, ["-l"], { name: "xterm-256color", cols: 80, rows: 24, cwd: terminalCwd, env: process.env }); } catch (error) { socket.send(JSON.stringify({ type: "error", data: (error as Error).message })); return socket.close(1011); } const send = (type: string, data: string) => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type, data })); }; child.onData((data) => send("output", data)); child.onExit(() => socket.close()); socket.on("message", (raw) => { try { const message = JSON.parse(raw.toString()) as { type?: string; data?: string; cols?: number; rows?: number }; if (message.type === "input" && typeof message.data === "string") child.write(message.data); if (message.type === "resize" && Number.isInteger(message.cols) && Number.isInteger(message.rows) && message.cols! > 0 && message.rows! > 0) child.resize(message.cols!, message.rows!); } catch { /* ignore malformed terminal messages */ } }); socket.on("close", () => child.kill()); });
|
|
839
|
+
server.post<{ Params: { slug: string } }>("/api/applications/:slug/session-rollover", async (request, reply) => { if (!guard(request, reply)) return; return reply.code(201).send(await registeredApplication(request.params.slug).runtime.rollover(request.body)); });
|
|
840
|
+
server.post<{ Params: { slug: string } }>("/api/message/app/:slug", async (request, reply) => {
|
|
841
|
+
const application = registeredApplication(request.params.slug); if (!application.record.enabled) return apiError(reply, 404, "NOT_FOUND", "Application is disabled.");
|
|
842
|
+
const headers = Object.fromEntries(Object.entries(request.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])); const query = request.query && typeof request.query === "object" ? request.query as Record<string, unknown> : {};
|
|
843
|
+
if (application.record.responseMode === "ack") { void application.runtime.process(request.body, headers, query).catch((error) => console.error(`[application:${application.record.slug}]`, error)); return reply.code(202).send({ ok: true, accepted: true }); }
|
|
844
|
+
return application.runtime.process(request.body, headers, query);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
server.setNotFoundHandler(async (request, reply) => {
|
|
848
|
+
if (!authorized(request, config.apiToken)) return apiError(reply, 401, "UNAUTHORIZED", "Bearer token is missing or invalid.");
|
|
849
|
+
return apiError(reply, 404, "NOT_FOUND", "Route not found.");
|
|
850
|
+
});
|
|
851
|
+
server.setErrorHandler(async (error, _request, reply) => {
|
|
852
|
+
if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return apiError(reply, 413, "BAD_REQUEST", "payload exceeds 1 MiB");
|
|
853
|
+
const typed = error as Error & { status?: number };
|
|
854
|
+
if (typed.status) return apiError(reply, typed.status, "BAD_REQUEST", typed.message);
|
|
855
|
+
console.error("[pi-api]", error);
|
|
856
|
+
return apiError(reply, 500, "INTERNAL_ERROR", "Internal API error.");
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
await server.listen({ port: config.port, host: config.host });
|
|
860
|
+
return server;
|
|
861
|
+
})();
|
|
862
|
+
try { return await serverPromise; } catch (error) { serverPromise = undefined; throw error; }
|
|
863
|
+
}
|