pi-feats 0.1.5 → 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 (50) hide show
  1. package/README.md +48 -5
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-runtime.ts +65 -4
  4. package/extensions/api-server/application-store.ts +5 -5
  5. package/extensions/api-server/index.ts +6 -2
  6. package/extensions/api-server/profile-store.ts +81 -34
  7. package/extensions/api-server/server.ts +179 -42
  8. package/extensions/cli-resources.ts +125 -94
  9. package/extensions/cli-tui-mode.ts +7 -0
  10. package/extensions/context-memory/index.ts +6 -1
  11. package/extensions/guardrails/index.ts +2 -1
  12. package/extensions/lib/application-context.ts +2 -0
  13. package/extensions/lib/cli-tui-mode.ts +72 -0
  14. package/extensions/lib/context-memory.ts +6 -1
  15. package/extensions/lib/package-resources.ts +58 -0
  16. package/extensions/lib/pi-command.ts +11 -0
  17. package/extensions/lib/profile-sandbox.ts +11 -3
  18. package/extensions/lib/remote-hosts.ts +7 -5
  19. package/extensions/lib/workflow-evaluator.ts +113 -0
  20. package/extensions/lib/workflow-harness.ts +409 -43
  21. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  22. package/extensions/pi-console-webui/app/globals.css +3 -0
  23. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  24. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  25. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  26. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  27. package/extensions/pi-console-webui/components/console-page.tsx +1 -0
  28. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  29. package/extensions/pi-console-webui/components/console.tsx +26 -9
  30. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  31. package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
  32. package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
  33. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  34. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  35. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  36. package/extensions/pi-console-webui/index.ts +2 -1
  37. package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
  38. package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
  39. package/extensions/profile-ssh.ts +2 -2
  40. package/extensions/profiles.ts +17 -9
  41. package/extensions/pulse/index.ts +35 -17
  42. package/extensions/pulse/store.ts +29 -7
  43. package/extensions/sequential-workflow.ts +121 -79
  44. package/extensions/skill-sources/store.ts +29 -10
  45. package/package.json +19 -9
  46. package/scripts/build-console-webui.mjs +7 -0
  47. package/scripts/console-webui-cache.mjs +79 -0
  48. package/scripts/ensure-console-build.mjs +43 -0
  49. package/skills/sequential-workflow/SKILL.md +4 -3
  50. 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,6 +222,12 @@ class ApiServer {
200
222
  return SessionManager.listAll(sessionDir);
201
223
  }
202
224
 
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
+
203
231
  async deleteProfile(profile: string): Promise<void> {
204
232
  if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
205
233
  await this.profiles.readSettings(profile);
@@ -214,18 +242,26 @@ class ApiServer {
214
242
  this.pulses.deleteProfile(profile);
215
243
  }
216
244
 
217
- async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
245
+ async listSessions(profile: string, includePulseSessions = false): Promise<Array<Record<string, unknown>>> {
218
246
  await this.profiles.readSettings(profile);
247
+ const pulseExecutions = this.pulses.executionSessions(profile);
219
248
  const sessions = await this.listProfileSessions(profile);
220
- return sessions.map((session) => ({
221
- id: session.id,
222
- name: session.name,
223
- cwd: session.cwd,
224
- createdAt: session.created.toISOString(),
225
- updatedAt: session.modified.toISOString(),
226
- messageCount: session.messageCount,
227
- preview: session.firstMessage,
228
- }));
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
+ });
229
265
  }
230
266
 
231
267
  async createSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
@@ -297,6 +333,8 @@ class ApiServer {
297
333
  const info = sessions.find((session) => session.id === id);
298
334
  if (!info) throw Object.assign(new Error("Session not found"), { status: 404 });
299
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.
300
338
  const entries = session.getBranch().filter((entry) => entry.type !== "session_info").map((entry) => {
301
339
  if (entry.type === "message") {
302
340
  const message = entry.message as { role?: string; content?: unknown; toolName?: string; isError?: boolean; timestamp?: number };
@@ -310,7 +348,7 @@ class ApiServer {
310
348
  }
311
349
  if (entry.type === "compaction" || entry.type === "branch_summary") return { id: entry.id, type: entry.type, timestamp: entry.timestamp, summary: (entry as any).summary };
312
350
  return { id: entry.id, type: entry.type, timestamp: entry.timestamp };
313
- });
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)));
314
352
  const limit = Math.max(1, Math.min(16, Math.floor(requestedLimit) || 16));
315
353
  const end = before ? entries.findIndex((entry) => entry.id === before) : entries.length;
316
354
  if (before && end < 0) throw Object.assign(new Error("Conversation cursor not found."), { status: 400 });
@@ -323,7 +361,8 @@ class ApiServer {
323
361
  const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
324
362
  const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
325
363
  delete environment.PI_API_WORKER;
326
- 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", ""], {
327
366
  cwd: agentDir,
328
367
  env: environment,
329
368
  stdio: ["ignore", "pipe", "pipe"],
@@ -349,7 +388,8 @@ class ApiServer {
349
388
  const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir };
350
389
  delete environment.PI_API_WORKER;
351
390
  return new Promise<string>((resolveRun, reject) => {
352
- 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"] });
353
393
  let stdout = "", stderr = "";
354
394
  child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); });
355
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"}`)));
@@ -364,6 +404,20 @@ class ApiServer {
364
404
  });
365
405
  }
366
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
+
367
421
  async discoverExtensionTools(profile: string): Promise<void> {
368
422
  const existing = this.toolDiscoveries.get(profile);
369
423
  if (existing) return existing;
@@ -422,29 +476,42 @@ class ApiServer {
422
476
  return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
423
477
  }
424
478
 
425
- 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> {
426
480
  const key = `${profile}:${id}`;
427
481
  if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
428
482
  this.sandboxBusy.add(key);
429
483
  try {
430
484
  const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
431
485
  if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
432
- 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") } : {}) } : {}) };
433
487
  // The HTTP gateway is an API worker itself. Its children are agent
434
488
  // runtimes, not additional HTTP servers.
435
489
  delete workerEnv.PI_API_WORKER;
436
490
  const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolveRun, reject) => {
437
491
  // Supply the native file path, not only its ID. The session manager
438
492
  // otherwise scopes an ID lookup to the API worker CWD (`/` in Docker).
439
- 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], {
440
495
  // The sandboxed agent sees its profile directory as CWD, never the
441
496
  // HTTP server's CWD or the caller's project directory.
442
- cwd: this.profileDirectory(profile),
443
- env: workerEnv,
444
- 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;
445
514
  });
446
- let stdout = ""; let stderr = "";
447
- child.stdout.on("data", (chunk) => { const text = String(chunk); stdout += text; onData?.(text); });
448
515
  child.stderr.on("data", (chunk) => { stderr += String(chunk); });
449
516
  child.once("error", reject);
450
517
  child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
@@ -462,7 +529,7 @@ class ApiServer {
462
529
  } finally { this.sandboxBusy.delete(key); }
463
530
  }
464
531
 
465
- 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 }> {
466
533
  if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
467
534
  const handle = await this.getSession(profile, id);
468
535
  if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
@@ -485,7 +552,7 @@ class ApiServer {
485
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" });
486
553
  reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
487
554
  try {
488
- 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`); });
489
556
  reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
490
557
  } catch (error) {
491
558
  const message = error instanceof Error ? error.message : String(error);
@@ -513,9 +580,10 @@ class ApiServer {
513
580
  });
514
581
  response.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
515
582
  unsubscribe = handle.session.subscribe((event) => {
516
- if (!closed && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
517
- response.write(`event: token\ndata: ${JSON.stringify({ text: event.assistantMessageEvent.delta })}\n\n`);
518
- }
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`);
519
587
  });
520
588
  response.once("close", () => { closed = true; void handle.session.abort(); });
521
589
  }
@@ -594,13 +662,14 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
594
662
  };
595
663
  const profileName = (request: FastifyRequest<{ Params: { profile: string } }>) => request.params.profile;
596
664
  const pulseTick = async (action: "start" | "stop" | "status") => new Promise<string>((resolveRun, reject) => {
597
- 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"] });
598
667
  let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
599
668
  child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Pulse command exited with ${code}`)));
600
669
  });
601
670
  const pulseTickStatus = async () => {
602
- 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." }; }
603
- 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 }; }
604
673
  };
605
674
  server.get("/api/pulse/tick/status", async (request, reply) => {
606
675
  if (!guard(request, reply)) return;
@@ -618,9 +687,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
618
687
  });
619
688
  server.post("/api/profiles", async (request, reply) => {
620
689
  if (!guard(request, reply)) return;
621
- const name = objectBody(request.body).name;
690
+ const body = objectBody(request.body), name = body.name, cloneFrom = body.cloneFrom;
622
691
  if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
623
- 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);
624
694
  return reply.code(201).send(profile);
625
695
  });
626
696
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
@@ -628,6 +698,12 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
628
698
  const settings = await api.profiles.readSettings(profileName(request));
629
699
  return { name: profileName(request), path: api.profiles.directory(profileName(request)), hasSoul: true, hasRefine: true, policy: settings.profile ?? {} };
630
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
+ });
631
707
  server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
632
708
  if (!guard(request, reply)) return;
633
709
  if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
@@ -670,6 +746,43 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
670
746
  return { settings: await api.profiles.writeSettings(profileName(request), settings) };
671
747
  });
672
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
+
673
786
  const documentRoute = (name: "soul" | "refine", file: "SOUL.md" | "REFINE.md") => {
674
787
  server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
675
788
  if (!guard(request, reply)) return;
@@ -779,9 +892,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
779
892
  return { content };
780
893
  });
781
894
 
782
- 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) => {
783
896
  if (!guard(request, reply)) return;
784
- return { sessions: await api.listSessions(profileName(request)) };
897
+ return { sessions: await api.listSessions(profileName(request), request.query.includePulseSessions === "true" || request.query.includePulseExecutions === "true") };
785
898
  });
786
899
  server.post<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
787
900
  if (!guard(request, reply)) return;
@@ -823,6 +936,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
823
936
  try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
824
937
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
825
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
+ });
826
946
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
827
947
  if (!guard(request, reply)) return;
828
948
  await api.profiles.readSettings(profileName(request));
@@ -835,7 +955,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
835
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 });
836
956
  const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
837
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 });
838
- 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 === true, 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) }) }); }
839
959
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
840
960
  });
841
961
  server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
@@ -845,11 +965,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
845
965
  });
846
966
  server.put<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
847
967
  if (!guard(request, reply)) return;
848
- const body = objectBody(request.body), fields = ["description", "schedule", "prompt"] as const;
968
+ const body = objectBody(request.body), fields = ["name", "description", "schedule", "prompt"] as const;
849
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 });
850
970
  const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
851
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 });
852
- 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, insertIntoApiSession: body.insertIntoApiSession === true }) }; }
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
+ }
853
977
  catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
854
978
  });
855
979
  server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
@@ -865,7 +989,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
865
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) }; });
866
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); });
867
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 }); });
868
- 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 }); });
869
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 }; });
870
994
 
871
995
  const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
@@ -964,7 +1088,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
964
1088
  server.post("/api/applications", async (request, reply) => {
965
1089
  if (!guard(request, reply)) return; const body = objectBody(request.body);
966
1090
  if (typeof body.name !== "string" || typeof body.slug !== "string") throw Object.assign(new Error("name and slug are required."), { status: 400 });
967
- 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 });
968
1092
  await loadApplication(record); return reply.code(201).send({ application: record });
969
1093
  });
970
1094
  server.get<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; return { application: registeredApplication(request.params.slug).record }; });
@@ -1011,9 +1135,22 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
1011
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 }; });
1012
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()); });
1013
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)); });
1014
- 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) => {
1015
1139
  const application = registeredApplication(request.params.slug); if (!application.record.enabled) return apiError(reply, 404, "NOT_FOUND", "Application is disabled.");
1016
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
+ }
1017
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 }); }
1018
1155
  return application.runtime.process(request.body, headers, query);
1019
1156
  });
@@ -1023,7 +1160,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
1023
1160
  return apiError(reply, 404, "NOT_FOUND", "Route not found.");
1024
1161
  });
1025
1162
  server.setErrorHandler(async (error, _request, reply) => {
1026
- 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.");
1027
1164
  const typed = error as Error & { status?: number };
1028
1165
  if (typed.status) return apiError(reply, typed.status, "BAD_REQUEST", typed.message);
1029
1166
  console.error("[pi-api]", error);