@poncho-ai/cli 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ import type {
55
55
  ApiThreadSummary,
56
56
  } from "@poncho-ai/sdk";
57
57
  import { getTextContent } from "@poncho-ai/sdk";
58
+ import { buildZip, type ZipEntry } from "./vfs-zip.js";
58
59
  import {
59
60
  AgentBridge,
60
61
  ResendAdapter,
@@ -134,6 +135,22 @@ const collectToolCallIds = (msgs: Message[]): Set<string> => {
134
135
  }
135
136
  return ids;
136
137
  };
138
+
139
+ const MEMORY_VFILE_PATH = "/memory.md";
140
+
141
+ const isSafeVfsPath = (p: string): boolean => {
142
+ if (typeof p !== "string" || p.length === 0) return false;
143
+ if (!p.startsWith("/")) return false;
144
+ if (p.includes("\0")) return false;
145
+ const segments = p.split("/").slice(1);
146
+ if (p !== "/" && segments[segments.length - 1] === "") return false;
147
+ for (const seg of segments) {
148
+ if (seg === "" && p !== "/") return false;
149
+ if (seg === "." || seg === "..") return false;
150
+ }
151
+ return true;
152
+ };
153
+
137
154
  const serverlessLog = createLogger("serverless");
138
155
  import {
139
156
  type DeployTarget,
@@ -307,6 +324,13 @@ export type RequestHandler = ((
307
324
  _finishConversationStream?: (conversationId: string) => void;
308
325
  _checkAndFireReminders?: () => Promise<{ fired: string[]; count: number; duration: number }>;
309
326
  _reminderPollIntervalMs?: number;
327
+ _buildTurnParameters?: (
328
+ conversation: Conversation,
329
+ opts?: {
330
+ bodyParameters?: Record<string, unknown>;
331
+ messagingMetadata?: { platform: string; sender: { id: string; name?: string | null }; threadId?: string };
332
+ },
333
+ ) => Record<string, unknown>;
310
334
  };
311
335
 
312
336
  export const createRequestHandler = async (options?: {
@@ -355,6 +379,32 @@ export const createRequestHandler = async (options?: {
355
379
  const conversationEventStreams = new Map<string, ConversationEventStream>();
356
380
  type EventCallback = (event: AgentEvent) => void;
357
381
  const conversationEventCallbacks = new Map<string, Set<EventCallback>>();
382
+ // Per-conversation replay-buffer cap. Live subscribers get full events; the
383
+ // buffer is just so a reconnecting client can catch up. Keep the most recent
384
+ // N events to bound memory.
385
+ const MAX_BUFFERED_EVENTS_PER_CONVERSATION = 1000;
386
+ // Deep-clone an event with any string > 4 KB replaced by a placeholder. Used
387
+ // when buffering for replay: a reconnecting client doesn't need fresh
388
+ // screenshots/large blobs (they're persisted in the conversation), and
389
+ // accumulating them caused OOMs (e.g. tool:completed for browser_screenshot
390
+ // carries a ~134 KB base64 JPEG per call).
391
+ const STRIP_LARGE_STRING_BYTES = 4096;
392
+ const stripLargeStringsForBuffer = (value: unknown): unknown => {
393
+ if (typeof value === "string") {
394
+ return value.length > STRIP_LARGE_STRING_BYTES
395
+ ? `[stripped-for-replay len=${value.length}]`
396
+ : value;
397
+ }
398
+ if (Array.isArray(value)) return value.map(stripLargeStringsForBuffer);
399
+ if (value && typeof value === "object") {
400
+ const out: Record<string, unknown> = {};
401
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
402
+ out[k] = stripLargeStringsForBuffer(v);
403
+ }
404
+ return out;
405
+ }
406
+ return value;
407
+ };
358
408
  const broadcastEvent = (conversationId: string, event: AgentEvent): void => {
359
409
  let stream = conversationEventStreams.get(conversationId);
360
410
  if (!stream) {
@@ -365,7 +415,10 @@ export const createRequestHandler = async (options?: {
365
415
  // Buffering them for reconnect replay grew to multi-GB and OOM'd the process;
366
416
  // they're ephemeral like browser:status and should never replay.
367
417
  if (event.type !== "browser:frame") {
368
- stream.buffer.push(event);
418
+ stream.buffer.push(stripLargeStringsForBuffer(event) as AgentEvent);
419
+ if (stream.buffer.length > MAX_BUFFERED_EVENTS_PER_CONVERSATION) {
420
+ stream.buffer.splice(0, stream.buffer.length - MAX_BUFFERED_EVENTS_PER_CONVERSATION);
421
+ }
369
422
  }
370
423
  for (const subscriber of stream.subscribers) {
371
424
  try {
@@ -681,6 +734,40 @@ export const createRequestHandler = async (options?: {
681
734
  };
682
735
  };
683
736
 
737
+ // ---------------------------------------------------------------------------
738
+ // Single helper for assembling runInput.parameters across every turn entry
739
+ // point (HTTP route, messaging adapter, cron, reminder). All `__`-prefixed
740
+ // context params live here so adding a new one only requires one edit.
741
+ // ---------------------------------------------------------------------------
742
+ const buildTurnParameters = (
743
+ conversation: Conversation,
744
+ opts: {
745
+ bodyParameters?: Record<string, unknown>;
746
+ messagingMetadata?: {
747
+ platform: string;
748
+ sender: { id: string; name?: string | null };
749
+ threadId?: string;
750
+ };
751
+ } = {},
752
+ ): Record<string, unknown> => {
753
+ return withToolResultArchiveParam({
754
+ ...(opts.bodyParameters ?? {}),
755
+ ...buildRecallParams({
756
+ ownerId: conversation.ownerId,
757
+ tenantId: conversation.tenantId,
758
+ excludeConversationId: conversation.conversationId,
759
+ }),
760
+ ...(opts.messagingMetadata ? {
761
+ __messaging_platform: opts.messagingMetadata.platform,
762
+ __messaging_sender_id: opts.messagingMetadata.sender.id,
763
+ __messaging_sender_name: opts.messagingMetadata.sender.name ?? "",
764
+ __messaging_thread_id: opts.messagingMetadata.threadId,
765
+ } : {}),
766
+ __activeConversationId: conversation.conversationId,
767
+ __ownerId: conversation.ownerId,
768
+ }, conversation);
769
+ };
770
+
684
771
  // Subagent lifecycle extracted to AgentOrchestrator (Phase 5).
685
772
 
686
773
  // ---------------------------------------------------------------------------
@@ -790,6 +877,7 @@ export const createRequestHandler = async (options?: {
790
877
  let runContextWindow = 0;
791
878
  let runContinuation = false;
792
879
  let runContinuationMessages: Message[] | undefined;
880
+ let runHarnessMessages: Message[] | undefined;
793
881
  let runSteps = 0;
794
882
  let runMaxSteps: number | undefined;
795
883
 
@@ -828,17 +916,22 @@ export const createRequestHandler = async (options?: {
828
916
  const runInput = {
829
917
  task: input.task,
830
918
  conversationId,
919
+ tenantId: latestConversation?.tenantId ?? undefined,
831
920
  messages: historyMessages,
832
921
  files: input.files,
833
- parameters: withToolResultArchiveParam({
834
- ...(input.metadata ? {
835
- __messaging_platform: input.metadata.platform,
836
- __messaging_sender_id: input.metadata.sender.id,
837
- __messaging_sender_name: input.metadata.sender.name ?? "",
838
- __messaging_thread_id: input.metadata.threadId,
839
- } : {}),
840
- __activeConversationId: conversationId,
841
- }, latestConversation ?? { _toolResultArchive: {} } as Conversation),
922
+ parameters: latestConversation
923
+ ? buildTurnParameters(latestConversation, {
924
+ messagingMetadata: input.metadata,
925
+ })
926
+ : withToolResultArchiveParam({
927
+ ...(input.metadata ? {
928
+ __messaging_platform: input.metadata.platform,
929
+ __messaging_sender_id: input.metadata.sender.id,
930
+ __messaging_sender_name: input.metadata.sender.name ?? "",
931
+ __messaging_thread_id: input.metadata.threadId,
932
+ } : {}),
933
+ __activeConversationId: conversationId,
934
+ }, { _toolResultArchive: {} } as Conversation),
842
935
  };
843
936
 
844
937
  try {
@@ -939,6 +1032,7 @@ export const createRequestHandler = async (options?: {
939
1032
  });
940
1033
  runContinuation = execution.runContinuation;
941
1034
  runContinuationMessages = execution.runContinuationMessages;
1035
+ runHarnessMessages = execution.runHarnessMessages;
942
1036
  runSteps = execution.runSteps;
943
1037
  runMaxSteps = execution.runMaxSteps;
944
1038
  runContextTokens = execution.runContextTokens;
@@ -962,7 +1056,11 @@ export const createRequestHandler = async (options?: {
962
1056
  contextWindow: runContextWindow,
963
1057
  continuation: runContinuation,
964
1058
  continuationMessages: runContinuationMessages,
965
- harnessMessages: runContinuationMessages,
1059
+ // Prefer the cancellation/end-of-run snapshot from the harness so
1060
+ // _harnessMessages stays in sync with what the model just saw,
1061
+ // even on aborted runs. Falls back to continuationMessages when
1062
+ // the run completed via continuation.
1063
+ harnessMessages: runHarnessMessages ?? runContinuationMessages,
966
1064
  toolResultArchive: harness.getToolResultArchive(conversationId),
967
1065
  }, { shouldRebuildCanonical: true });
968
1066
  });
@@ -1428,7 +1526,7 @@ export const createRequestHandler = async (options?: {
1428
1526
  }
1429
1527
 
1430
1528
  if (webUiEnabled) {
1431
- if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/"))) {
1529
+ if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/") || pathname.startsWith("/f/"))) {
1432
1530
  writeHtml(response, 200, renderWebUiHtml({ agentName, isDev: !isProduction }));
1433
1531
  return;
1434
1532
  }
@@ -2650,13 +2748,16 @@ export const createRequestHandler = async (options?: {
2650
2748
  && Array.isArray(conversation._continuationMessages)
2651
2749
  && conversation._continuationMessages.length > 0
2652
2750
  && !hasPendingApprovals;
2751
+ const verboseDev = process.env.PONCHO_DEV_VERBOSE === "1";
2653
2752
  writeJson(response, 200, {
2654
2753
  conversation: {
2655
2754
  ...conversation,
2656
2755
  messages: conversation.messages.map(normalizeMessageForClient).filter((m): m is Message => m !== null),
2657
2756
  pendingApprovals: storedPending,
2658
2757
  _continuationMessages: undefined,
2659
- _harnessMessages: undefined,
2758
+ // In verbose dev mode the web UI exposes a toggle to inspect the
2759
+ // raw harness messages sent to the model API. Strip it otherwise.
2760
+ _harnessMessages: verboseDev ? conversation._harnessMessages : undefined,
2660
2761
  // The browser has no use for the archive; make sure we never ship
2661
2762
  // it back even if the conversation was loaded via getWithArchive.
2662
2763
  _toolResultArchive: undefined,
@@ -2665,6 +2766,7 @@ export const createRequestHandler = async (options?: {
2665
2766
  hasActiveRun: hasActiveRun || hasPendingCallbackResults,
2666
2767
  hasRunningSubagents,
2667
2768
  needsContinuation,
2769
+ verboseDev,
2668
2770
  });
2669
2771
  return;
2670
2772
  }
@@ -2789,6 +2891,22 @@ export const createRequestHandler = async (options?: {
2789
2891
  writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
2790
2892
  return;
2791
2893
  }
2894
+ if (vfsPath === MEMORY_VFILE_PATH) {
2895
+ try {
2896
+ const memory = await engine.memory.get(tenantId);
2897
+ const data = Buffer.from(memory.content, "utf-8");
2898
+ response.writeHead(200, {
2899
+ "Content-Type": "text/markdown; charset=utf-8",
2900
+ "Content-Length": data.length,
2901
+ "Content-Disposition": `inline; filename="memory.md"`,
2902
+ "Cache-Control": "no-cache",
2903
+ });
2904
+ response.end(data);
2905
+ } catch (err) {
2906
+ writeJson(response, 500, { code: "READ_FAILED", message: (err as Error)?.message ?? "Failed to read memory" });
2907
+ }
2908
+ return;
2909
+ }
2792
2910
  try {
2793
2911
  const stat = await engine.vfs.stat(tenantId, vfsPath);
2794
2912
  if (!stat || stat.type !== "file") {
@@ -2823,6 +2941,273 @@ export const createRequestHandler = async (options?: {
2823
2941
  return;
2824
2942
  }
2825
2943
 
2944
+ if (vfsMatch && request.method === "PUT") {
2945
+ const rawPath = "/" + decodeURIComponent(vfsMatch[1] ?? "");
2946
+ const tenantId = ctx.tenantId ?? "__default__";
2947
+ const engine = harness.storageEngine;
2948
+ if (!engine) {
2949
+ writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
2950
+ return;
2951
+ }
2952
+ if (!isSafeVfsPath(rawPath) || rawPath === "/") {
2953
+ writeJson(response, 400, { code: "BAD_PATH", message: "Invalid path" });
2954
+ return;
2955
+ }
2956
+ if (rawPath === MEMORY_VFILE_PATH) {
2957
+ try {
2958
+ const chunks: Buffer[] = [];
2959
+ for await (const chunk of request) chunks.push(chunk as Buffer);
2960
+ const body = Buffer.concat(chunks);
2961
+ const content = body.toString("utf-8").trim();
2962
+ const memory = await engine.memory.update(content, tenantId);
2963
+ writeJson(response, 200, {
2964
+ path: MEMORY_VFILE_PATH,
2965
+ size: Buffer.byteLength(memory.content, "utf-8"),
2966
+ mimeType: "text/markdown",
2967
+ updatedAt: memory.updatedAt,
2968
+ });
2969
+ } catch (err) {
2970
+ writeJson(response, 500, { code: "WRITE_FAILED", message: (err as Error)?.message ?? "Failed to write memory" });
2971
+ }
2972
+ return;
2973
+ }
2974
+ const allowOverwrite = requestUrl.searchParams.get("overwrite") === "1";
2975
+ try {
2976
+ const existing = await engine.vfs.stat(tenantId, rawPath);
2977
+ if (existing && !allowOverwrite) {
2978
+ writeJson(response, 409, { code: "EXISTS", message: "File already exists" });
2979
+ return;
2980
+ }
2981
+ if (existing && existing.type !== "file") {
2982
+ writeJson(response, 409, { code: "NOT_A_FILE", message: "Path exists and is not a file" });
2983
+ return;
2984
+ }
2985
+ const chunks: Buffer[] = [];
2986
+ for await (const chunk of request) chunks.push(chunk as Buffer);
2987
+ const body = Buffer.concat(chunks);
2988
+ const mimeType = (request.headers["content-type"] as string | undefined)?.split(";")[0]?.trim() || undefined;
2989
+ await engine.vfs.writeFile(tenantId, rawPath, new Uint8Array(body), mimeType);
2990
+ if (rawPath === "/skills" || rawPath.startsWith("/skills/")) {
2991
+ harness.invalidateSkillsForTenant(tenantId);
2992
+ }
2993
+ const stat = await engine.vfs.stat(tenantId, rawPath);
2994
+ writeJson(response, 200, {
2995
+ path: rawPath,
2996
+ size: stat?.size ?? body.length,
2997
+ mimeType: stat?.mimeType ?? mimeType ?? null,
2998
+ updatedAt: stat?.updatedAt ?? Date.now(),
2999
+ });
3000
+ } catch (err) {
3001
+ const message = (err as Error)?.message ?? "Upload failed";
3002
+ const code = /quota|too large|exceed/i.test(message) ? 413 : 500;
3003
+ writeJson(response, code, { code: code === 413 ? "QUOTA" : "WRITE_FAILED", message });
3004
+ }
3005
+ return;
3006
+ }
3007
+
3008
+ if (vfsMatch && request.method === "DELETE") {
3009
+ const rawPath = "/" + decodeURIComponent(vfsMatch[1] ?? "");
3010
+ const tenantId = ctx.tenantId ?? "__default__";
3011
+ const engine = harness.storageEngine;
3012
+ if (!engine) {
3013
+ writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
3014
+ return;
3015
+ }
3016
+ if (!isSafeVfsPath(rawPath) || rawPath === "/") {
3017
+ writeJson(response, 400, { code: "BAD_PATH", message: "Invalid path" });
3018
+ return;
3019
+ }
3020
+ if (rawPath === MEMORY_VFILE_PATH) {
3021
+ writeJson(response, 400, {
3022
+ code: "RESERVED",
3023
+ message: "memory.md cannot be deleted; clear its contents instead.",
3024
+ });
3025
+ return;
3026
+ }
3027
+ try {
3028
+ const stat = await engine.vfs.stat(tenantId, rawPath);
3029
+ if (!stat) {
3030
+ writeJson(response, 404, { code: "NOT_FOUND", message: "Path not found" });
3031
+ return;
3032
+ }
3033
+ if (stat.type === "directory") {
3034
+ await engine.vfs.deleteDir(tenantId, rawPath, true);
3035
+ } else {
3036
+ await engine.vfs.deleteFile(tenantId, rawPath);
3037
+ }
3038
+ if (rawPath === "/skills" || rawPath.startsWith("/skills/")) {
3039
+ harness.invalidateSkillsForTenant(tenantId);
3040
+ }
3041
+ writeJson(response, 200, { ok: true, path: rawPath });
3042
+ } catch (err) {
3043
+ writeJson(response, 500, { code: "DELETE_FAILED", message: (err as Error)?.message ?? "Failed to delete" });
3044
+ }
3045
+ return;
3046
+ }
3047
+
3048
+ if (pathname === "/api/vfs-archive" && request.method === "GET") {
3049
+ const dirPath = requestUrl.searchParams.get("path") ?? "/";
3050
+ const tenantId = ctx.tenantId ?? "__default__";
3051
+ const engine = harness.storageEngine;
3052
+ if (!engine) {
3053
+ writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
3054
+ return;
3055
+ }
3056
+ if (!isSafeVfsPath(dirPath)) {
3057
+ writeJson(response, 400, { code: "BAD_PATH", message: "Invalid path" });
3058
+ return;
3059
+ }
3060
+ try {
3061
+ if (dirPath !== "/") {
3062
+ const stat = await engine.vfs.stat(tenantId, dirPath);
3063
+ if (!stat || stat.type !== "directory") {
3064
+ writeJson(response, 404, { code: "NOT_FOUND", message: "Directory not found" });
3065
+ return;
3066
+ }
3067
+ }
3068
+ const entries: ZipEntry[] = [];
3069
+ const walk = async (dir: string, prefix: string): Promise<void> => {
3070
+ const children = await engine.vfs.readdir(tenantId, dir);
3071
+ for (const child of children) {
3072
+ if (dir === "/" && child.name === "memory.md") continue;
3073
+ const childPath = dir === "/" ? "/" + child.name : dir + "/" + child.name;
3074
+ const relName = prefix === "" ? child.name : prefix + "/" + child.name;
3075
+ if (child.type === "directory") {
3076
+ await walk(childPath, relName);
3077
+ } else if (child.type === "file") {
3078
+ const content = await engine.vfs.readFile(tenantId, childPath);
3079
+ const stat = await engine.vfs.stat(tenantId, childPath);
3080
+ entries.push({
3081
+ name: relName,
3082
+ content,
3083
+ mtime: stat?.updatedAt ? new Date(stat.updatedAt) : undefined,
3084
+ });
3085
+ }
3086
+ }
3087
+ };
3088
+ await walk(dirPath, "");
3089
+ if (dirPath === "/") {
3090
+ const memory = await engine.memory.get(tenantId);
3091
+ entries.push({
3092
+ name: "memory.md",
3093
+ content: new Uint8Array(Buffer.from(memory.content, "utf-8")),
3094
+ mtime: memory.updatedAt > 0 ? new Date(memory.updatedAt) : undefined,
3095
+ });
3096
+ }
3097
+ const archiveName = (dirPath === "/" ? "vfs" : (dirPath.split("/").pop() || "archive")) + ".zip";
3098
+ const zip = buildZip(entries);
3099
+ response.writeHead(200, {
3100
+ "Content-Type": "application/zip",
3101
+ "Content-Length": zip.length,
3102
+ "Content-Disposition": `attachment; filename="${archiveName}"`,
3103
+ "Cache-Control": "no-cache",
3104
+ });
3105
+ response.end(zip);
3106
+ } catch (err) {
3107
+ writeJson(response, 500, { code: "ARCHIVE_FAILED", message: (err as Error)?.message ?? "Failed to build archive" });
3108
+ }
3109
+ return;
3110
+ }
3111
+
3112
+ if (pathname === "/api/vfs-list" && request.method === "GET") {
3113
+ const dirPath = requestUrl.searchParams.get("path") ?? "/";
3114
+ const tenantId = ctx.tenantId ?? "__default__";
3115
+ const engine = harness.storageEngine;
3116
+ if (!engine) {
3117
+ writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
3118
+ return;
3119
+ }
3120
+ if (!isSafeVfsPath(dirPath)) {
3121
+ writeJson(response, 400, { code: "BAD_PATH", message: "Invalid path" });
3122
+ return;
3123
+ }
3124
+ try {
3125
+ if (dirPath !== "/") {
3126
+ const stat = await engine.vfs.stat(tenantId, dirPath);
3127
+ if (!stat || stat.type !== "directory") {
3128
+ writeJson(response, 404, { code: "NOT_FOUND", message: "Directory not found" });
3129
+ return;
3130
+ }
3131
+ }
3132
+ const rawDirEntries = await engine.vfs.readdir(tenantId, dirPath);
3133
+ const dirEntries = dirPath === "/"
3134
+ ? rawDirEntries.filter((e) => e.name !== "memory.md")
3135
+ : rawDirEntries;
3136
+ const entries: Array<{
3137
+ name: string;
3138
+ type: "file" | "directory" | "symlink";
3139
+ size: number;
3140
+ mimeType: string | null;
3141
+ updatedAt: number | null;
3142
+ }> = await Promise.all(dirEntries.map(async (entry) => {
3143
+ const childPath = dirPath === "/" ? "/" + entry.name : dirPath + "/" + entry.name;
3144
+ const stat = await engine.vfs.stat(tenantId, childPath);
3145
+ return {
3146
+ name: entry.name,
3147
+ type: entry.type,
3148
+ size: stat?.size ?? 0,
3149
+ mimeType: stat?.mimeType ?? null,
3150
+ updatedAt: stat?.updatedAt ?? null,
3151
+ };
3152
+ }));
3153
+ if (dirPath === "/") {
3154
+ const memory = await engine.memory.get(tenantId);
3155
+ entries.push({
3156
+ name: "memory.md",
3157
+ type: "file",
3158
+ size: Buffer.byteLength(memory.content, "utf-8"),
3159
+ mimeType: "text/markdown",
3160
+ updatedAt: memory.updatedAt > 0 ? memory.updatedAt : null,
3161
+ });
3162
+ }
3163
+ entries.sort((a, b) => {
3164
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
3165
+ return a.name.localeCompare(b.name);
3166
+ });
3167
+ const usage = await engine.vfs.getUsage(tenantId);
3168
+ writeJson(response, 200, { path: dirPath, entries, usage });
3169
+ } catch (err) {
3170
+ writeJson(response, 500, { code: "READDIR_FAILED", message: (err as Error)?.message ?? "Failed to list directory" });
3171
+ }
3172
+ return;
3173
+ }
3174
+
3175
+ if (pathname === "/api/vfs-mkdir" && request.method === "POST") {
3176
+ const tenantId = ctx.tenantId ?? "__default__";
3177
+ const engine = harness.storageEngine;
3178
+ if (!engine) {
3179
+ writeJson(response, 500, { code: "NO_ENGINE", message: "Storage engine not available" });
3180
+ return;
3181
+ }
3182
+ try {
3183
+ const chunks: Buffer[] = [];
3184
+ for await (const chunk of request) chunks.push(chunk as Buffer);
3185
+ const body = JSON.parse(Buffer.concat(chunks).toString() || "{}") as { path?: string };
3186
+ const dirPath = body.path ?? "";
3187
+ if (!isSafeVfsPath(dirPath) || dirPath === "/") {
3188
+ writeJson(response, 400, { code: "BAD_PATH", message: "Invalid path" });
3189
+ return;
3190
+ }
3191
+ if (dirPath === MEMORY_VFILE_PATH) {
3192
+ writeJson(response, 400, { code: "RESERVED", message: "memory.md is reserved" });
3193
+ return;
3194
+ }
3195
+ const existing = await engine.vfs.stat(tenantId, dirPath);
3196
+ if (existing) {
3197
+ writeJson(response, 409, { code: "EXISTS", message: "Path already exists" });
3198
+ return;
3199
+ }
3200
+ await engine.vfs.mkdir(tenantId, dirPath, true);
3201
+ if (dirPath === "/skills" || dirPath.startsWith("/skills/")) {
3202
+ harness.invalidateSkillsForTenant(tenantId);
3203
+ }
3204
+ writeJson(response, 200, { path: dirPath });
3205
+ } catch (err) {
3206
+ writeJson(response, 500, { code: "MKDIR_FAILED", message: (err as Error)?.message ?? "Failed to create directory" });
3207
+ }
3208
+ return;
3209
+ }
3210
+
2826
3211
  if (pathname === "/api/slash-commands" && request.method === "GET") {
2827
3212
  const skills: ApiSlashCommand[] = harness.listSkills().map((s) => ({
2828
3213
  command: "/" + s.name,
@@ -3104,6 +3489,10 @@ export const createRequestHandler = async (options?: {
3104
3489
  let checkpointedRun = false;
3105
3490
  let runCancelled = false;
3106
3491
  let runContinuationMessages: Message[] | undefined;
3492
+ // Snapshot of the harness's in-flight messages emitted with run:cancelled,
3493
+ // so the catch-path (executeConversationTurn threw) can still persist a
3494
+ // canonical history that includes the cancelled work.
3495
+ let cancelHarnessMessages: Message[] | undefined;
3107
3496
 
3108
3497
  // Hoist stable ids for this turn. The same userMessage / assistantId is
3109
3498
  // reused across every buildMessages() call so the in-flight assistant
@@ -3170,12 +3559,7 @@ export const createRequestHandler = async (options?: {
3170
3559
  task: messageText,
3171
3560
  conversationId,
3172
3561
  tenantId: ctx.tenantId ?? undefined,
3173
- parameters: withToolResultArchiveParam({
3174
- ...(bodyParameters ?? {}),
3175
- ...buildRecallParams({ ownerId, tenantId: ctx.tenantId, excludeConversationId: conversationId }),
3176
- __activeConversationId: conversationId,
3177
- __ownerId: ownerId,
3178
- }, conversation),
3562
+ parameters: buildTurnParameters(conversation, { bodyParameters }),
3179
3563
  messages: harnessMessages,
3180
3564
  files: files.length > 0 ? files : undefined,
3181
3565
  abortSignal: abortController.signal,
@@ -3200,6 +3584,7 @@ export const createRequestHandler = async (options?: {
3200
3584
  }
3201
3585
  if (event.type === "run:cancelled") {
3202
3586
  runCancelled = true;
3587
+ if (event.messages) cancelHarnessMessages = event.messages;
3203
3588
  }
3204
3589
  if (event.type === "compaction:completed") {
3205
3590
  if (event.compactedMessages) {
@@ -3327,7 +3712,17 @@ export const createRequestHandler = async (options?: {
3327
3712
  if (abortController.signal.aborted || runCancelled) {
3328
3713
  if (draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draft.sections.length > 0) {
3329
3714
  conversation.messages = buildMessages();
3330
- conversation.updatedAt = Date.now();
3715
+ // Keep _harnessMessages aligned with what the model just saw.
3716
+ // Without this, loadCanonicalHistory will hand the next turn a
3717
+ // pre-cancellation snapshot and the agent will have no memory of
3718
+ // the work it just did.
3719
+ applyTurnMetadata(conversation, {
3720
+ latestRunId,
3721
+ contextTokens: 0,
3722
+ contextWindow: 0,
3723
+ harnessMessages: cancelHarnessMessages,
3724
+ toolResultArchive: harness.getToolResultArchive(conversationId),
3725
+ }, { shouldRebuildCanonical: true });
3331
3726
  await conversationStore.update(conversation);
3332
3727
  }
3333
3728
  if (!checkpointedRun) {
@@ -3456,6 +3851,8 @@ export const createRequestHandler = async (options?: {
3456
3851
  const result = await runCronAgent(harness, task, conv.conversationId, historyMessages,
3457
3852
  conv._toolResultArchive,
3458
3853
  async (event) => { await telemetry.emit(event); },
3854
+ buildTurnParameters(conv),
3855
+ conv.tenantId,
3459
3856
  );
3460
3857
 
3461
3858
  const freshConv = await conversationStore.get(conv.conversationId);
@@ -3526,6 +3923,8 @@ export const createRequestHandler = async (options?: {
3526
3923
  broadcastEvent(convId, event);
3527
3924
  await telemetry.emit(event);
3528
3925
  },
3926
+ buildTurnParameters(conversation),
3927
+ conversation.tenantId,
3529
3928
  );
3530
3929
  finishConversationStream(convId);
3531
3930
 
@@ -3673,6 +4072,9 @@ export const createRequestHandler = async (options?: {
3673
4072
  harness, framedMessage, originConv.conversationId,
3674
4073
  originConv.messages ?? [],
3675
4074
  originConv._toolResultArchive,
4075
+ undefined,
4076
+ buildTurnParameters(originConv),
4077
+ originConv.tenantId,
3676
4078
  );
3677
4079
  if (result.response) {
3678
4080
  try {
@@ -3705,7 +4107,13 @@ export const createRequestHandler = async (options?: {
3705
4107
  `[reminder] ${reminder.task.slice(0, 80)} ${timestamp}`,
3706
4108
  );
3707
4109
  const convId = conversation.conversationId;
3708
- const result = await runCronAgent(harness, framedMessage, convId, []);
4110
+ const result = await runCronAgent(
4111
+ harness, framedMessage, convId, [],
4112
+ undefined,
4113
+ undefined,
4114
+ buildTurnParameters(conversation),
4115
+ conversation.tenantId,
4116
+ );
3709
4117
  const freshConv = await conversationStore.get(convId);
3710
4118
  if (freshConv) {
3711
4119
  freshConv.messages = buildCronMessages(framedMessage, [], result);
@@ -3741,6 +4149,7 @@ export const createRequestHandler = async (options?: {
3741
4149
  handler._finishConversationStream = finishConversationStream;
3742
4150
  handler._checkAndFireReminders = checkAndFireReminders;
3743
4151
  handler._reminderPollIntervalMs = reminderPollWindowMs;
4152
+ handler._buildTurnParameters = buildTurnParameters;
3744
4153
 
3745
4154
  // Recover stale subagent runs that were "running" when the server last stopped
3746
4155
  orchestrator.recoverStaleSubagents().catch(err =>
@@ -3785,6 +4194,7 @@ export const startDevServer = async (
3785
4194
  const activeRuns = handler._activeConversationRuns;
3786
4195
  const deferredCallbacks = handler._pendingCallbackNeeded;
3787
4196
  const runCallback = handler._processSubagentCallback;
4197
+ const buildParams = handler._buildTurnParameters;
3788
4198
  if (!harnessRef || !store) return;
3789
4199
 
3790
4200
  for (const [jobName, config] of entries) {
@@ -3845,6 +4255,8 @@ export const startDevServer = async (
3845
4255
  const result = await runCronAgent(harnessRef, task, convId, historyMessages,
3846
4256
  conversation._toolResultArchive,
3847
4257
  broadcastCh ? (ev) => broadcastCh(convId, ev) : undefined,
4258
+ buildParams?.(conversation),
4259
+ conversation.tenantId,
3848
4260
  );
3849
4261
  handler._finishConversationStream?.(convId);
3850
4262
 
@@ -3913,6 +4325,8 @@ export const startDevServer = async (
3913
4325
  const result = await runCronAgent(harnessRef, config.task, cronConvId, [],
3914
4326
  conversation._toolResultArchive,
3915
4327
  broadcast ? (ev) => broadcast(cronConvId!, ev) : undefined,
4328
+ buildParams?.(conversation),
4329
+ conversation.tenantId,
3916
4330
  );
3917
4331
  handler._finishConversationStream?.(cronConvId);
3918
4332
  const freshConv = await store.get(cronConvId);
@@ -4054,6 +4468,12 @@ export const buildCli = (): Command => {
4054
4468
  }
4055
4469
  setLogLevel(level as typeof valid[number]);
4056
4470
  }
4471
+ if (options.verbose) {
4472
+ // Surfaced to the request handler so the web UI can offer a toggle
4473
+ // between user-facing messages and the raw harness-message stream
4474
+ // sent to the model API.
4475
+ process.env.PONCHO_DEV_VERBOSE = "1";
4476
+ }
4057
4477
  if (process.stdout.isTTY && !process.env.NO_COLOR) {
4058
4478
  process.stdout.write("\n");
4059
4479
  for (const line of getMascotLines()) process.stdout.write(`${line}\n`);