pi-feats 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +90 -9
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-log-store.ts +4 -2
  4. package/extensions/api-server/application-runtime.ts +79 -8
  5. package/extensions/api-server/application-store.ts +23 -10
  6. package/extensions/api-server/index.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +109 -39
  8. package/extensions/api-server/server.ts +220 -44
  9. package/extensions/cli-resources.ts +144 -113
  10. package/extensions/cli-tui-mode.ts +7 -0
  11. package/extensions/context-memory/index.ts +6 -1
  12. package/extensions/conversation-search/index.ts +42 -0
  13. package/extensions/guardrails/index.ts +2 -1
  14. package/extensions/lib/application-context.ts +2 -0
  15. package/extensions/lib/builtin-tools.ts +24 -0
  16. package/extensions/lib/cli-tui-mode.ts +72 -0
  17. package/extensions/lib/context-memory.ts +6 -1
  18. package/extensions/lib/conversation-search.ts +33 -0
  19. package/extensions/lib/package-resources.ts +58 -0
  20. package/extensions/lib/pi-command.ts +11 -0
  21. package/extensions/lib/profile-sandbox.ts +14 -3
  22. package/extensions/lib/remote-hosts.ts +7 -5
  23. package/extensions/lib/workflow-evaluator.ts +113 -0
  24. package/extensions/lib/workflow-harness.ts +409 -43
  25. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  26. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  27. package/extensions/pi-console-webui/app/globals.css +3 -0
  28. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  29. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  30. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  31. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  32. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  33. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  34. package/extensions/pi-console-webui/components/console-page.tsx +4 -0
  35. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  36. package/extensions/pi-console-webui/components/console.tsx +32 -11
  37. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  38. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  39. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  40. package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
  41. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  42. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  43. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  44. package/extensions/pi-console-webui/index.ts +2 -1
  45. package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
  46. package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
  47. package/extensions/pi-console-webui/next.config.js +1 -0
  48. package/extensions/profile-ssh.ts +274 -0
  49. package/extensions/profiles.ts +69 -25
  50. package/extensions/pulse/index.ts +148 -16
  51. package/extensions/pulse/store.ts +132 -18
  52. package/extensions/sequential-workflow.ts +121 -79
  53. package/extensions/skill-sources/store.ts +29 -10
  54. package/package.json +21 -9
  55. package/scripts/build-console-webui.mjs +7 -0
  56. package/scripts/console-webui-cache.mjs +79 -0
  57. package/scripts/ensure-console-build.mjs +43 -0
  58. package/skills/profile-ssh/SKILL.md +65 -0
  59. package/skills/sequential-workflow/SKILL.md +4 -3
  60. package/themes/legacy-dark.json +85 -0
@@ -22,6 +22,7 @@ import { applicationExecutionContext, type ContextMemoryExecutionLog } from "../
22
22
  import { contextMemoryCharacters, contextMemoryConfig, contextMemoryLimit, memoryPath, readMemory } from "../lib/context-memory.ts";
23
23
  import { isSandboxEnabled } from "../lib/profile-sandbox.ts";
24
24
  import { handlerEnvironment, HOST_SSH_CREDENTIAL_KEYS, parseProfileEnv, profileEnvironment } from "../lib/profile-env.ts";
25
+ import { piCommand } from "../lib/pi-command.ts";
25
26
  import { PulseStore } from "../pulse/store.ts";
26
27
  import { SkillSourceStore } from "../skill-sources/store.ts";
27
28
 
@@ -38,6 +39,7 @@ type ChatBody = { message: string };
38
39
  type ChatParams = { profile?: string; sessionId: string };
39
40
 
40
41
  const MAX_BODY_BYTES = 1_048_576;
42
+ const MAX_APPLICATION_BODY_BYTES = 200 * 1024 * 1024;
41
43
  let serverPromise: Promise<FastifyInstance> | undefined;
42
44
  // Some third-party extensions read PI_CODING_AGENT_DIR while loading.
43
45
  // Only bootstrap is serialized so each runtime captures its own profile.
@@ -79,12 +81,32 @@ function objectBody(input: unknown): Record<string, unknown> {
79
81
  return input as Record<string, unknown>;
80
82
  }
81
83
 
84
+ async function hasOpenRouterCredential(directory: string, environment: NodeJS.ProcessEnv): Promise<boolean> {
85
+ if (environment.OPENROUTER_API_KEY?.trim()) return true;
86
+ try {
87
+ const auth: unknown = JSON.parse(await readFile(join(directory, "auth.json"), "utf8"));
88
+ return !!auth && typeof auth === "object" && !Array.isArray(auth) && typeof (auth as { openrouter?: { key?: unknown } }).openrouter?.key === "string" && !!(auth as { openrouter: { key: string } }).openrouter.key.trim();
89
+ } catch { return false; }
90
+ }
91
+
82
92
  function resourceKind(value: string): ResourceKind {
83
93
  if (!(RESOURCE_KINDS as readonly string[]).includes(value)) throw Object.assign(new Error("Unknown resource type."), { status: 404 });
84
94
  return value as ResourceKind;
85
95
  }
86
96
  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, ""); }
87
97
 
98
+ function activityFromAgentEvent(event: Record<string, any>): Record<string, unknown> | undefined {
99
+ if (event.type === "tool_execution_start") return { id: String(event.toolCallId), kind: "tool", status: "started", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: `Running ${String(event.toolName)}…` };
100
+ if (event.type === "tool_execution_update") return { id: String(event.toolCallId), kind: "tool", status: "running", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: `Running ${String(event.toolName)}…` };
101
+ if (event.type === "tool_execution_end") return { id: String(event.toolCallId), kind: "tool", status: event.isError ? "failed" : "completed", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: event.isError ? `${String(event.toolName)} failed.` : `${String(event.toolName)} completed.` };
102
+ if (event.type === "agent_start") return { id: "agent", kind: "agent", status: "running", name: "Pi", summary: "Pi is working…" };
103
+ if (event.type === "agent_settled") return { id: "agent", kind: "agent", status: "completed", name: "Pi", summary: "Pi completed its work." };
104
+ const update = event.type === "message_update" ? event.assistantMessageEvent as Record<string, unknown> | undefined : undefined;
105
+ if (update?.type === "thinking_start" || update?.type === "thinking_delta") return { id: `thinking-${String(update.contentIndex ?? 0)}`, kind: "thinking", status: "running", name: "Thinking", summary: "Pi is thinking…" };
106
+ if (update?.type === "thinking_end") return { id: `thinking-${String(update.contentIndex ?? 0)}`, kind: "thinking", status: "completed", name: "Thinking", summary: "Pi finished thinking." };
107
+ return undefined;
108
+ }
109
+
88
110
  function textFromMessage(message: unknown): string {
89
111
  const content = (message as { content?: unknown })?.content;
90
112
  if (typeof content === "string") return content;
@@ -150,10 +172,10 @@ class ApiServer {
150
172
 
151
173
  async getIdentityMapping(application: string, identityKey: string) { return this.applications.identityMapping(application, identityKey); }
152
174
  async automaticApplicationSessionPrefix(application: string, identityKey: string) { return this.applications.automaticPrefix(application, identityKey); }
153
- async getApplicationSession(application: string, profile: string, prefix: string) { return this.applications.session(application, profile, prefix); }
175
+ async getApplicationSession(application: string, profile: string, prefix: string, sessionType: "stateful" | "stateless" = "stateful") { return this.applications.session(application, profile, prefix, sessionType); }
154
176
  async getActiveApplicationSession(application: string, profile: string, prefix: string) { return this.applications.activeSession(application, profile, prefix); }
155
177
  async nextApplicationSessionId(application: string, profile: string, prefix: string) { return this.applications.nextSessionId(application, profile, prefix); }
156
- async recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { this.applications.createSession(application, profile, prefix, sessionId, rollover); }
178
+ async recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean, sessionType: "stateful" | "stateless" = "stateful") { this.applications.createSession(application, profile, prefix, sessionId, rollover, sessionType); }
157
179
  async touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { this.applications.touchSession(application, profile, prefix, sessionId, identityKey); }
158
180
  async getApplicationWorkingSessions(profile: string, application: string) { return this.profiles.readApplicationWorkingSessions(profile, application); }
159
181
  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); }
@@ -200,18 +222,46 @@ class ApiServer {
200
222
  return SessionManager.listAll(sessionDir);
201
223
  }
202
224
 
203
- async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
225
+ private isPulseOnlySession(profile: string, session: { path: string; cwd: string }): boolean {
226
+ const entries = SessionManager.open(session.path, this.sessionDirectory(profile), session.cwd).getBranch() as Array<{ type?: string }>;
227
+ const relevant = entries.filter((entry) => entry.type !== "session_info");
228
+ return relevant.length > 0 && relevant.some((entry) => entry.type === "pulse") && relevant.every((entry) => entry.type === "pulse");
229
+ }
230
+
231
+ async deleteProfile(profile: string): Promise<void> {
232
+ if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
204
233
  await this.profiles.readSettings(profile);
234
+ const handles = [...this.sessions.values()].filter((handle) => handle.profile === profile);
235
+ if (handles.some((handle) => handle.busy) || [...this.sandboxBusy].some((key) => key.startsWith(`${profile}:`))) throw Object.assign(new Error("The profile has a session that is processing a request."), { status: 409 });
236
+ for (const handle of handles) { handle.session.dispose(); this.sessions.delete(`${profile}:${handle.sessionId}`); }
237
+ this.runtimes.delete(this.profileDirectory(profile));
238
+ this.toolDiscoveries.delete(profile);
239
+ this.profiles.clearExtensionTools(profile);
240
+ await this.profiles.delete(profile);
241
+ this.applications.deleteProfile(profile);
242
+ this.pulses.deleteProfile(profile);
243
+ }
244
+
245
+ async listSessions(profile: string, includePulseSessions = false): Promise<Array<Record<string, unknown>>> {
246
+ await this.profiles.readSettings(profile);
247
+ const pulseExecutions = this.pulses.executionSessions(profile);
205
248
  const sessions = await this.listProfileSessions(profile);
206
- return sessions.map((session) => ({
207
- id: session.id,
208
- name: session.name,
209
- cwd: session.cwd,
210
- createdAt: session.created.toISOString(),
211
- updatedAt: session.modified.toISOString(),
212
- messageCount: session.messageCount,
213
- preview: session.firstMessage,
214
- }));
249
+ return sessions.flatMap((session) => {
250
+ const execution = pulseExecutions.get(session.id), pulseOnly = !execution && this.isPulseOnlySession(profile, session);
251
+ if (!includePulseSessions && (execution || pulseOnly)) return [];
252
+ const pulse = execution ? { id: execution.pulseId, name: execution.pulseName, type: execution.pulseType, runId: execution.runId, status: execution.status, startedAt: execution.startedAt, finishedAt: execution.finishedAt, source: "execution" } : pulseOnly ? { source: "injected-result" } : undefined;
253
+ return [{
254
+ id: session.id,
255
+ name: session.name,
256
+ cwd: session.cwd,
257
+ createdAt: session.created.toISOString(),
258
+ updatedAt: session.modified.toISOString(),
259
+ messageCount: session.messageCount,
260
+ preview: session.firstMessage,
261
+ kind: execution ? "pulse-execution" : pulseOnly ? "pulse-only" : "conversation",
262
+ ...(pulse ? { pulse } : {}),
263
+ }];
264
+ });
215
265
  }
216
266
 
217
267
  async createSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
@@ -283,6 +333,8 @@ class ApiServer {
283
333
  const info = sessions.find((session) => session.id === id);
284
334
  if (!info) throw Object.assign(new Error("Session not found"), { status: 404 });
285
335
  const session = SessionManager.open(info.path, this.sessionDirectory(profile), this.options.cwd);
336
+ // System prompt entries and empty assistant messages are session internals,
337
+ // not conversation content. Neither must become an empty chat bubble.
286
338
  const entries = session.getBranch().filter((entry) => entry.type !== "session_info").map((entry) => {
287
339
  if (entry.type === "message") {
288
340
  const message = entry.message as { role?: string; content?: unknown; toolName?: string; isError?: boolean; timestamp?: number };
@@ -296,7 +348,7 @@ class ApiServer {
296
348
  }
297
349
  if (entry.type === "compaction" || entry.type === "branch_summary") return { id: entry.id, type: entry.type, timestamp: entry.timestamp, summary: (entry as any).summary };
298
350
  return { id: entry.id, type: entry.type, timestamp: entry.timestamp };
299
- });
351
+ }).filter((entry: any) => entry.type !== "message" || (entry.role !== "system" && (entry.role !== "assistant" || entry.content.some((block: { text?: unknown }) => typeof block.text === "string" && block.text.trim().length > 0) || entry.tools.length > 0 || !!entry.toolName)));
300
352
  const limit = Math.max(1, Math.min(16, Math.floor(requestedLimit) || 16));
301
353
  const end = before ? entries.findIndex((entry) => entry.id === before) : entries.length;
302
354
  if (before && end < 0) throw Object.assign(new Error("Conversation cursor not found."), { status: 400 });
@@ -309,7 +361,8 @@ class ApiServer {
309
361
  const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
310
362
  const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
311
363
  delete environment.PI_API_WORKER;
312
- const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", ""], {
364
+ const pi = piCommand();
365
+ const child = spawn(pi.command, [...pi.args, "profile", profile, "--no-session", "--print", ""], {
313
366
  cwd: agentDir,
314
367
  env: environment,
315
368
  stdio: ["ignore", "pipe", "pipe"],
@@ -335,7 +388,8 @@ class ApiServer {
335
388
  const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir };
336
389
  delete environment.PI_API_WORKER;
337
390
  return new Promise<string>((resolveRun, reject) => {
338
- const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", prompt], { cwd: agentDir, env: environment, stdio: ["ignore", "pipe", "pipe"] });
391
+ const pi = piCommand();
392
+ const child = spawn(pi.command, [...pi.args, "profile", profile, "--no-session", "--print", prompt], { cwd: agentDir, env: environment, stdio: ["ignore", "pipe", "pipe"] });
339
393
  let stdout = "", stderr = "";
340
394
  child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); });
341
395
  child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Handoff completion exited with ${code ?? "unknown"}`)));
@@ -350,6 +404,20 @@ class ApiServer {
350
404
  });
351
405
  }
352
406
 
407
+ async promptStateless(profile: string, id: string, history: Array<{ role: "user" | "assistant"; content: string }>, message: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }): Promise<{ profile: string; sessionId: string; response: string }> {
408
+ if (await this.sandboxed(profile)) throw Object.assign(new Error("Stateless sessions are not yet available for sandboxed profiles."), { status: 501 });
409
+ const agentDir = this.profileDirectory(profile); await this.profiles.readSettings(profile);
410
+ return this.withProfileEnvironment(agentDir, async () => {
411
+ const settingsManager = SettingsManager.create(this.options.cwd, agentDir); const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager }); await loader.reload();
412
+ 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); }
413
+ const manager = SessionManager.inMemory(this.options.cwd);
414
+ for (const item of history) { const timestamp = Date.now(); if (item.role === "user") manager.appendMessage({ role: "user", content: item.content, timestamp }); else manager.appendMessage({ role: "assistant", content: [{ type: "text", text: item.content }], api: "openai-completions", provider: "external-history", model: "external-history", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp } as any); }
415
+ const { session } = await createAgentSession({ cwd: this.options.cwd, agentDir, sessionManager: manager, settingsManager, resourceLoader: loader, modelRuntime: await runtime });
416
+ let response = ""; const unsubscribe = session.subscribe((event) => { if (event.type === "message_end" && event.message.role === "assistant") response = textFromMessage(event.message); });
417
+ try { const run = () => session.prompt(message, { source: "rpc" }); if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, run); else await run(); response ||= session.getLastAssistantText() ?? ""; if (!response) throw Object.assign(new Error("Pi completed without an assistant response."), { status: 502, code: "STATELESS_RESPONSE_MISSING", stage: "response_extraction" }); return { profile, sessionId: id, response }; } finally { unsubscribe(); session.dispose(); }
418
+ });
419
+ }
420
+
353
421
  async discoverExtensionTools(profile: string): Promise<void> {
354
422
  const existing = this.toolDiscoveries.get(profile);
355
423
  if (existing) return existing;
@@ -408,29 +476,42 @@ class ApiServer {
408
476
  return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
409
477
  }
410
478
 
411
- private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<string> {
479
+ private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }, onEvent?: (event: Record<string, unknown>) => void): Promise<string> {
412
480
  const key = `${profile}:${id}`;
413
481
  if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
414
482
  this.sandboxBusy.add(key);
415
483
  try {
416
484
  const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
417
485
  if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
418
- const workerEnv: NodeJS.ProcessEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}), ...(applicationContext ? { PI_APPLICATION_IDENTITY_KEY: applicationContext.identityKey, PI_APPLICATION_SLUG: applicationContext.application, PI_APPLICATION_SESSION_ID: id } : {}) };
486
+ const workerEnv: NodeJS.ProcessEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}), ...(applicationContext ? { PI_APPLICATION_IDENTITY_KEY: applicationContext.identityKey, PI_APPLICATION_SLUG: applicationContext.application, PI_APPLICATION_SESSION_ID: id, ...(applicationContext.directContextMemory !== undefined ? { PI_APPLICATION_DIRECT_CONTEXT_MEMORY: Buffer.from(applicationContext.directContextMemory, "utf8").toString("base64url") } : {}) } : {}) };
419
487
  // The HTTP gateway is an API worker itself. Its children are agent
420
488
  // runtimes, not additional HTTP servers.
421
489
  delete workerEnv.PI_API_WORKER;
422
490
  const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolveRun, reject) => {
423
491
  // Supply the native file path, not only its ID. The session manager
424
492
  // otherwise scopes an ID lookup to the API worker CWD (`/` in Docker).
425
- const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--session", session.path, "--print", message], {
493
+ const pi = piCommand();
494
+ const child = spawn(pi.command, [...pi.args, "profile", profile, "--session", session.path, ...(onEvent ? ["--mode", "json"] : ["--print"]), message], {
426
495
  // The sandboxed agent sees its profile directory as CWD, never the
427
496
  // HTTP server's CWD or the caller's project directory.
428
- cwd: this.profileDirectory(profile),
429
- env: workerEnv,
430
- stdio: ["ignore", "pipe", "pipe"],
497
+ cwd: this.profileDirectory(profile), env: workerEnv, stdio: ["ignore", "pipe", "pipe"],
498
+ });
499
+ let stdout = ""; let stderr = ""; let jsonBuffer = ""; let finalResponse = "";
500
+ child.stdout.on("data", (chunk) => {
501
+ const text = String(chunk);
502
+ if (!onEvent) { stdout += text; onData?.(text); return; }
503
+ jsonBuffer += text;
504
+ const lines = jsonBuffer.split("\n"); jsonBuffer = lines.pop() ?? "";
505
+ for (const line of lines) {
506
+ if (!line.trim()) continue;
507
+ try {
508
+ const event = JSON.parse(line) as Record<string, unknown>; onEvent(event);
509
+ if (event.type === "message_update") { const update = event.assistantMessageEvent as Record<string, unknown> | undefined; if (update?.type === "text_delta" && typeof update.delta === "string") onData?.(update.delta); }
510
+ if (event.type === "message_end" && (event.message as Record<string, unknown> | undefined)?.role === "assistant") finalResponse = textFromMessage(event.message);
511
+ } catch { stderr += `Invalid JSON event from sandboxed Pi: ${line}\n`; }
512
+ }
513
+ stdout = finalResponse;
431
514
  });
432
- let stdout = ""; let stderr = "";
433
- child.stdout.on("data", (chunk) => { const text = String(chunk); stdout += text; onData?.(text); });
434
515
  child.stderr.on("data", (chunk) => { stderr += String(chunk); });
435
516
  child.once("error", reject);
436
517
  child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
@@ -448,7 +529,7 @@ class ApiServer {
448
529
  } finally { this.sandboxBusy.delete(key); }
449
530
  }
450
531
 
451
- async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }> {
532
+ async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }): Promise<{ profile: string; sessionId: string; response: string }> {
452
533
  if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
453
534
  const handle = await this.getSession(profile, id);
454
535
  if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
@@ -471,7 +552,7 @@ class ApiServer {
471
552
  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" });
472
553
  reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
473
554
  try {
474
- const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext);
555
+ const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext, (event) => { const activity = activityFromAgentEvent(event); if (activity) reply.raw.write(`event: activity\ndata: ${JSON.stringify(activity)}\n\n`); });
475
556
  reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
476
557
  } catch (error) {
477
558
  const message = error instanceof Error ? error.message : String(error);
@@ -499,9 +580,10 @@ class ApiServer {
499
580
  });
500
581
  response.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
501
582
  unsubscribe = handle.session.subscribe((event) => {
502
- if (!closed && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
503
- response.write(`event: token\ndata: ${JSON.stringify({ text: event.assistantMessageEvent.delta })}\n\n`);
504
- }
583
+ if (closed) return;
584
+ const activity = activityFromAgentEvent(event as unknown as Record<string, any>);
585
+ if (activity) response.write(`event: activity\ndata: ${JSON.stringify(activity)}\n\n`);
586
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") response.write(`event: token\ndata: ${JSON.stringify({ text: event.assistantMessageEvent.delta })}\n\n`);
505
587
  });
506
588
  response.once("close", () => { closed = true; void handle.session.abort(); });
507
589
  }
@@ -580,13 +662,14 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
580
662
  };
581
663
  const profileName = (request: FastifyRequest<{ Params: { profile: string } }>) => request.params.profile;
582
664
  const pulseTick = async (action: "start" | "stop" | "status") => new Promise<string>((resolveRun, reject) => {
583
- const child = spawn(process.execPath, [process.argv[1], "pulse", action], { cwd: options.cwd, env: { ...process.env, PI_API_WORKER: "" }, stdio: ["ignore", "pipe", "pipe"] });
665
+ const pi = piCommand();
666
+ const child = spawn(pi.command, [...pi.args, "pulse", action], { cwd: options.cwd, env: { ...process.env, PI_API_WORKER: "" }, stdio: ["ignore", "pipe", "pipe"] });
584
667
  let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
585
668
  child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Pulse command exited with ${code}`)));
586
669
  });
587
670
  const pulseTickStatus = async () => {
588
- 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." }; }
589
- catch { return { running: false, status: "Pulse tick stopped." }; }
671
+ try { const state = JSON.parse(await readFile(join(options.agentDir, "pulse-tick.state.json"), "utf8")) as { pid?: unknown; startedAt?: unknown; lastHeartbeatAt?: unknown }; const pid = typeof state.pid === "number" ? state.pid : 0, heartbeat = typeof state.lastHeartbeatAt === "string" ? Date.parse(state.lastHeartbeatAt) : NaN; let running = false; try { process.kill(pid, 0); running = pid > 0; } catch {} if (!running) return { running: false, status: `Pulse tick abandoned (PID ${pid} is not running).`, state: "abandoned", lastHeartbeatAt: state.lastHeartbeatAt ?? null }; if (!Number.isNaN(heartbeat) && Date.now() - heartbeat > 2 * 60_000) return { running: false, status: `Pulse tick stale (last heartbeat ${state.lastHeartbeatAt}).`, state: "stale", lastHeartbeatAt: state.lastHeartbeatAt }; return { running: true, status: `Pulse tick running (PID ${pid}).`, state: "healthy", lastHeartbeatAt: state.lastHeartbeatAt ?? null }; }
672
+ catch { return { running: false, status: "Pulse tick stopped.", state: "stopped", lastHeartbeatAt: null }; }
590
673
  };
591
674
  server.get("/api/pulse/tick/status", async (request, reply) => {
592
675
  if (!guard(request, reply)) return;
@@ -604,9 +687,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
604
687
  });
605
688
  server.post("/api/profiles", async (request, reply) => {
606
689
  if (!guard(request, reply)) return;
607
- const name = objectBody(request.body).name;
690
+ const body = objectBody(request.body), name = body.name, cloneFrom = body.cloneFrom;
608
691
  if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
609
- const profile = await api.profiles.create(name);
692
+ if (cloneFrom !== undefined && typeof cloneFrom !== "string") throw Object.assign(new Error("The 'cloneFrom' field must be a string."), { status: 400 });
693
+ const profile = await api.profiles.create(name, { description: body.description, tags: body.tags }, cloneFrom);
610
694
  return reply.code(201).send(profile);
611
695
  });
612
696
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
@@ -614,13 +698,44 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
614
698
  const settings = await api.profiles.readSettings(profileName(request));
615
699
  return { name: profileName(request), path: api.profiles.directory(profileName(request)), hasSoul: true, hasRefine: true, policy: settings.profile ?? {} };
616
700
  });
701
+ server.patch<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
702
+ if (!guard(request, reply)) return;
703
+ const body = objectBody(request.body);
704
+ if (body.description === undefined || body.tags === undefined) throw Object.assign(new Error("description and tags are required."), { status: 400 });
705
+ return { profile: await api.profiles.updateMetadata(profileName(request), { description: body.description, tags: body.tags }) };
706
+ });
617
707
  server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
618
708
  if (!guard(request, reply)) return;
619
709
  if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
620
- await api.profiles.delete(profileName(request));
710
+ await api.deleteProfile(profileName(request));
621
711
  return reply.code(204).send();
622
712
  });
623
713
 
714
+ server.get<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
715
+ if (!guard(request, reply)) return;
716
+ const profile = profileName(request), directory = api.profiles.directory(profile);
717
+ const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
718
+ if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
719
+ const providers = new Map<string, Array<{ id: string; name: string }>>();
720
+ for (const model of runtime.getModels()) {
721
+ const models = providers.get(model.provider) ?? [];
722
+ models.push({ id: model.id, name: model.name ?? model.id });
723
+ providers.set(model.provider, models);
724
+ }
725
+ return { settings: await api.profiles.readSettings(profile), providers: [...providers.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([id, models]) => ({ id, models: models.sort((left, right) => left.name.localeCompare(right.name)) })) };
726
+ });
727
+ server.put<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
728
+ if (!guard(request, reply)) return;
729
+ const profile = profileName(request), body = objectBody(request.body), provider = body.provider, model = body.model;
730
+ if (typeof provider !== "string" || !provider || typeof model !== "string" || !model) throw Object.assign(new Error("provider and model must be non-empty strings."), { status: 400 });
731
+ const directory = api.profiles.directory(profile);
732
+ const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
733
+ if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
734
+ if (!runtime.getModel(provider, model)) throw Object.assign(new Error("The selected provider and model are not available."), { status: 400 });
735
+ const settings = await api.profiles.readSettings(profile);
736
+ return { settings: await api.profiles.writeSettings(profile, { ...settings, defaultProvider: provider, defaultModel: model }) };
737
+ });
738
+
624
739
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
625
740
  if (!guard(request, reply)) return;
626
741
  return { settings: await api.profiles.readSettings(profileName(request)) };
@@ -631,6 +746,43 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
631
746
  return { settings: await api.profiles.writeSettings(profileName(request), settings) };
632
747
  });
633
748
 
749
+ const workflowEvaluation = async (profile: string, value: unknown) => {
750
+ const input = objectBody(value), modelType = input.modelType;
751
+ if (modelType === "llm") return { modelType: "llm" as const };
752
+ const systemOne = objectBody(input.systemOne), provider = typeof systemOne.provider === "string" ? systemOne.provider.trim() : "", model = typeof systemOne.model === "string" ? systemOne.model.trim() : "", endpoint = systemOne.endpoint && typeof systemOne.endpoint === "object" && !Array.isArray(systemOne.endpoint) ? systemOne.endpoint as Record<string, unknown> : {}, baseUrl = typeof endpoint.baseUrl === "string" && endpoint.baseUrl.trim() ? endpoint.baseUrl.trim() : "https://openrouter.ai/api/", path = typeof endpoint.path === "string" && endpoint.path.trim() ? endpoint.path.trim().replace(/^\/+/, "") : "alpha/decisions";
753
+ try { const url = new URL(baseUrl); if (!/^https?:$/.test(url.protocol) || /^(?:https?:)?\/\//i.test(path)) throw new Error(); }
754
+ catch { throw Object.assign(new Error("Invalid System One endpoint configuration."), { status: 400 }); }
755
+ if (modelType !== "system_one" || !provider || !model || (systemOne.onUncertain !== undefined && systemOne.onUncertain !== "block")) throw Object.assign(new Error("Invalid Sequential Workflow evaluation configuration."), { status: 400 });
756
+ const directory = api.profiles.directory(profile), runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
757
+ if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
758
+ if (!runtime.getModels().some((available) => available.provider === provider)) throw Object.assign(new Error("The selected provider is not available for this profile."), { status: 400 });
759
+ return { modelType: "system_one" as const, systemOne: { provider, model, endpoint: { baseUrl, path }, onUncertain: "block" as const } };
760
+ };
761
+ const configuredWorkflowEvaluation = async (profile: string, settings: ProfileSettings) => {
762
+ const value = (settings as Record<string, unknown>).sequentialWorkflow;
763
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { modelType: "llm" as const };
764
+ const evaluation = (value as Record<string, unknown>).evaluation;
765
+ return !evaluation || typeof evaluation !== "object" || Array.isArray(evaluation) ? { modelType: "llm" as const } : workflowEvaluation(profile, evaluation);
766
+ };
767
+ server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/config", async (request, reply) => {
768
+ if (!guard(request, reply)) return;
769
+ const profile = profileName(request); return { evaluation: await configuredWorkflowEvaluation(profile, await api.profiles.readSettings(profile)) };
770
+ });
771
+ server.put<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/config", async (request, reply) => {
772
+ if (!guard(request, reply)) return;
773
+ const profile = profileName(request), evaluation = await workflowEvaluation(profile, objectBody(request.body).evaluation), settings = await api.profiles.readSettings(profile);
774
+ const sequentialWorkflow = { ...(((settings as Record<string, unknown>).sequentialWorkflow as Record<string, unknown> | undefined) ?? {}), evaluation };
775
+ await api.profiles.writeSettings(profile, { ...settings, sequentialWorkflow } as ProfileSettings);
776
+ return { evaluation };
777
+ });
778
+ server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/status", async (request, reply) => {
779
+ if (!guard(request, reply)) return;
780
+ const profile = profileName(request), evaluation = await configuredWorkflowEvaluation(profile, await api.profiles.readSettings(profile));
781
+ const directory = api.profiles.directory(profile), environment = await api.handlerEnvironment(profile);
782
+ const credentialConfigured = evaluation.modelType !== "system_one" || await hasOpenRouterCredential(directory, environment);
783
+ return { evaluation, credentialConfigured };
784
+ });
785
+
634
786
  const documentRoute = (name: "soul" | "refine", file: "SOUL.md" | "REFINE.md") => {
635
787
  server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
636
788
  if (!guard(request, reply)) return;
@@ -740,9 +892,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
740
892
  return { content };
741
893
  });
742
894
 
743
- server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
895
+ server.get<{ Params: { profile: string }; Querystring: { includePulseSessions?: string; includePulseExecutions?: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
744
896
  if (!guard(request, reply)) return;
745
- return { sessions: await api.listSessions(profileName(request)) };
897
+ return { sessions: await api.listSessions(profileName(request), request.query.includePulseSessions === "true" || request.query.includePulseExecutions === "true") };
746
898
  });
747
899
  server.post<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
748
900
  if (!guard(request, reply)) return;
@@ -784,6 +936,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
784
936
  try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
785
937
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
786
938
  });
939
+ server.post<{ Params: { profile: string; name: string; runId: string } }>("/api/profiles/:profile/pulses/:name/runs/:runId/finalize", async (request, reply) => {
940
+ if (!guard(request, reply)) return;
941
+ const status = objectBody(request.body).status;
942
+ if (status !== "success" && status !== "error") throw Object.assign(new Error("The 'status' field must be 'success' or 'error'."), { status: 400 });
943
+ try { return { run: api.pulses.finalizeRunning(request.params.name, profileName(request), request.params.runId, status) }; }
944
+ catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 409 }); }
945
+ });
787
946
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
788
947
  if (!guard(request, reply)) return;
789
948
  await api.profiles.readSettings(profileName(request));
@@ -796,7 +955,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
796
955
  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 });
797
956
  const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
798
957
  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 });
799
- 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) }) }); }
958
+ 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, insertIntoApiSession: body.insertIntoApiSession !== false, profile: profileName(request) }) }); }
800
959
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
801
960
  });
802
961
  server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
@@ -806,11 +965,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
806
965
  });
807
966
  server.put<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
808
967
  if (!guard(request, reply)) return;
809
- const body = objectBody(request.body), fields = ["description", "schedule", "prompt"] as const;
968
+ const body = objectBody(request.body), fields = ["name", "description", "schedule", "prompt"] as const;
810
969
  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 });
811
970
  const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
812
971
  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 });
813
- 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 }) }; }
972
+ try {
973
+ const profile = profileName(request), current = api.pulses.get(request.params.name, profile);
974
+ if (!current) throw new Error("Pulse not found.");
975
+ return { pulse: api.pulses.update(request.params.name, profile, { name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: typeof body.insertIntoApiSession === "boolean" ? body.insertIntoApiSession : current.insertIntoApiSession }) };
976
+ }
814
977
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
815
978
  });
816
979
  server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
@@ -826,7 +989,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
826
989
  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) }; });
827
990
  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); });
828
991
  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 }); });
829
- 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 }); });
992
+ 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, body.confirmOverwrite === true); if (publication.action !== "confirmation_required") await api.profiles.refreshSkills(profile); return reply.code(publication.action === "created" ? 201 : 200).send({ publication }); });
830
993
  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 }; });
831
994
 
832
995
  const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
@@ -925,7 +1088,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
925
1088
  server.post("/api/applications", async (request, reply) => {
926
1089
  if (!guard(request, reply)) return; const body = objectBody(request.body);
927
1090
  if (typeof body.name !== "string" || typeof body.slug !== "string") throw Object.assign(new Error("name and slug are required."), { status: 400 });
928
- 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 });
1091
+ const responseMode = body.responseMode === "result" ? "result" : "ack"; const defaults: ApplicationSettings = { responseMode, inboundHandler: "inbound", transformHandlers: [], directMode: { enabled: true } }; 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 });
929
1092
  await loadApplication(record); return reply.code(201).send({ application: record });
930
1093
  });
931
1094
  server.get<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; return { application: registeredApplication(request.params.slug).record }; });
@@ -936,7 +1099,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
936
1099
  });
937
1100
  server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; applicationBySlug.get(request.params.slug)?.runtime.dispose(); 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(); });
938
1101
  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) }; });
939
- 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) }; });
1102
+ 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, ignored = body.sessionMode === "ignore" || body.profile === null, automatic = !ignored && (identityKey === "*" || body.sessionMode === "automatic"), prefix = ignored || automatic ? null : body.sessionPrefix, profile = ignored ? null : body.profile; 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 || (!ignored && (typeof profile !== "string" || !profile || (!automatic && typeof prefix !== "string")))) throw Object.assign(new Error("profile and a fixed sessionPrefix are required unless this mapping ignores requests."), { 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 }); if (!ignored) await api.profiles.readSettings(profile as string); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) }; });
940
1103
  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 }); } });
941
1104
  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) }; });
942
1105
  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(); });
@@ -972,9 +1135,22 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
972
1135
  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 }; });
973
1136
  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()); });
974
1137
  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)); });
975
- server.post<{ Params: { slug: string } }>("/api/message/app/:slug", async (request, reply) => {
1138
+ server.post<{ Params: { slug: string } }>("/api/message/app/:slug", { bodyLimit: MAX_APPLICATION_BODY_BYTES }, async (request, reply) => {
976
1139
  const application = registeredApplication(request.params.slug); if (!application.record.enabled) return apiError(reply, 404, "NOT_FOUND", "Application is disabled.");
977
1140
  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> : {};
1141
+ if (application.runtime.isDirectRequest(request.body)) {
1142
+ const direct = await application.runtime.prepareDirectRequest(request.body);
1143
+ if (application.record.responseMode === "ack") {
1144
+ void application.runtime.process(request.body, headers, query, direct).then(async (value) => {
1145
+ const result = value as { profile: string; sessionId: string; response: string };
1146
+ const response = await fetch(direct.responseWebhookUrl!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ application: application.record.slug, identityKey: direct.identityKey, profile: result.profile, sessionId: result.sessionId, response: result.response }), signal: AbortSignal.timeout(15_000) });
1147
+ if (!response.ok) throw new Error(`Response webhook returned HTTP ${response.status}.`);
1148
+ }).catch((error) => console.error(`[application:${application.record.slug}] Direct Mode response webhook failed:`, error));
1149
+ return reply.code(202).send({ ok: true, accepted: true });
1150
+ }
1151
+ // In wait mode responseWebhookUrl is intentionally ignored.
1152
+ return application.runtime.process(request.body, headers, query, direct);
1153
+ }
978
1154
  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 }); }
979
1155
  return application.runtime.process(request.body, headers, query);
980
1156
  });
@@ -984,7 +1160,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
984
1160
  return apiError(reply, 404, "NOT_FOUND", "Route not found.");
985
1161
  });
986
1162
  server.setErrorHandler(async (error, _request, reply) => {
987
- if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return apiError(reply, 413, "BAD_REQUEST", "payload exceeds 1 MiB");
1163
+ if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return apiError(reply, 413, "BAD_REQUEST", "payload exceeds the allowed request size limit.");
988
1164
  const typed = error as Error & { status?: number };
989
1165
  if (typed.status) return apiError(reply, typed.status, "BAD_REQUEST", typed.message);
990
1166
  console.error("[pi-api]", error);