@poncho-ai/harness 0.43.1 → 0.45.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.43.1",
3
+ "version": "0.45.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "mustache": "^4.2.0",
35
35
  "yaml": "^2.4.0",
36
36
  "zod": "^3.22.0",
37
- "@poncho-ai/sdk": "1.10.0"
37
+ "@poncho-ai/sdk": "1.11.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "esbuild": ">=0.17.0",
package/src/config.ts CHANGED
@@ -37,7 +37,26 @@ export interface UploadsConfig {
37
37
  endpoint?: string;
38
38
  }
39
39
 
40
- export type ToolAccess = boolean | "approval";
40
+ export type ToolAccess =
41
+ | boolean
42
+ | "approval"
43
+ | { access?: "approval"; dispatch?: "device" };
44
+
45
+ /**
46
+ * Normalize any ToolAccess value into a {access, dispatch} struct.
47
+ * `boolean` collapses to no special handling — the boolean only encodes
48
+ * enable/disable, not dispatch — callers gate behavior on `dispatch` and
49
+ * `access`.
50
+ */
51
+ export const normalizeToolAccess = (
52
+ value: ToolAccess | undefined,
53
+ ): { access?: "approval"; dispatch?: "device" } => {
54
+ if (value === "approval") return { access: "approval" };
55
+ if (value && typeof value === "object") {
56
+ return { access: value.access, dispatch: value.dispatch };
57
+ }
58
+ return {};
59
+ };
41
60
 
42
61
  /** @deprecated Use flat tool keys on `tools` instead. Kept for backward compat. */
43
62
  export type BuiltInToolToggles = {
package/src/harness.ts CHANGED
@@ -20,7 +20,7 @@ const costLog = createLogger("cost");
20
20
  const mcpLog = createLogger("mcp");
21
21
  const modelLog = createLogger("model");
22
22
  import type { UploadStore } from "./upload-store.js";
23
- import { PONCHO_UPLOAD_SCHEME, VFS_SCHEME, deriveUploadKey } from "./upload-store.js";
23
+ import { PONCHO_UPLOAD_SCHEME, VFS_SCHEME, decodeFileInputData, deriveUploadKey } from "./upload-store.js";
24
24
  import type { StorageEngine } from "./storage/engine.js";
25
25
  import { createStorageEngine, type StorageProvider } from "./storage/index.js";
26
26
  import {
@@ -30,13 +30,15 @@ import {
30
30
  createReminderStoreFromEngine,
31
31
  } from "./storage/store-adapters.js";
32
32
  import { BashEnvironmentManager } from "./vfs/bash-manager.js";
33
+ import type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
34
+ export type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
33
35
  import { createBashTool } from "./vfs/bash-tool.js";
34
36
  import { createReadFileTool } from "./vfs/read-file-tool.js";
35
37
  import { createEditFileTool } from "./vfs/edit-file-tool.js";
36
38
  import { createWriteFileTool } from "./vfs/write-file-tool.js";
37
39
  import { PonchoFsAdapter } from "./vfs/poncho-fs-adapter.js";
38
40
  import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
39
- import { loadPonchoConfig, resolveMemoryConfig, resolveStateConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
41
+ import { loadPonchoConfig, normalizeToolAccess, resolveMemoryConfig, resolveStateConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
40
42
  import { ponchoDocsTool } from "./default-tools.js";
41
43
  import {
42
44
  createMemoryStore,
@@ -123,6 +125,15 @@ export interface HarnessOptions {
123
125
  * `resolveStateConfig`, etc.) run as today regardless of source.
124
126
  */
125
127
  config?: PonchoConfig;
128
+ /**
129
+ * Read-only virtual mounts overlaid on the VFS. Each mount maps a VFS
130
+ * prefix (e.g. "/system/") to a local filesystem directory; reads under
131
+ * the prefix are served from local disk, writes are rejected. Used by
132
+ * platforms like PonchOS to expose deployment-shipped defaults (system
133
+ * jobs, system skills) without storing them in each tenant's VFS.
134
+ * Empty by default — no system mounts in the CLI / dev workflow.
135
+ */
136
+ virtualMounts?: VirtualMount[];
126
137
  }
127
138
 
128
139
  export interface HarnessRunOutput {
@@ -855,6 +866,8 @@ export class AgentHarness {
855
866
  storageEngine?: StorageEngine;
856
867
  /** Bash environment manager (creates per-tenant bash instances). */
857
868
  private bashManager?: BashEnvironmentManager;
869
+ /** Read-only virtual mounts overlaid on the VFS. Empty by default. */
870
+ private virtualMounts: VirtualMount[] = [];
858
871
 
859
872
  private resolveToolAccess(toolName: string): ToolAccess {
860
873
  const tools = this.loadedConfig?.tools;
@@ -865,7 +878,17 @@ export class AgentHarness {
865
878
  if (envOverride !== undefined) return envOverride;
866
879
 
867
880
  const flatValue = tools[toolName];
868
- if (typeof flatValue === "boolean" || flatValue === "approval") return flatValue;
881
+ if (
882
+ typeof flatValue === "boolean" ||
883
+ flatValue === "approval" ||
884
+ (flatValue !== null && typeof flatValue === "object" && !Array.isArray(flatValue) &&
885
+ // distinguish a ToolAccess object from the nested `defaults` /
886
+ // `byEnvironment` sibling fields by checking it has only the
887
+ // expected ToolAccess keys.
888
+ Object.keys(flatValue as object).every((k) => k === "access" || k === "dispatch"))
889
+ ) {
890
+ return flatValue as ToolAccess;
891
+ }
869
892
 
870
893
  const legacyValue = tools.defaults?.[toolName as keyof BuiltInToolToggles];
871
894
  if (legacyValue !== undefined) return legacyValue;
@@ -873,6 +896,11 @@ export class AgentHarness {
873
896
  return true;
874
897
  }
875
898
 
899
+ /** Returns the normalized {access, dispatch} mode for the tool. */
900
+ private resolveToolMode(toolName: string): { access?: "approval"; dispatch?: "device" } {
901
+ return normalizeToolAccess(this.resolveToolAccess(toolName));
902
+ }
903
+
876
904
  private isToolEnabled(name: string): boolean {
877
905
  const access = this.resolveToolAccess(name);
878
906
  if (access === false) return false;
@@ -1048,6 +1076,7 @@ export class AgentHarness {
1048
1076
  this.storageEngine = options.storageEngine;
1049
1077
  this.injectedStorageEngine = true;
1050
1078
  }
1079
+ this.virtualMounts = options.virtualMounts ?? [];
1051
1080
 
1052
1081
  if (options.toolDefinitions?.length) {
1053
1082
  this.dispatcher.registerMany(options.toolDefinitions);
@@ -1255,6 +1284,21 @@ export class AgentHarness {
1255
1284
  // "__default__" so dev-mode (no auth) conversations see the same VFS
1256
1285
  // namespace the Files sidebar writes to.
1257
1286
  const effectiveTenant = tenantId || "__default__";
1287
+ // Refresh the engine's path cache before fingerprinting. The cache is
1288
+ // the only thing `computeVfsSkillFingerprint` reads, and historically
1289
+ // it was only populated by `bash-manager.refreshPathCache` — chat-only
1290
+ // flows (no bash) left it empty, the patched writeFile's incremental
1291
+ // update became a no-op (it skips when the cache isn't loaded), the
1292
+ // fingerprint stuck at "" across runs, and any skill the agent (or a
1293
+ // client like PonchOS's iOS Files browser) authored after the harness
1294
+ // was first instantiated was invisible from the next turn onward.
1295
+ // One SELECT per turn is the cost of correctness here.
1296
+ const engineWithRefresh = this.storageEngine as unknown as {
1297
+ refreshPathCache?: (tenantId: string) => Promise<void>;
1298
+ };
1299
+ if (typeof engineWithRefresh.refreshPathCache === "function") {
1300
+ await engineWithRefresh.refreshPathCache(effectiveTenant);
1301
+ }
1258
1302
  const fingerprint = this.computeVfsSkillFingerprint(effectiveTenant);
1259
1303
  const cached = this.skillCache.get(effectiveTenant);
1260
1304
  if (cached && cached.fingerprint === fingerprint) {
@@ -1441,7 +1485,7 @@ export class AgentHarness {
1441
1485
  toolName: string,
1442
1486
  input: Record<string, unknown>,
1443
1487
  ): boolean {
1444
- if (this.resolveToolAccess(toolName) === "approval") {
1488
+ if (this.resolveToolMode(toolName).access === "approval") {
1445
1489
  return true;
1446
1490
  }
1447
1491
  if (toolName === "run_skill_script") {
@@ -1695,6 +1739,7 @@ export class AgentHarness {
1695
1739
  bashWorkingDir,
1696
1740
  config?.bash,
1697
1741
  config?.network,
1742
+ this.virtualMounts,
1698
1743
  );
1699
1744
  // Register VFS tools
1700
1745
  this.registerIfMissing(createBashTool(this.bashManager));
@@ -2256,7 +2301,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
2256
2301
  ];
2257
2302
  for (const file of input.files) {
2258
2303
  if (this.uploadStore) {
2259
- const buf = Buffer.from(file.data, "base64");
2304
+ const buf = await decodeFileInputData(file.data);
2260
2305
  const key = deriveUploadKey(buf, file.mediaType);
2261
2306
  const ref = await this.uploadStore.put(key, buf, file.mediaType);
2262
2307
  parts.push({
@@ -3089,8 +3134,19 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3089
3134
  name: string;
3090
3135
  input: Record<string, unknown>;
3091
3136
  }> = [];
3137
+ const deviceNeeded: Array<{
3138
+ approvalId: string;
3139
+ id: string;
3140
+ name: string;
3141
+ input: Record<string, unknown>;
3142
+ }> = [];
3092
3143
 
3093
- // Phase 1: classify all tool calls
3144
+ // Phase 1: classify all tool calls.
3145
+ // Approval gates run first; device dispatch fires only after approval is
3146
+ // cleared. On a device+approval tool the first dispatch pass yields the
3147
+ // approval, and the post-resume pass (where access is no longer required
3148
+ // because the message stream has the approve decision baked in) sees
3149
+ // dispatch="device" still set and falls into deviceNeeded below.
3094
3150
  for (const call of toolCalls) {
3095
3151
  if (isCancelled()) {
3096
3152
  yield emitCancellation();
@@ -3105,6 +3161,13 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3105
3161
  name: runtimeToolName,
3106
3162
  input: call.input,
3107
3163
  });
3164
+ } else if (this.resolveToolMode(runtimeToolName).dispatch === "device") {
3165
+ deviceNeeded.push({
3166
+ approvalId: `device_${randomUUID()}`,
3167
+ id: call.id,
3168
+ name: runtimeToolName,
3169
+ input: call.input,
3170
+ });
3108
3171
  } else {
3109
3172
  approvedCalls.push({
3110
3173
  id: call.id,
@@ -3157,6 +3220,52 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3157
3220
  return;
3158
3221
  }
3159
3222
 
3223
+ // Phase 2a': if any tools must dispatch to a connected device, emit
3224
+ // tool:device:required events for each and checkpoint with kind="device".
3225
+ // Consumers (e.g. PonchOS) route the events to the right WS and POST
3226
+ // the resulting tool output back through resumeRunFromCheckpoint.
3227
+ if (deviceNeeded.length > 0) {
3228
+ for (const dn of deviceNeeded) {
3229
+ yield pushEvent({
3230
+ type: "tool:device:required",
3231
+ tool: dn.name,
3232
+ input: dn.input,
3233
+ requestId: dn.approvalId,
3234
+ });
3235
+ }
3236
+
3237
+ const assistantContent = JSON.stringify({
3238
+ text: fullText,
3239
+ tool_calls: toolCalls.map(tc => ({
3240
+ id: tc.id,
3241
+ name: exposedToolNames.get(tc.name) ?? tc.name,
3242
+ input: tc.input,
3243
+ })),
3244
+ });
3245
+ const assistantMsg: Message = {
3246
+ role: "assistant",
3247
+ content: assistantContent,
3248
+ metadata: { timestamp: now(), id: randomUUID(), step, runId },
3249
+ };
3250
+ const deltaMessages = [...messages.slice(inputMessageCount), assistantMsg];
3251
+ yield pushEvent({
3252
+ type: "tool:device:checkpoint",
3253
+ approvals: deviceNeeded.map(dn => ({
3254
+ approvalId: dn.approvalId,
3255
+ tool: dn.name,
3256
+ toolCallId: dn.id,
3257
+ input: dn.input,
3258
+ })),
3259
+ checkpointMessages: deltaMessages,
3260
+ pendingToolCalls: toolCalls.map(tc => ({
3261
+ id: tc.id,
3262
+ name: exposedToolNames.get(tc.name) ?? tc.name,
3263
+ input: tc.input,
3264
+ })),
3265
+ });
3266
+ return;
3267
+ }
3268
+
3160
3269
  // Phase 2b: no approvals needed — execute all auto-approved calls
3161
3270
  const batchStart = now();
3162
3271
  if (isCancelled()) {
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export * from "./telemetry.js";
21
21
  export * from "./secrets-store.js";
22
22
  export * from "./storage/index.js";
23
23
  export * from "./storage/store-adapters.js";
24
- export { PonchoFsAdapter } from "./vfs/poncho-fs-adapter.js";
24
+ export { PonchoFsAdapter, type VirtualMount } from "./vfs/poncho-fs-adapter.js";
25
25
  export { BashEnvironmentManager } from "./vfs/bash-manager.js";
26
26
  export { createBashTool } from "./vfs/bash-tool.js";
27
27
  export * from "./tenant-token.js";
@@ -1511,6 +1511,52 @@ export class AgentOrchestrator {
1511
1511
  }
1512
1512
  return results;
1513
1513
  },
1514
+
1515
+ getTranscript: async (opts) => {
1516
+ const conversation = await this.conversationStore.get(opts.subagentId);
1517
+ if (!conversation) {
1518
+ throw new Error(`Subagent "${opts.subagentId}" not found.`);
1519
+ }
1520
+ if (!conversation.parentConversationId) {
1521
+ throw new Error(`Conversation "${opts.subagentId}" is not a subagent.`);
1522
+ }
1523
+ if (conversation.parentConversationId !== opts.parentConversationId) {
1524
+ throw new Error(`Subagent "${opts.subagentId}" was not spawned by this conversation.`);
1525
+ }
1526
+
1527
+ const all = conversation.messages;
1528
+ let filtered: Message[];
1529
+ if (opts.mode === "final") {
1530
+ let lastAssistant: Message | undefined;
1531
+ for (let i = all.length - 1; i >= 0; i--) {
1532
+ if (all[i]!.role === "assistant") {
1533
+ lastAssistant = all[i];
1534
+ break;
1535
+ }
1536
+ }
1537
+ filtered = lastAssistant ? [lastAssistant] : [];
1538
+ } else if (opts.mode === "assistant") {
1539
+ filtered = all.filter((m) => m.role === "assistant");
1540
+ } else {
1541
+ filtered = all;
1542
+ }
1543
+
1544
+ const startIndex = Math.max(0, opts.sinceIndex ?? 0);
1545
+ const sliced = filtered.slice(startIndex);
1546
+ const cap = opts.maxMessages !== undefined && opts.maxMessages >= 0 ? opts.maxMessages : sliced.length;
1547
+ const messages = sliced.slice(0, cap);
1548
+ const truncated = startIndex + messages.length < filtered.length;
1549
+
1550
+ return {
1551
+ subagentId: conversation.conversationId,
1552
+ task: conversation.subagentMeta?.task ?? conversation.title,
1553
+ status: conversation.subagentMeta?.status ?? "stopped",
1554
+ totalMessages: filtered.length,
1555
+ startIndex,
1556
+ messages,
1557
+ truncated,
1558
+ };
1559
+ },
1514
1560
  };
1515
1561
  }
1516
1562
 
@@ -24,7 +24,7 @@ import type { AgentEvent, FileInput, Message } from "@poncho-ai/sdk";
24
24
  import { createLogger } from "@poncho-ai/sdk";
25
25
  import type { AgentHarness } from "../harness.js";
26
26
  import type { ConversationStore } from "../state.js";
27
- import { deriveUploadKey } from "../upload-store.js";
27
+ import { decodeFileInputData, deriveUploadKey } from "../upload-store.js";
28
28
  import { withToolResultArchiveParam } from "./continuation.js";
29
29
  import { resolveRunRequest } from "./history.js";
30
30
  import {
@@ -104,7 +104,7 @@ export const runConversationTurn = async (
104
104
  if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
105
105
  const uploadedParts = await Promise.all(
106
106
  opts.files.map(async (f) => {
107
- const buf = Buffer.from(f.data, "base64");
107
+ const buf = await decodeFileInputData(f.data);
108
108
  const key = deriveUploadKey(buf, f.mediaType);
109
109
  const ref = await opts.harness.uploadStore!.put(key, buf, f.mediaType);
110
110
  return {
@@ -257,6 +257,34 @@ export const runConversationTurn = async (
257
257
  checkpointMessages: undefined,
258
258
  baseMessageCount: historyMessages.length,
259
259
  pendingToolCalls: [],
260
+ kind: "approval",
261
+ },
262
+ ];
263
+ conversation.updatedAt = Date.now();
264
+ await opts.conversationStore.update(conversation);
265
+ }
266
+ await persistDraft();
267
+ }
268
+ if (event.type === "tool:device:required") {
269
+ const toolText = `- device dispatch \`${event.tool}\``;
270
+ draft.toolTimeline.push(toolText);
271
+ draft.currentTools.push(toolText);
272
+ const existing = Array.isArray(conversation.pendingApprovals)
273
+ ? conversation.pendingApprovals
274
+ : [];
275
+ if (!existing.some((a) => a.approvalId === event.requestId)) {
276
+ conversation.pendingApprovals = [
277
+ ...existing,
278
+ {
279
+ approvalId: event.requestId,
280
+ runId: latestRunId || conversation.runtimeRunId || "",
281
+ tool: event.tool,
282
+ toolCallId: undefined,
283
+ input: (event.input ?? {}) as Record<string, unknown>,
284
+ checkpointMessages: undefined,
285
+ baseMessageCount: historyMessages.length,
286
+ pendingToolCalls: [],
287
+ kind: "device",
260
288
  },
261
289
  ];
262
290
  conversation.updatedAt = Date.now();
@@ -272,6 +300,24 @@ export const runConversationTurn = async (
272
300
  checkpointMessages: event.checkpointMessages,
273
301
  baseMessageCount: historyMessages.length,
274
302
  pendingToolCalls: event.pendingToolCalls,
303
+ kind: "approval",
304
+ });
305
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
306
+ opts.conversationId,
307
+ );
308
+ conversation.updatedAt = Date.now();
309
+ await opts.conversationStore.update(conversation);
310
+ checkpointedRun = true;
311
+ }
312
+ if (event.type === "tool:device:checkpoint") {
313
+ conversation.messages = buildMessages();
314
+ conversation.pendingApprovals = buildApprovalCheckpoints({
315
+ approvals: event.approvals,
316
+ runId: latestRunId,
317
+ checkpointMessages: event.checkpointMessages,
318
+ baseMessageCount: historyMessages.length,
319
+ pendingToolCalls: event.pendingToolCalls,
320
+ kind: "device",
275
321
  });
276
322
  conversation._toolResultArchive = opts.harness.getToolResultArchive(
277
323
  opts.conversationId,
@@ -304,12 +304,14 @@ export const buildApprovalCheckpoints = ({
304
304
  checkpointMessages,
305
305
  baseMessageCount,
306
306
  pendingToolCalls,
307
+ kind = "approval",
307
308
  }: {
308
309
  approvals: ApprovalEventItem[];
309
310
  runId: string;
310
311
  checkpointMessages: Message[];
311
312
  baseMessageCount: number;
312
313
  pendingToolCalls: PendingToolCall[];
314
+ kind?: "approval" | "device";
313
315
  }): NonNullable<Conversation["pendingApprovals"]> =>
314
316
  approvals.map((approval) => ({
315
317
  approvalId: approval.approvalId,
@@ -320,6 +322,7 @@ export const buildApprovalCheckpoints = ({
320
322
  checkpointMessages,
321
323
  baseMessageCount,
322
324
  pendingToolCalls,
325
+ kind,
323
326
  }));
324
327
 
325
328
  // ── Turn metadata persistence ──
package/src/state.ts CHANGED
@@ -47,6 +47,15 @@ export interface Conversation {
47
47
  baseMessageCount?: number;
48
48
  pendingToolCalls?: Array<{ id: string; name: string; input: Record<string, unknown> }>;
49
49
  decision?: "approved" | "denied";
50
+ /**
51
+ * Checkpoint kind discriminator.
52
+ * - "approval" (default for legacy rows): user approve/deny gate.
53
+ * - "device": tool executes on a connected client device (e.g. iOS); the
54
+ * consumer of the harness POSTs a tool result back to resume.
55
+ * Treat `undefined` as "approval" for backward compatibility with rows
56
+ * persisted before this field existed.
57
+ */
58
+ kind?: "approval" | "device";
50
59
  }>;
51
60
  runStatus?: "running" | "idle";
52
61
  ownerId: string;
@@ -195,4 +195,23 @@ export const migrations: Migration[] = [
195
195
  WHERE parent_message_id IS NOT NULL`,
196
196
  ],
197
197
  },
198
+ {
199
+ version: 7,
200
+ name: "fix_reminder_scheduled_at_precision",
201
+ // Postgres maps `REAL` to float4 (4 bytes, ~7 digit precision),
202
+ // which silently rounds millisecond epochs (13 digits) — every
203
+ // reminder write+read on Postgres returned a different value than
204
+ // it stored. SQLite's REAL is float8 (15+ digit precision) so it
205
+ // was always fine there.
206
+ //
207
+ // Convert the Postgres column to BIGINT so future writes are exact
208
+ // (already-stored rounded values aren't recoverable but they were
209
+ // never correct in the first place).
210
+ up: (d) => {
211
+ if (d === "sqlite") return [];
212
+ return [
213
+ `ALTER TABLE reminders ALTER COLUMN scheduled_at TYPE BIGINT USING scheduled_at::bigint`,
214
+ ];
215
+ },
216
+ },
198
217
  ];
@@ -1195,7 +1195,10 @@ export abstract class SqlStorageEngine implements StorageEngine {
1195
1195
  return {
1196
1196
  id: row.id as string,
1197
1197
  task: row.task as string,
1198
- scheduledAt: row.scheduled_at as number,
1198
+ // Postgres-js returns BIGINT columns as strings to avoid silent
1199
+ // precision loss in JS. Coerce to Number — ms epochs max out at
1200
+ // ~10^16 in year 2286, well under Number.MAX_SAFE_INTEGER (2^53).
1201
+ scheduledAt: Number(row.scheduled_at),
1199
1202
  timezone: (row.timezone as string) ?? undefined,
1200
1203
  status: row.status as Reminder["status"],
1201
1204
  createdAt: new Date(row.created_at as string).getTime(),
@@ -1203,7 +1206,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
1203
1206
  ownerId: (row.owner_id as string) ?? undefined,
1204
1207
  tenantId: tid === DEFAULT_TENANT ? null : tid,
1205
1208
  recurrence,
1206
- occurrenceCount: (row.occurrence_count as number) ?? 0,
1209
+ occurrenceCount: Number(row.occurrence_count ?? 0),
1207
1210
  };
1208
1211
  }
1209
1212
 
@@ -19,6 +19,18 @@ export interface SubagentSpawnResult {
19
19
  subagentId: string;
20
20
  }
21
21
 
22
+ export type SubagentTranscriptMode = "final" | "assistant" | "full";
23
+
24
+ export interface SubagentTranscript {
25
+ subagentId: string;
26
+ task: string;
27
+ status: string;
28
+ totalMessages: number;
29
+ startIndex: number;
30
+ messages: Message[];
31
+ truncated: boolean;
32
+ }
33
+
22
34
  export interface SubagentManager {
23
35
  spawn(opts: {
24
36
  task: string;
@@ -32,4 +44,12 @@ export interface SubagentManager {
32
44
  stop(subagentId: string): Promise<void>;
33
45
 
34
46
  list(parentConversationId: string): Promise<SubagentSummary[]>;
47
+
48
+ getTranscript(opts: {
49
+ subagentId: string;
50
+ parentConversationId: string;
51
+ mode: SubagentTranscriptMode;
52
+ sinceIndex?: number;
53
+ maxMessages?: number;
54
+ }): Promise<SubagentTranscript>;
35
55
  }
@@ -131,4 +131,66 @@ export const createSubagentTools = (
131
131
  return { subagents };
132
132
  },
133
133
  }),
134
+
135
+ defineTool({
136
+ name: "read_subagent",
137
+ description:
138
+ "Fetch the conversation transcript of a subagent you spawned. Use this to inspect a " +
139
+ "subagent's intermediate reasoning, tool calls, or full output -- instead of asking it " +
140
+ "to repeat its work via message_subagent.\n\n" +
141
+ "Modes:\n" +
142
+ "- 'final' (default): just the last assistant message. Cheap.\n" +
143
+ "- 'assistant': all assistant messages, no tool calls/results.\n" +
144
+ "- 'full': every message including tool calls and results. Can be large.\n\n" +
145
+ "Use since_index / max_messages to page through long transcripts. Only works on " +
146
+ "subagents directly spawned by this conversation.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ subagent_id: {
151
+ type: "string",
152
+ description: "The subagent ID (from spawn_subagent or list_subagents).",
153
+ },
154
+ mode: {
155
+ type: "string",
156
+ enum: ["final", "assistant", "full"],
157
+ description: "How much of the transcript to return. Defaults to 'final'.",
158
+ },
159
+ since_index: {
160
+ type: "number",
161
+ description: "Skip messages before this index (applied after mode filter).",
162
+ },
163
+ max_messages: {
164
+ type: "number",
165
+ description: "Cap the number of messages returned.",
166
+ },
167
+ },
168
+ required: ["subagent_id"],
169
+ additionalProperties: false,
170
+ },
171
+ handler: async (input: Record<string, unknown>, context: ToolContext) => {
172
+ const subagentId = typeof input.subagent_id === "string" ? input.subagent_id : "";
173
+ if (!subagentId) {
174
+ return { error: "subagent_id is required" };
175
+ }
176
+ const parentConversationId = context.conversationId;
177
+ if (!parentConversationId) {
178
+ return { error: "no active conversation" };
179
+ }
180
+ const rawMode = typeof input.mode === "string" ? input.mode : "final";
181
+ const mode: "final" | "assistant" | "full" =
182
+ rawMode === "assistant" || rawMode === "full" ? rawMode : "final";
183
+ try {
184
+ return await manager.getTranscript({
185
+ subagentId,
186
+ parentConversationId,
187
+ mode,
188
+ sinceIndex: typeof input.since_index === "number" ? input.since_index : undefined,
189
+ maxMessages: typeof input.max_messages === "number" ? input.max_messages : undefined,
190
+ });
191
+ } catch (err) {
192
+ return { error: err instanceof Error ? err.message : String(err) };
193
+ }
194
+ },
195
+ }),
134
196
  ];
@@ -108,6 +108,33 @@ export const deriveUploadKey = (
108
108
  return `${hash}${ext}`;
109
109
  };
110
110
 
111
+ const DATA_URI_PREFIX = /^data:[^,]*?;base64,/;
112
+
113
+ /**
114
+ * Decode the `FileInput.data` field per its documented contract: it may be a
115
+ * raw base64 string, a `data:<mime>;base64,<…>` URI, or an `https?://` URL.
116
+ * Returns the decoded bytes. Older versions of the harness called
117
+ * `Buffer.from(data, "base64")` unconditionally; that silently produced
118
+ * garbage bytes for data URIs (the `:` / `;` / `,` in the prefix are not
119
+ * valid base64 chars but Node's decoder ignores them rather than throwing).
120
+ */
121
+ export const decodeFileInputData = async (data: string): Promise<Buffer> => {
122
+ if (data.startsWith("http://") || data.startsWith("https://")) {
123
+ const resp = await fetch(data);
124
+ if (!resp.ok) {
125
+ throw new Error(
126
+ `uploads: failed to fetch file at ${data}: ${resp.status} ${resp.statusText}`,
127
+ );
128
+ }
129
+ return Buffer.from(await resp.arrayBuffer());
130
+ }
131
+ const match = data.match(DATA_URI_PREFIX);
132
+ if (match) {
133
+ return Buffer.from(data.slice(match[0].length), "base64");
134
+ }
135
+ return Buffer.from(data, "base64");
136
+ };
137
+
111
138
  const MIME_EXT_MAP: Record<string, string> = {
112
139
  "image/jpeg": ".jpg",
113
140
  "image/png": ".png",
@@ -11,7 +11,7 @@ import type {
11
11
  } from "just-bash";
12
12
  import type { StorageEngine } from "../storage/engine.js";
13
13
  import type { BashConfig, NetworkConfig } from "../config.js";
14
- import { PonchoFsAdapter } from "./poncho-fs-adapter.js";
14
+ import { PonchoFsAdapter, type VirtualMount } from "./poncho-fs-adapter.js";
15
15
  import { createBashFs } from "./create-bash-fs.js";
16
16
  import type { PostgresEngine } from "../storage/postgres-engine.js";
17
17
 
@@ -69,6 +69,7 @@ export class BashEnvironmentManager {
69
69
  private filesystems = new Map<string, IFileSystem>();
70
70
  private readonly workingDir: string | null;
71
71
  private readonly bashOptions: Partial<BashOptions>;
72
+ private readonly virtualMounts: VirtualMount[];
72
73
 
73
74
  constructor(
74
75
  private engine: StorageEngine,
@@ -76,16 +77,18 @@ export class BashEnvironmentManager {
76
77
  workingDir: string | null,
77
78
  bashConfig?: BashConfig,
78
79
  network?: NetworkConfig,
80
+ virtualMounts: VirtualMount[] = [],
79
81
  ) {
80
82
  this.workingDir = workingDir;
81
83
  this.bashOptions = toBashOptions(bashConfig, network);
84
+ this.virtualMounts = virtualMounts;
82
85
  }
83
86
 
84
87
  /** Return the combined IFileSystem (VFS + optional /project mount) for a tenant. */
85
88
  getFs(tenantId: string): IFileSystem {
86
89
  let fs = this.filesystems.get(tenantId);
87
90
  if (!fs) {
88
- const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
91
+ const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits, this.virtualMounts);
89
92
  fs = createBashFs(adapter, this.workingDir);
90
93
  this.filesystems.set(tenantId, fs);
91
94
  }
@@ -107,7 +110,7 @@ export class BashEnvironmentManager {
107
110
  }
108
111
 
109
112
  getAdapter(tenantId: string): PonchoFsAdapter {
110
- return new PonchoFsAdapter(this.engine, tenantId, this.limits);
113
+ return new PonchoFsAdapter(this.engine, tenantId, this.limits, this.virtualMounts);
111
114
  }
112
115
 
113
116
  /** Refresh the PostgreSQL path cache before a bash.exec() call. */