pi-feats 0.1.3 → 0.1.4

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 (121) hide show
  1. package/README.md +22 -1
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/extensions/api-server/application-log-store.ts +31 -0
  4. package/extensions/api-server/application-runtime.ts +80 -18
  5. package/extensions/api-server/application-store.ts +6 -2
  6. package/extensions/api-server/profile-store.ts +47 -20
  7. package/extensions/api-server/server.ts +182 -47
  8. package/extensions/browser-harness/cdp/commands.ts +260 -0
  9. package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
  10. package/extensions/browser-harness/cdp/discovery.ts +198 -0
  11. package/extensions/browser-harness/cdp/errors.ts +23 -0
  12. package/extensions/browser-harness/cdp/events.ts +170 -0
  13. package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
  14. package/extensions/browser-harness/cdp/ownership.ts +73 -0
  15. package/extensions/browser-harness/cdp/record-store.ts +61 -0
  16. package/extensions/browser-harness/cdp/session.ts +403 -0
  17. package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
  18. package/extensions/browser-harness/cdp/target-factory.ts +153 -0
  19. package/extensions/browser-harness/cdp/types.ts +82 -0
  20. package/extensions/browser-harness/client.ts +366 -0
  21. package/extensions/browser-harness/daemon/bridge.ts +531 -0
  22. package/extensions/browser-harness/daemon/index.ts +86 -0
  23. package/extensions/browser-harness/daemon/protocol.ts +110 -0
  24. package/extensions/browser-harness/daemon/queue.ts +85 -0
  25. package/extensions/browser-harness/daemon/server.ts +170 -0
  26. package/extensions/browser-harness/daemon/spawn.ts +124 -0
  27. package/extensions/browser-harness/daemon/transport.ts +178 -0
  28. package/extensions/browser-harness/domains/ax-tree.ts +179 -0
  29. package/extensions/browser-harness/domains/box.ts +51 -0
  30. package/extensions/browser-harness/domains/cdp-call.ts +55 -0
  31. package/extensions/browser-harness/domains/click.ts +97 -0
  32. package/extensions/browser-harness/domains/console.ts +219 -0
  33. package/extensions/browser-harness/domains/dialog.ts +34 -0
  34. package/extensions/browser-harness/domains/drag.ts +67 -0
  35. package/extensions/browser-harness/domains/element-call.ts +117 -0
  36. package/extensions/browser-harness/domains/files.ts +219 -0
  37. package/extensions/browser-harness/domains/fill-engine.ts +80 -0
  38. package/extensions/browser-harness/domains/form-batch.ts +65 -0
  39. package/extensions/browser-harness/domains/form.ts +283 -0
  40. package/extensions/browser-harness/domains/headers.ts +89 -0
  41. package/extensions/browser-harness/domains/history.ts +80 -0
  42. package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
  43. package/extensions/browser-harness/domains/js.ts +241 -0
  44. package/extensions/browser-harness/domains/keyboard.ts +197 -0
  45. package/extensions/browser-harness/domains/navigate.ts +161 -0
  46. package/extensions/browser-harness/domains/network.ts +301 -0
  47. package/extensions/browser-harness/domains/page.ts +154 -0
  48. package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
  49. package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
  50. package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
  51. package/extensions/browser-harness/domains/render.ts +43 -0
  52. package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
  53. package/extensions/browser-harness/domains/screenshot.ts +76 -0
  54. package/extensions/browser-harness/domains/scroll.ts +59 -0
  55. package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
  56. package/extensions/browser-harness/domains/search/web-search.ts +144 -0
  57. package/extensions/browser-harness/domains/setup.ts +35 -0
  58. package/extensions/browser-harness/domains/snapshot.ts +256 -0
  59. package/extensions/browser-harness/domains/tabs.ts +202 -0
  60. package/extensions/browser-harness/domains/target.ts +35 -0
  61. package/extensions/browser-harness/domains/viewport.ts +42 -0
  62. package/extensions/browser-harness/index.ts +88 -0
  63. package/extensions/browser-harness/profile/bind.ts +90 -0
  64. package/extensions/browser-harness/profile/browser-process.ts +320 -0
  65. package/extensions/browser-harness/profile/command.ts +189 -0
  66. package/extensions/browser-harness/profile/launch.ts +80 -0
  67. package/extensions/browser-harness/profile/list.ts +131 -0
  68. package/extensions/browser-harness/profile/paths.ts +103 -0
  69. package/extensions/browser-harness/profile/store.ts +84 -0
  70. package/extensions/browser-harness/prompt.ts +16 -0
  71. package/extensions/browser-harness/registry.ts +80 -0
  72. package/extensions/browser-harness/schemas/common.ts +11 -0
  73. package/extensions/browser-harness/schemas/parse.ts +23 -0
  74. package/extensions/browser-harness/setup.ts +47 -0
  75. package/extensions/browser-harness/state.ts +51 -0
  76. package/extensions/browser-harness/steel.ts +215 -0
  77. package/extensions/browser-harness/util/debug.ts +8 -0
  78. package/extensions/browser-harness/util/guards.ts +30 -0
  79. package/extensions/browser-harness/util/js-template.ts +12 -0
  80. package/extensions/browser-harness/util/mutex.ts +20 -0
  81. package/extensions/browser-harness/util/paths.ts +31 -0
  82. package/extensions/browser-harness/util/result.ts +9 -0
  83. package/extensions/browser-harness/util/sharp-shim.ts +42 -0
  84. package/extensions/browser-harness/util/tool.ts +185 -0
  85. package/extensions/browser-harness/util/truncate.ts +41 -0
  86. package/extensions/cli-resources.ts +35 -16
  87. package/extensions/composio/index.ts +163 -0
  88. package/extensions/context-memory/index.ts +58 -0
  89. package/extensions/lib/application-context.ts +22 -0
  90. package/extensions/lib/application-handler-templates.ts +17 -1
  91. package/extensions/lib/context-memory.ts +165 -0
  92. package/extensions/lib/profile-sandbox.ts +3 -0
  93. package/extensions/lib/workflow-harness.ts +238 -0
  94. package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
  95. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
  96. package/extensions/pi-console-webui/app/layout.tsx +6 -1
  97. package/extensions/pi-console-webui/app/login/page.tsx +5 -9
  98. package/extensions/pi-console-webui/app/not-found.tsx +5 -0
  99. package/extensions/pi-console-webui/app/page.tsx +6 -0
  100. package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
  101. package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
  102. package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
  103. package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
  104. package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
  105. package/extensions/pi-console-webui/components/console-page.tsx +3 -1
  106. package/extensions/pi-console-webui/components/console.tsx +53 -7
  107. package/extensions/pi-console-webui/components/login-form.tsx +14 -0
  108. package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
  109. package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
  110. package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
  111. package/extensions/profiles.ts +18 -7
  112. package/extensions/pulse/store.ts +5 -2
  113. package/extensions/sequential-workflow.ts +340 -256
  114. package/extensions/skill-sources/store.ts +24 -3
  115. package/licenses/pi-browser-harness-MIT.txt +21 -0
  116. package/package.json +21 -3
  117. package/schemas/sequential-workflow-template.schema.json +62 -0
  118. package/skills/browser-harness/SKILL.md +33 -0
  119. package/skills/composio-connections/SKILL.md +75 -0
  120. package/skills/sequential-workflow/SKILL.md +70 -0
  121. package/skills/sequential-workflow-template/SKILL.md +44 -0
@@ -18,6 +18,8 @@ import { ProfileStore, RESOURCE_KINDS, type ProfileSettings, type ResourceKind }
18
18
  import { ApplicationRuntime, type Settings as ApplicationSettings } from "./application-runtime.ts";
19
19
  import { ApplicationStore, type ApplicationRecord } from "./application-store.ts";
20
20
  import { applicationHandlerTemplate } from "../lib/application-handler-templates.ts";
21
+ import { applicationExecutionContext, type ContextMemoryExecutionLog } from "../lib/application-context.ts";
22
+ import { contextMemoryCharacters, contextMemoryConfig, contextMemoryLimit, memoryPath, readMemory } from "../lib/context-memory.ts";
21
23
  import { isSandboxEnabled } from "../lib/profile-sandbox.ts";
22
24
  import { handlerEnvironment, HOST_SSH_CREDENTIAL_KEYS, parseProfileEnv, profileEnvironment } from "../lib/profile-env.ts";
23
25
  import { PulseStore } from "../pulse/store.ts";
@@ -98,10 +100,12 @@ class ApiServer {
98
100
  private readonly sessions = new Map<string, SessionHandle>();
99
101
  private readonly sandboxBusy = new Set<string>();
100
102
  private readonly runtimes = new Map<string, Promise<ModelRuntime>>();
103
+ private readonly toolDiscoveries = new Map<string, Promise<void>>();
101
104
  readonly profiles: ProfileStore;
102
105
  readonly pulses: PulseStore;
103
106
  readonly applications: ApplicationStore;
104
107
  readonly skillSources: SkillSourceStore;
108
+ private contextMemoryLogger?: (application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog) => void;
105
109
 
106
110
  constructor(private readonly options: ServerOptions, private readonly config: ApiConfig) {
107
111
  this.profiles = new ProfileStore(options.agentDir);
@@ -230,6 +234,21 @@ class ApiServer {
230
234
  return { id: session.getSessionId(), name: session.getSessionName() ?? null, createdAt: header.timestamp, updatedAt: header.timestamp, messageCount: 0, preview: "" };
231
235
  }
232
236
 
237
+ async createApplicationConsoleSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
238
+ const body = objectBody(input), application = body.application, identityKey = body.identityKey;
239
+ if (typeof application !== "string" || !application) throw Object.assign(new Error("Application is required."), { status: 400 });
240
+ if (typeof identityKey !== "string" || !identityKey.trim()) throw Object.assign(new Error("Identity key is required."), { status: 400 });
241
+ const record = this.applications.get(application);
242
+ if (!record?.enabled) throw Object.assign(new Error("Application was not found or is disabled."), { status: 404 });
243
+ const mapping = this.applications.identityMapping(application, identityKey.trim());
244
+ if (!mapping) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 });
245
+ if (mapping.profile !== profile) throw Object.assign(new Error("This identity routes to a different profile."), { status: 422 });
246
+ const id = this.applications.nextConsoleSessionId(application, identityKey.trim());
247
+ const session = await this.createSession(profile, { id, name: `Application: ${record.name}` });
248
+ this.applications.createConsoleSession(id, application, profile, identityKey.trim());
249
+ return { ...session, application, manualApplicationSession: true };
250
+ }
251
+
233
252
  async renameSession(profile: string, id: string, name: string): Promise<Record<string, unknown>> {
234
253
  if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) throw Object.assign(new Error("Invalid sessionId"), { status: 400 });
235
254
  const trimmed = name.trim();
@@ -256,6 +275,7 @@ class ApiServer {
256
275
  active?.session.dispose();
257
276
  this.sessions.delete(key);
258
277
  await rm(existing.path);
278
+ this.applications.deleteConsoleSession(profile, id);
259
279
  }
260
280
 
261
281
  async getSessionConversation(profile: string, id: string, before?: string, requestedLimit = 16): Promise<Record<string, unknown>> {
@@ -287,7 +307,7 @@ class ApiServer {
287
307
  private async discoverSandboxedExtensionTools(profile: string): Promise<void> {
288
308
  const agentDir = this.profileDirectory(profile);
289
309
  const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
290
- const output = await new Promise<string>((resolveRun, reject) => {
310
+ const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
291
311
  delete environment.PI_API_WORKER;
292
312
  const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", ""], {
293
313
  cwd: agentDir,
@@ -298,13 +318,15 @@ class ApiServer {
298
318
  child.stdout.on("data", (chunk) => { stdout += String(chunk); });
299
319
  child.stderr.on("data", (chunk) => { stderr += String(chunk); });
300
320
  child.once("error", reject);
301
- child.once("exit", (code) => code === 0 ? resolveRun(stdout) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
321
+ child.once("exit", (code) => code === 0 ? resolveRun({ stdout, stderr }) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
302
322
  });
303
- const line = output.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
323
+ // Nono can relay a successful child Pi process through stderr. Its JSON
324
+ // protocol remains authoritative regardless of the stream it arrived on.
325
+ const line = `${output.stdout}\n${output.stderr}`.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
304
326
  if (!line) throw new Error("Sandboxed tool discovery returned no tool list.");
305
327
  const value = JSON.parse(line) as { tools?: unknown };
306
328
  if (!Array.isArray(value.tools) || value.tools.some((name) => typeof name !== "string")) throw new Error("Sandboxed tool discovery returned an invalid tool list.");
307
- this.profiles.registerExtensionTools(value.tools);
329
+ this.profiles.registerExtensionTools(profile, value.tools);
308
330
  }
309
331
 
310
332
  async complete(profile: string, prompt: string): Promise<string> {
@@ -329,46 +351,71 @@ class ApiServer {
329
351
  }
330
352
 
331
353
  async discoverExtensionTools(profile: string): Promise<void> {
332
- const agentDir = this.profileDirectory(profile);
333
- await this.profiles.readSettings(profile);
334
- if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
335
- await this.withProfileEnvironment(agentDir, async () => {
336
- const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
337
- const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
338
- await loader.reload();
339
- let runtime = this.runtimes.get(agentDir);
340
- if (!runtime) {
341
- runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
342
- this.runtimes.set(agentDir, runtime);
343
- }
344
- const { session } = await createAgentSession({
345
- cwd: this.options.cwd,
346
- agentDir,
347
- sessionManager: SessionManager.inMemory(this.options.cwd),
348
- settingsManager,
349
- resourceLoader: loader,
350
- modelRuntime: await runtime,
354
+ const existing = this.toolDiscoveries.get(profile);
355
+ if (existing) return existing;
356
+ const discovery = (async () => {
357
+ const agentDir = this.profileDirectory(profile);
358
+ await this.profiles.readSettings(profile);
359
+ if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
360
+ await this.withProfileEnvironment(agentDir, async () => {
361
+ const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
362
+ const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
363
+ await loader.reload();
364
+ let runtime = this.runtimes.get(agentDir);
365
+ if (!runtime) {
366
+ runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
367
+ this.runtimes.set(agentDir, runtime);
368
+ }
369
+ const { session } = await createAgentSession({
370
+ cwd: this.options.cwd,
371
+ agentDir,
372
+ sessionManager: SessionManager.inMemory(this.options.cwd),
373
+ settingsManager,
374
+ resourceLoader: loader,
375
+ modelRuntime: await runtime,
376
+ });
377
+ try {
378
+ this.profiles.registerExtensionTools(profile, session.agent.state.tools.map((tool) => tool.name));
379
+ } finally {
380
+ session.dispose();
381
+ }
351
382
  });
352
- try {
353
- this.profiles.registerExtensionTools(session.agent.state.tools.map((tool) => tool.name));
354
- } finally {
355
- session.dispose();
356
- }
357
- });
383
+ })();
384
+ this.toolDiscoveries.set(profile, discovery);
385
+ try {
386
+ await discovery;
387
+ } catch (error) {
388
+ this.toolDiscoveries.delete(profile);
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ invalidateExtensionTools(profile?: string): void {
394
+ if (profile) this.toolDiscoveries.delete(profile);
395
+ else this.toolDiscoveries.clear();
396
+ this.profiles.clearExtensionTools(profile);
397
+ }
398
+
399
+ setContextMemoryLogger(logger: (application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog) => void): void {
400
+ this.contextMemoryLogger = logger;
401
+ }
402
+
403
+ private recordContextMemoryExecution(application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog): void {
404
+ this.contextMemoryLogger?.(application, profile, identityKey, sessionId, execution);
358
405
  }
359
406
 
360
407
  private async sandboxed(profile: string): Promise<boolean> {
361
408
  return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
362
409
  }
363
410
 
364
- private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string): Promise<string> {
411
+ private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<string> {
365
412
  const key = `${profile}:${id}`;
366
413
  if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
367
414
  this.sandboxBusy.add(key);
368
415
  try {
369
416
  const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
370
417
  if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
371
- const workerEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}) };
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 } : {}) };
372
419
  // The HTTP gateway is an API worker itself. Its children are agent
373
420
  // runtimes, not additional HTTP servers.
374
421
  delete workerEnv.PI_API_WORKER;
@@ -388,31 +435,43 @@ class ApiServer {
388
435
  child.once("error", reject);
389
436
  child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
390
437
  });
391
- if (result.code !== 0) throw new Error(result.stderr.trim() || `Sandboxed Pi exited with ${result.code}`);
438
+ const stderr = result.stderr.split(/\r?\n/).filter((line) => {
439
+ if (!line.startsWith("PI_CONTEXT_MEMORY_LOG:")) return true;
440
+ if (applicationContext) try {
441
+ const execution = JSON.parse(line.slice("PI_CONTEXT_MEMORY_LOG:".length)) as ContextMemoryExecutionLog;
442
+ this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution);
443
+ } catch { /* Ignore malformed child telemetry. */ }
444
+ return false;
445
+ }).join("\n").trim();
446
+ if (result.code !== 0) throw new Error(stderr || `Sandboxed Pi exited with ${result.code}`);
392
447
  return result.stdout.trim();
393
448
  } finally { this.sandboxBusy.delete(key); }
394
449
  }
395
450
 
396
- async prompt(profile: string, id: string, message: string, handoff?: string): Promise<{ profile: string; sessionId: string; response: string }> {
397
- if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff) };
451
+ async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }> {
452
+ if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
398
453
  const handle = await this.getSession(profile, id);
399
454
  if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
400
455
  handle.busy = true;
401
456
  try {
402
- await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
457
+ const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
458
+ if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
459
+ else await promptSession();
403
460
  const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
404
461
  return { profile, sessionId: id, response: textFromMessage(assistant) };
405
462
  } finally { handle.busy = false; }
406
463
  }
407
464
 
408
465
  private async chat(profile: string, id: string, message: string, reply: FastifyReply, stream: boolean): Promise<void> {
466
+ const manual = this.applications.consoleSession(profile, id);
467
+ const applicationContext = manual ? { application: manual.application, identityKey: manual.identityKey } : undefined;
409
468
  if (await this.sandboxed(profile)) {
410
- if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message) }); return; }
469
+ if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, undefined, applicationContext) }); return; }
411
470
  reply.hijack();
412
471
  reply.raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
413
472
  reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
414
473
  try {
415
- const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`));
474
+ const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext);
416
475
  reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
417
476
  } catch (error) {
418
477
  const message = error instanceof Error ? error.message : String(error);
@@ -446,7 +505,9 @@ class ApiServer {
446
505
  });
447
506
  response.once("close", () => { closed = true; void handle.session.abort(); });
448
507
  }
449
- await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
508
+ const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
509
+ if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
510
+ else await promptSession();
450
511
  const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
451
512
  const response = textFromMessage(assistant);
452
513
  if (stream) {
@@ -586,6 +647,41 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
586
647
  documentRoute("soul", "SOUL.md");
587
648
  documentRoute("refine", "REFINE.md");
588
649
 
650
+ server.get<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
651
+ if (!guard(request, reply)) return;
652
+ const profile = profileName(request);
653
+ const settings = await api.profiles.readSettings(profile);
654
+ return { config: contextMemoryConfig((settings.profile as { contextMemory?: unknown } | undefined)?.contextMemory) };
655
+ });
656
+ server.put<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
657
+ if (!guard(request, reply)) return;
658
+ const profile = profileName(request), body = objectBody(request.body);
659
+ const config = body.config === null ? undefined : contextMemoryConfig(body.config);
660
+ if (body.config !== null && !config) throw Object.assign(new Error("Invalid Context Memory configuration."), { status: 400 });
661
+ const settings = await api.profiles.readSettings(profile);
662
+ const policy = { ...((settings.profile as Record<string, unknown> | undefined) ?? {}) };
663
+ if (config) policy.contextMemory = config; else delete policy.contextMemory;
664
+ await api.profiles.writeSettings(profile, { ...settings, profile: policy });
665
+ return { config: config ?? null };
666
+ });
667
+ for (const target of ["operational", "profile"] as const) {
668
+ server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
669
+ if (!guard(request, reply)) return;
670
+ const profile = profileName(request), directory = api.profiles.directory(profile);
671
+ return { content: await readMemory(memoryPath(options.agentDir, directory, target)) };
672
+ });
673
+ server.put<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
674
+ if (!guard(request, reply)) return;
675
+ const content = objectBody(request.body).content;
676
+ if (typeof content !== "string") throw Object.assign(new Error("Content must be a string."), { status: 400 });
677
+ const limit = contextMemoryLimit(target);
678
+ if (contextMemoryCharacters(content.trim()) > limit) throw Object.assign(new Error(`${target === "operational" ? "OPERATIONAL.md" : "PROFILE.md"} exceeds its ${limit}-character limit.`), { status: 413 });
679
+ const profile = profileName(request), directory = api.profiles.directory(profile), path = memoryPath(options.agentDir, directory, target);
680
+ await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${content.trim()}${content.trim() ? "\n" : ""}`, { mode: 0o600 });
681
+ return { content: content.trim() };
682
+ });
683
+ }
684
+
589
685
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/env", async (request, reply) => {
590
686
  if (!guard(request, reply)) return;
591
687
  const path = join(api.profiles.directory(profileName(request)), ".env");
@@ -652,6 +748,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
652
748
  if (!guard(request, reply)) return;
653
749
  return reply.code(201).send(await api.createSession(profileName(request), request.body));
654
750
  });
751
+ server.post<{ Params: { profile: string } }>("/api/profiles/:profile/application-sessions", async (request, reply) => {
752
+ if (!guard(request, reply)) return;
753
+ return reply.code(201).send(await api.createApplicationConsoleSession(profileName(request), request.body));
754
+ });
655
755
  server.get<{ Params: { profile: string; sessionId: string }; Querystring: { before?: string; limit?: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
656
756
  if (!guard(request, reply)) return;
657
757
  const limit = request.query.limit === undefined ? 16 : Number(request.query.limit);
@@ -675,6 +775,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
675
775
  await api.profiles.readSettings(profileName(request));
676
776
  return { pulses: api.pulses.list(profileName(request)) };
677
777
  });
778
+ server.get<{ Params: { profile: string; name: string }; Querystring: { start?: string; end?: string; limit?: string } }>("/api/profiles/:profile/pulses/:name/runs", async (request, reply) => {
779
+ if (!guard(request, reply)) return;
780
+ const profile = profileName(request); await api.profiles.readSettings(profile);
781
+ const end = request.query.end ?? new Date().toISOString(), start = request.query.start ?? new Date(Date.now() - 24 * 60 * 60 * 1_000).toISOString(), limit = request.query.limit === undefined ? 100 : Number(request.query.limit);
782
+ if (Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end)) || Date.parse(start) > Date.parse(end)) throw Object.assign(new Error("'start' and 'end' must be a valid chronological ISO-8601 range."), { status: 400 });
783
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw Object.assign(new Error("The 'limit' query parameter must be an integer from 1 to 100."), { status: 400 });
784
+ try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
785
+ catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
786
+ });
678
787
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
679
788
  if (!guard(request, reply)) return;
680
789
  await api.profiles.readSettings(profileName(request));
@@ -720,9 +829,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
720
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 }); });
721
830
  server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/sync", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), source=skillSource(request.query.source), skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); const synchronization=await api.skillSources.syncInstallation(profile, request.params.name, skill.path); await api.profiles.refreshSkills(profile); return { synchronization }; });
722
831
 
723
- const skillSource = (value: unknown): "shared" | "profile" | undefined => {
832
+ const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
724
833
  if (value === undefined) return undefined;
725
- if (value === "shared" || value === "profile") return value;
834
+ if (value === "shared" || value === "profile" || value === "package") return value;
726
835
  throw Object.assign(new Error("Invalid skill source."), { status: 400 });
727
836
  };
728
837
  server.get<{ Params: { profile: string } }>("/api/profiles/:profile/skills/sources", async (request, reply) => {
@@ -746,13 +855,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
746
855
  server.patch<{ Params: { profile: string; name: string }; Body: { enabled?: unknown } }>("/api/profiles/:profile/packages/:name", async (request, reply) => {
747
856
  if (!guard(request, reply)) return;
748
857
  if (typeof request.body?.enabled !== "boolean") return reply.code(400).send({ error: "'enabled' must be a boolean." });
749
- return { package: await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled) };
858
+ const result = await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled);
859
+ api.invalidateExtensionTools();
860
+ return { package: result };
750
861
  });
751
862
 
752
863
  server.get<{ Params: { profile: string; kind: string } }>("/api/profiles/:profile/resources/:kind", async (request, reply) => {
753
864
  if (!guard(request, reply)) return;
754
865
  const kind = resourceKind(request.params.kind);
755
- if (kind === "tools") await api.discoverExtensionTools(profileName(request)).catch(() => {});
866
+ if (kind === "tools") await api.discoverExtensionTools(profileName(request));
756
867
  return { resources: await api.profiles.resources(profileName(request), kind) };
757
868
  });
758
869
  server.get<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
@@ -767,7 +878,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
767
878
  if (kind === "tools") await api.discoverExtensionTools(profileName(request));
768
879
  const enabled = objectBody(request.body).enabled;
769
880
  if (typeof enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
770
- return api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
881
+ const result = await api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
882
+ if (kind === "extensions") api.invalidateExtensionTools();
883
+ return result;
771
884
  });
772
885
 
773
886
  server.delete<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
@@ -784,10 +897,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
784
897
  const applicationsRoot = join(options.agentDir, "applications");
785
898
  const applicationBySlug = new Map<string, { record: ApplicationRecord; runtime: ApplicationRuntime }>();
786
899
  const applicationSettings = (record: ApplicationRecord): ApplicationSettings => ({ ...record.settings, responseMode: record.responseMode, defaultProfile: record.defaultProfile }) as ApplicationSettings;
787
- const ensureApplicationFiles = async (slug: string) => { const handlers = join(applicationsRoot, slug, "handlers"); await mkdir(join(handlers, "transforms"), { recursive: true }); const inbound = join(handlers, "inbound.ts"); if (!existsSync(inbound)) await writeFile(inbound, applicationHandlerTemplate("inbound"), "utf8"); };
788
- const loadApplication = async (record: ApplicationRecord) => { const directory = join(applicationsRoot, record.slug); await ensureApplicationFiles(record.slug); const runtime = await ApplicationRuntime.load(directory, api, applicationSettings(record), async (settings) => { const saved = applicationStore.update(record.slug, { settings, responseMode: settings.responseMode, defaultProfile: settings.defaultProfile ?? null }); applicationBySlug.set(saved.slug, { record: saved, runtime }); }, record); applicationBySlug.set(record.slug, { record, runtime }); };
900
+ const ensureApplicationFiles = async (slug: string) => { const handlers = join(applicationsRoot, slug, "handlers"); await mkdir(join(handlers, "transforms"), { recursive: true }); await mkdir(join(handlers, "context-memory"), { recursive: true }); const inbound = join(handlers, "inbound.ts"); if (!existsSync(inbound)) await writeFile(inbound, applicationHandlerTemplate("inbound"), "utf8"); };
901
+ const loadApplication = async (record: ApplicationRecord) => { applicationBySlug.get(record.slug)?.runtime.dispose(); const directory = join(applicationsRoot, record.slug); await ensureApplicationFiles(record.slug); const runtime = await ApplicationRuntime.load(directory, api, applicationSettings(record), async (settings) => { const saved = applicationStore.update(record.slug, { settings, responseMode: settings.responseMode, defaultProfile: settings.defaultProfile ?? null }); applicationBySlug.set(saved.slug, { record: saved, runtime }); }, record); applicationBySlug.set(record.slug, { record, runtime }); };
789
902
  for (const record of applicationStore.list()) await loadApplication(record);
790
903
  const registeredApplication = (slug: string) => { const application = applicationBySlug.get(slug); if (!application) throw Object.assign(new Error("Application not found."), { status: 404 }); return application; };
904
+ api.setContextMemoryLogger((application, profile, identityKey, sessionId, execution) => {
905
+ applicationBySlug.get(application)?.runtime.logs.recordContextMemory({ profile, identityKey, sessionId, execution });
906
+ });
791
907
  const handlerFiles = async (directory: string, prefix = ""): Promise<string[]> => { const entries = await readdir(directory, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const path = join(directory, entry.name), name = `${prefix}${entry.name}`; if (entry.isDirectory()) files.push(...await handlerFiles(path, `${name}/`)); else if (entry.isFile() && entry.name.endsWith(".ts")) files.push(name); } return files; };
792
908
  const applicationHandlerPath = (slug: string, file: unknown) => {
793
909
  if (typeof file !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_./-]*\.ts$/.test(file) || file.includes("..")) throw Object.assign(new Error("Invalid handler path."), { status: 400 });
@@ -818,7 +934,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
818
934
  const record = applicationStore.update(current.slug, { name: typeof body.name === "string" ? body.name : current.name, enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, responseMode: body.responseMode === "result" || body.responseMode === "ack" ? body.responseMode : current.responseMode, defaultProfile: typeof body.defaultProfile === "string" ? body.defaultProfile || null : current.defaultProfile, routingPolicy: body.routingPolicy === "drop" || body.routingPolicy === "default_as_fallback" ? body.routingPolicy : current.routingPolicy, settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : current.settings });
819
935
  await loadApplication(record); return { application: record };
820
936
  });
821
- server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; applicationStore.delete(request.params.slug); applicationBySlug.delete(request.params.slug); await rm(join(applicationsRoot, request.params.slug), { recursive: true, force: true }); return reply.code(204).send(); });
937
+ 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(); });
822
938
  server.get<{ Params: { slug: string } }>("/api/applications/:slug/identity-mappings", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); return { mappings: applicationStore.identityMappings(request.params.slug) }; });
823
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) }; });
824
940
  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 }); } });
@@ -830,7 +946,26 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
830
946
  server.patch<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), oldPath = applicationHandlerPath(request.params.slug, request.query.path), newFile = objectBody(request.body).newPath; const newPath = applicationHandlerPath(request.params.slug, newFile); if (dirname(oldPath) !== dirname(newPath)) throw Object.assign(new Error("Handlers must remain in their current folder."), { status: 400 }); if (existsSync(newPath)) throw Object.assign(new Error("A handler with this name already exists."), { status: 409 }); await rename(oldPath, newPath); const oldFile = String(request.query.path), oldName = oldFile.replace(/^transforms\//, "").replace(/\.ts$/, ""), newName = String(newFile).replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>, testPayloads = handlerTestPayloads(settings); if (oldFile in testPayloads) { testPayloads[String(newFile)] = testPayloads[oldFile]; delete testPayloads[oldFile]; } settings.handlerTestPayloads = testPayloads; if (settings.inboundHandler === oldName) settings.inboundHandler = newName; if (settings.outboundHandler === oldName) settings.outboundHandler = newName; settings.transformHandlers = settings.transformHandlers?.map((value) => value === oldName ? newName : value); const record = updateApplicationSettings(application, settings); await loadApplication(record); return { ok: true, path: newFile, settingsUpdated: true }; });
831
947
  server.delete<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), path = applicationHandlerPath(request.params.slug, request.query.path), file = String(request.query.path), handlerName = file.replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>; if (settings.inboundHandler === handlerName || settings.outboundHandler === handlerName || settings.transformHandlers?.includes(handlerName)) throw Object.assign(new Error("This handler is active in Application Settings."), { status: 409 }); await rm(path); const testPayloads = handlerTestPayloads(settings); if (file in testPayloads) { delete testPayloads[file]; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); } return reply.code(204).send(); });
832
948
  server.post<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler/test", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug); const body = objectBody(request.body); if (!body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) throw Object.assign(new Error("The 'payload' field must be a JSON object."), { status: 400 }); applicationHandlerPath(request.params.slug, request.query.path); const settings = structuredClone(application.record.settings) as Record<string, unknown>, testPayloads = handlerTestPayloads(settings); testPayloads[String(request.query.path)] = body.payload; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); return application.runtime.testHandler(applicationHandlerPath(request.params.slug, request.query.path), body.payload as Record<string, unknown>, typeof body.content === "string" ? body.content : undefined); });
833
- server.get<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => { if (!guard(request, reply)) return; return { calls: registeredApplication(request.params.slug).runtime.logs.list() }; });
949
+ server.get<{ Params: { slug: string }; Querystring: { summary?: string; limit?: string; start?: string; end?: string } }>("/api/applications/:slug/logs", async (request, reply) => {
950
+ if (!guard(request, reply)) return;
951
+ const logs = registeredApplication(request.params.slug).runtime.logs;
952
+ if (request.query.summary !== "1") return { calls: logs.list() };
953
+ const limit = request.query.limit === undefined ? 250 : Number(request.query.limit);
954
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250) throw Object.assign(new Error("The 'limit' query parameter must be an integer from 1 to 250."), { status: 400 });
955
+ const parseDate = (value: string | undefined, name: string) => {
956
+ if (!value) return undefined;
957
+ const date = new Date(value);
958
+ if (Number.isNaN(date.getTime())) throw Object.assign(new Error(`The '${name}' query parameter must be a valid ISO date.`), { status: 400 });
959
+ return date;
960
+ };
961
+ return { calls: logs.listSummaries(limit, parseDate(request.query.start, "start"), parseDate(request.query.end, "end")) };
962
+ });
963
+ server.get<{ Params: { slug: string; callId: string } }>("/api/applications/:slug/logs/calls/:callId", async (request, reply) => {
964
+ if (!guard(request, reply)) return;
965
+ const call = registeredApplication(request.params.slug).runtime.logs.get(request.params.callId);
966
+ if (!call) throw Object.assign(new Error("Log call not found."), { status: 404 });
967
+ return { call };
968
+ });
834
969
  server.delete<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => { if (!guard(request, reply)) return; await registeredApplication(request.params.slug).runtime.logs.clear(); return reply.code(204).send(); });
835
970
  server.post<{ Params: { slug: string } }>("/api/applications/:slug/logs/ticket", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), ticket = randomUUID(); applicationLogTickets.set(ticket, { slug: application.record.slug, expiresAt: Date.now() + 60_000 }); const base = new URL(config.publicBaseUrl ?? `http://127.0.0.1:${config.port}`); base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; base.pathname = `/api/applications/${encodeURIComponent(application.record.slug)}/logs/stream`; base.search = `ticket=${encodeURIComponent(ticket)}`; return { ticket, url: base.toString() }; });
836
971
  server.get("/api/applications/:slug/logs/stream", { websocket: true }, (socket, request) => { const ticket = (request.query as { ticket?: string }).ticket; const value = ticket && applicationLogTickets.get(ticket); if (!value || value.slug !== (request.params as { slug: string }).slug || value.expiresAt < Date.now()) return socket.close(1008, "Invalid ticket"); applicationLogTickets.delete(ticket!); const unsubscribe = registeredApplication(value.slug).runtime.logs.on((event) => socket.send(JSON.stringify(event))); socket.on("close", unsubscribe); });