@agent-native/core 0.101.10 → 0.101.12

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 (91) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +23 -1
  5. package/corpus/core/src/a2a/handlers.ts +65 -1
  6. package/corpus/core/src/a2a/server.ts +124 -0
  7. package/corpus/core/src/a2a/task-store.ts +253 -0
  8. package/corpus/core/src/a2a/types.ts +26 -0
  9. package/corpus/core/src/agent/production-agent.ts +5 -2
  10. package/corpus/core/src/coding-tools/run-code.ts +65 -12
  11. package/corpus/core/src/extensions/actions.ts +141 -7
  12. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  13. package/corpus/core/src/scripts/call-agent.ts +22 -3
  14. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  15. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  16. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +59 -0
  17. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +5 -2
  18. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +13 -4
  19. package/corpus/templates/analytics/server/lib/real-data-actions.ts +63 -1
  20. package/corpus/templates/analytics/server/plugins/agent-chat.ts +30 -0
  21. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  22. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  23. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  24. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  25. package/corpus/templates/content/app/global.css +53 -0
  26. package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
  27. package/corpus/templates/content/app/i18n-data.ts +163 -0
  28. package/corpus/templates/content/app/root.tsx +2 -0
  29. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  30. package/corpus/templates/content/package.json +1 -0
  31. package/corpus/templates/content/shared/document-export.ts +296 -25
  32. package/corpus/templates/content/shared/inline-math.ts +128 -0
  33. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  34. package/corpus/templates/content/shared/nfm.ts +17 -14
  35. package/corpus/templates/content/vite.config.ts +0 -1
  36. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  37. package/corpus/templates/mail/AGENTS.md +21 -19
  38. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  39. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  40. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  41. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  42. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  43. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  44. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  45. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  46. package/dist/a2a/client.d.ts +5 -1
  47. package/dist/a2a/client.d.ts.map +1 -1
  48. package/dist/a2a/client.js +18 -1
  49. package/dist/a2a/client.js.map +1 -1
  50. package/dist/a2a/handlers.d.ts.map +1 -1
  51. package/dist/a2a/handlers.js +57 -8
  52. package/dist/a2a/handlers.js.map +1 -1
  53. package/dist/a2a/server.d.ts.map +1 -1
  54. package/dist/a2a/server.js +87 -1
  55. package/dist/a2a/server.js.map +1 -1
  56. package/dist/a2a/task-store.d.ts +27 -0
  57. package/dist/a2a/task-store.d.ts.map +1 -1
  58. package/dist/a2a/task-store.js +216 -0
  59. package/dist/a2a/task-store.js.map +1 -1
  60. package/dist/a2a/types.d.ts +24 -0
  61. package/dist/a2a/types.d.ts.map +1 -1
  62. package/dist/a2a/types.js.map +1 -1
  63. package/dist/agent/production-agent.d.ts +1 -0
  64. package/dist/agent/production-agent.d.ts.map +1 -1
  65. package/dist/agent/production-agent.js +5 -2
  66. package/dist/agent/production-agent.js.map +1 -1
  67. package/dist/coding-tools/run-code.js +64 -11
  68. package/dist/coding-tools/run-code.js.map +1 -1
  69. package/dist/collab/struct-routes.d.ts +1 -1
  70. package/dist/extensions/actions.d.ts.map +1 -1
  71. package/dist/extensions/actions.js +129 -7
  72. package/dist/extensions/actions.js.map +1 -1
  73. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  74. package/dist/mcp/builtin-tools.js +22 -3
  75. package/dist/mcp/builtin-tools.js.map +1 -1
  76. package/dist/notifications/routes.d.ts +2 -2
  77. package/dist/observability/routes.d.ts +5 -5
  78. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  79. package/dist/resources/handlers.d.ts +3 -3
  80. package/dist/scripts/call-agent.d.ts +1 -1
  81. package/dist/scripts/call-agent.d.ts.map +1 -1
  82. package/dist/scripts/call-agent.js +19 -1
  83. package/dist/scripts/call-agent.js.map +1 -1
  84. package/dist/secrets/routes.d.ts +9 -9
  85. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  86. package/dist/server/agent-chat-plugin.js +71 -2
  87. package/dist/server/agent-chat-plugin.js.map +1 -1
  88. package/dist/server/transcribe-voice.d.ts +1 -1
  89. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  90. package/package.json +1 -1
  91. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -24,6 +24,23 @@ async function ensureTable(): Promise<void> {
24
24
  updated_at ${intType()} NOT NULL
25
25
  )
26
26
  `;
27
+ const createApprovalsSql = `
28
+ CREATE TABLE IF NOT EXISTS a2a_approvals (
29
+ id TEXT PRIMARY KEY,
30
+ task_id TEXT NOT NULL UNIQUE,
31
+ owner_email TEXT NOT NULL,
32
+ org_id TEXT,
33
+ tool_name TEXT NOT NULL,
34
+ tool_input TEXT NOT NULL,
35
+ approval_key TEXT NOT NULL,
36
+ call_id TEXT NOT NULL,
37
+ status TEXT NOT NULL DEFAULT 'pending',
38
+ result TEXT,
39
+ expires_at ${intType()} NOT NULL,
40
+ created_at ${intType()} NOT NULL,
41
+ updated_at ${intType()} NOT NULL
42
+ )
43
+ `;
27
44
 
28
45
  if (isPostgres()) {
29
46
  // PG-guard: probe information_schema before issuing DDL to avoid ACCESS
@@ -40,6 +57,7 @@ async function ensureTable(): Promise<void> {
40
57
  "owner_email",
41
58
  `ALTER TABLE a2a_tasks ADD COLUMN IF NOT EXISTS owner_email TEXT`,
42
59
  );
60
+ await ensureTableExists("a2a_approvals", createApprovalsSql);
43
61
  return;
44
62
  }
45
63
 
@@ -58,6 +76,7 @@ async function ensureTable(): Promise<void> {
58
76
  } catch {
59
77
  // Column already exists — expected on every restart after first run.
60
78
  }
79
+ await client.execute(createApprovalsSql);
61
80
  })().catch((err) => {
62
81
  // Retry init on the next call after a failed startup.
63
82
  _initPromise = undefined;
@@ -67,6 +86,240 @@ async function ensureTable(): Promise<void> {
67
86
  return _initPromise;
68
87
  }
69
88
 
89
+ export interface A2AApprovalRecord {
90
+ id: string;
91
+ taskId: string;
92
+ ownerEmail: string;
93
+ orgId: string | null;
94
+ tool: string;
95
+ input: unknown;
96
+ approvalKey: string;
97
+ callId: string;
98
+ status: "pending" | "processing" | "completed" | "failed";
99
+ result: string | null;
100
+ expiresAt: number;
101
+ }
102
+
103
+ async function withDbTransaction<T>(
104
+ client: ReturnType<typeof getDbExec>,
105
+ fn: (tx: ReturnType<typeof getDbExec>) => Promise<T>,
106
+ ): Promise<T> {
107
+ if (client.transaction) return client.transaction(fn);
108
+ await client.execute(isPostgres() ? "BEGIN" : "BEGIN IMMEDIATE");
109
+ try {
110
+ const result = await fn(client);
111
+ await client.execute("COMMIT");
112
+ return result;
113
+ } catch (error) {
114
+ await client.execute("ROLLBACK").catch(() => {});
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ function approvalFromRow(row: any): A2AApprovalRecord {
120
+ return {
121
+ id: String(row.id),
122
+ taskId: String(row.task_id),
123
+ ownerEmail: String(row.owner_email),
124
+ orgId: row.org_id ? String(row.org_id) : null,
125
+ tool: String(row.tool_name),
126
+ input: JSON.parse(String(row.tool_input)),
127
+ approvalKey: String(row.approval_key),
128
+ callId: String(row.call_id),
129
+ status: row.status,
130
+ result: row.result ? String(row.result) : null,
131
+ expiresAt: Number(row.expires_at),
132
+ };
133
+ }
134
+
135
+ export async function createA2AApproval(input: {
136
+ taskId: string;
137
+ ownerEmail: string;
138
+ orgId?: string | null;
139
+ tool: string;
140
+ toolInput: unknown;
141
+ approvalKey: string;
142
+ callId: string;
143
+ ttlMs?: number;
144
+ }): Promise<A2AApprovalRecord> {
145
+ await ensureTable();
146
+ const client = getDbExec();
147
+ const id = crypto.randomUUID();
148
+ const now = Date.now();
149
+ const expiresAt = now + (input.ttlMs ?? 15 * 60_000);
150
+ await client.execute({
151
+ sql: `INSERT INTO a2a_approvals (id, task_id, owner_email, org_id, tool_name, tool_input, approval_key, call_id, status, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
152
+ args: [
153
+ id,
154
+ input.taskId,
155
+ input.ownerEmail,
156
+ input.orgId ?? null,
157
+ input.tool,
158
+ JSON.stringify(input.toolInput ?? {}),
159
+ input.approvalKey,
160
+ input.callId,
161
+ expiresAt,
162
+ now,
163
+ now,
164
+ ],
165
+ });
166
+ return {
167
+ id,
168
+ taskId: input.taskId,
169
+ ownerEmail: input.ownerEmail,
170
+ orgId: input.orgId ?? null,
171
+ tool: input.tool,
172
+ input: input.toolInput ?? {},
173
+ approvalKey: input.approvalKey,
174
+ callId: input.callId,
175
+ status: "pending",
176
+ result: null,
177
+ expiresAt,
178
+ };
179
+ }
180
+
181
+ export async function getA2AApprovalForOwner(
182
+ id: string,
183
+ ownerEmail: string,
184
+ orgId?: string | null,
185
+ ): Promise<A2AApprovalRecord | null> {
186
+ await ensureTable();
187
+ const { rows } = await getDbExec().execute({
188
+ sql: `SELECT * FROM a2a_approvals WHERE id = ? AND owner_email = ? AND (org_id IS NULL OR org_id = ?)`,
189
+ args: [id, ownerEmail, orgId ?? null],
190
+ });
191
+ return rows[0] ? approvalFromRow(rows[0]) : null;
192
+ }
193
+
194
+ export async function claimA2AApproval(
195
+ id: string,
196
+ ownerEmail: string,
197
+ orgId?: string | null,
198
+ ): Promise<A2AApprovalRecord | null> {
199
+ await ensureTable();
200
+ const client = getDbExec();
201
+ const now = Date.now();
202
+ class ClaimRejected extends Error {}
203
+ try {
204
+ return await withDbTransaction(client, async (tx) => {
205
+ const approvalUpdate = await tx.execute({
206
+ sql: `UPDATE a2a_approvals SET status = 'processing', updated_at = ? WHERE id = ? AND owner_email = ? AND status = 'pending' AND expires_at > ? AND (org_id IS NULL OR org_id = ?)`,
207
+ args: [now, id, ownerEmail, now, orgId ?? null],
208
+ });
209
+ if (getAffectedRowCount(approvalUpdate) === 0) throw new ClaimRejected();
210
+ const { rows } = await tx.execute({
211
+ sql: `SELECT * FROM a2a_approvals WHERE id = ? AND owner_email = ? AND status = 'processing'`,
212
+ args: [id, ownerEmail],
213
+ });
214
+ if (!rows[0]) throw new ClaimRejected();
215
+ const approval = approvalFromRow(rows[0]);
216
+ const timestamp = new Date(now).toISOString();
217
+ const runningMessage: Message = {
218
+ role: "agent",
219
+ parts: [{ type: "text", text: "Approved action is running." }],
220
+ };
221
+ const taskUpdate = await tx.execute({
222
+ sql: `UPDATE a2a_tasks SET status_state = 'working', status_message = ?, status_timestamp = ?, updated_at = ? WHERE id = ? AND owner_email = ? AND status_state = 'input-required'`,
223
+ args: [
224
+ JSON.stringify(runningMessage),
225
+ timestamp,
226
+ now,
227
+ approval.taskId,
228
+ ownerEmail,
229
+ ],
230
+ });
231
+ if (getAffectedRowCount(taskUpdate) === 0) throw new ClaimRejected();
232
+ return approval;
233
+ });
234
+ } catch (error) {
235
+ if (error instanceof ClaimRejected) return null;
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ export async function settleA2AApproval(
241
+ id: string,
242
+ status: "completed" | "failed",
243
+ resultText: string,
244
+ ): Promise<void> {
245
+ await ensureTable();
246
+ const client = getDbExec();
247
+ const now = Date.now();
248
+ const message: Message = {
249
+ role: "agent",
250
+ parts: [{ type: "text", text: resultText }],
251
+ };
252
+ await withDbTransaction(client, async (tx) => {
253
+ const { rows } = await tx.execute({
254
+ sql: `SELECT task_id FROM a2a_approvals WHERE id = ? AND status = 'processing'`,
255
+ args: [id],
256
+ });
257
+ if (!rows[0]) throw new Error("Approval is not processing");
258
+ const taskId = String((rows[0] as any).task_id);
259
+ const { rows: taskRows } = await tx.execute({
260
+ sql: `SELECT history FROM a2a_tasks WHERE id = ? AND status_state = 'working'`,
261
+ args: [taskId],
262
+ });
263
+ if (!taskRows[0]) {
264
+ throw new Error("Approval task is no longer awaiting settlement");
265
+ }
266
+ const history = JSON.parse(String((taskRows[0] as any).history));
267
+ history.push(message);
268
+ const timestamp = new Date(now).toISOString();
269
+ const taskUpdate = await tx.execute({
270
+ sql: `UPDATE a2a_tasks SET status_state = ?, status_message = ?, status_timestamp = ?, history = ?, updated_at = ? WHERE id = ? AND status_state = 'working'`,
271
+ args: [
272
+ status,
273
+ JSON.stringify(message),
274
+ timestamp,
275
+ JSON.stringify(history),
276
+ now,
277
+ taskId,
278
+ ],
279
+ });
280
+ if (getAffectedRowCount(taskUpdate) === 0) {
281
+ throw new Error("Approval task settlement lost its state claim");
282
+ }
283
+ const approvalUpdate = await tx.execute({
284
+ sql: `UPDATE a2a_approvals SET status = ?, result = ?, updated_at = ? WHERE id = ? AND status = 'processing'`,
285
+ args: [status, resultText, now, id],
286
+ });
287
+ if (getAffectedRowCount(approvalUpdate) === 0) {
288
+ throw new Error("Approval settlement lost its state claim");
289
+ }
290
+ });
291
+ }
292
+
293
+ export async function pauseProcessingA2ATask(
294
+ id: string,
295
+ message: Message,
296
+ ): Promise<Task | null> {
297
+ await ensureTable();
298
+ const client = getDbExec();
299
+ const { rows } = await client.execute({
300
+ sql: `SELECT * FROM a2a_tasks WHERE id = ? AND status_state = 'processing'`,
301
+ args: [id],
302
+ });
303
+ if (!rows[0]) return null;
304
+ const task = taskFromRow(rows[0]);
305
+ task.history?.push(message);
306
+ const now = Date.now();
307
+ const timestamp = new Date(now).toISOString();
308
+ const result = await client.execute({
309
+ sql: `UPDATE a2a_tasks SET status_state = 'input-required', status_message = ?, status_timestamp = ?, history = ?, updated_at = ? WHERE id = ? AND status_state = 'processing'`,
310
+ args: [
311
+ JSON.stringify(message),
312
+ timestamp,
313
+ JSON.stringify(task.history ?? []),
314
+ now,
315
+ id,
316
+ ],
317
+ });
318
+ if (getAffectedRowCount(result) === 0) return null;
319
+ task.status = { state: "input-required", message, timestamp };
320
+ return task;
321
+ }
322
+
70
323
  function taskFromRow(row: any): Task & { ownerEmail?: string | null } {
71
324
  return {
72
325
  id: row.id as string,
@@ -126,6 +126,12 @@ export interface JsonRpcResponse {
126
126
  error?: JsonRpcError;
127
127
  }
128
128
 
129
+ /** One exact downstream action explicitly authorized in the caller's chat. */
130
+ export interface A2AApprovedAction {
131
+ tool: string;
132
+ input: unknown;
133
+ }
134
+
129
135
  // --- Framework config ---
130
136
 
131
137
  export interface A2AHandlerContext {
@@ -135,12 +141,27 @@ export interface A2AHandlerContext {
135
141
  metadata?: Record<string, unknown>;
136
142
  /** Current H3 event when the handler is running inside an HTTP request. */
137
143
  event?: unknown;
144
+ /** Exact one-time action grants from a JWT-authenticated caller. */
145
+ approvedActions?: A2AApprovedAction[];
138
146
  writeArtifact: (name: string, content: string, mimeType?: string) => string;
139
147
  }
140
148
 
141
149
  export interface A2AHandlerResult {
142
150
  message: Message;
143
151
  artifacts?: Artifact[];
152
+ /** Optional non-terminal state requested by the handler. */
153
+ taskState?: Extract<TaskState, "input-required">;
154
+ }
155
+
156
+ export interface A2AApprovalExecution {
157
+ id: string;
158
+ taskId: string;
159
+ ownerEmail: string;
160
+ orgId?: string | null;
161
+ tool: string;
162
+ input: unknown;
163
+ approvalKey: string;
164
+ callId: string;
144
165
  }
145
166
 
146
167
  export type A2AHandler = (
@@ -160,4 +181,9 @@ export interface A2AConfig {
160
181
  streaming?: boolean;
161
182
  /** Route async A2A work through the app's durable background worker when available. */
162
183
  durableBackgroundRuns?: boolean;
184
+ /** Execute a persisted, human-approved A2A tool call. */
185
+ executeApproval?: (approval: A2AApprovalExecution) => Promise<{
186
+ status: "completed" | "failed";
187
+ output: string;
188
+ }>;
163
189
  }
@@ -2747,7 +2747,7 @@ function stableStringify(value: unknown): string {
2747
2747
  .join(",")}}`;
2748
2748
  }
2749
2749
 
2750
- function toolCallCacheKey(toolName: string, input: unknown): string {
2750
+ export function toolCallCacheKey(toolName: string, input: unknown): string {
2751
2751
  return `${toolName}:${stableStringify(normalizeToolCallInputForHistory(input))}`;
2752
2752
  }
2753
2753
 
@@ -4180,7 +4180,10 @@ export async function runAgentLoop(opts: {
4180
4180
  // instead of executing. The action's side effect never happens until a
4181
4181
  // human re-issues the turn approving this call's stable key.
4182
4182
  const approvalKey = toolCallCacheKey(toolCall.name, toolCall.input);
4183
- if (actionEntry.needsApproval && !approvedToolCallKeys.has(approvalKey)) {
4183
+ // Consume a grant on its first exact match. A second identical call in
4184
+ // the same continuation must request its own human approval.
4185
+ const wasApproved = approvedToolCallKeys.delete(approvalKey);
4186
+ if (actionEntry.needsApproval && !wasApproved) {
4184
4187
  let mustApprove = false;
4185
4188
  try {
4186
4189
  mustApprove =
@@ -740,8 +740,18 @@ function handleBridgeRequest(
740
740
 
741
741
  // Enforce allowlist.
742
742
  const entry = actions[toolName];
743
+ // Unknown/mistyped tool: report "not registered" (404) before the
744
+ // access-control branch below. Otherwise an undefined `entry` falls into the
745
+ // allowlist 403 and returns a misleading access error for a tool that simply
746
+ // does not exist. (Bridge-allowlisted tools always have an entry, so this
747
+ // cannot mask a legitimate allowlisted call.)
748
+ if (!entry) {
749
+ res.writeHead(404, { "Content-Type": "application/json" });
750
+ res.end(JSON.stringify({ error: `Tool "${toolName}" is not registered.` }));
751
+ return;
752
+ }
743
753
  const isReadOnlyAction =
744
- entry?.readOnly === true &&
754
+ entry.readOnly === true &&
745
755
  entry.agentTool !== false &&
746
756
  entry.toolCallable !== false;
747
757
  if (
@@ -749,21 +759,24 @@ function handleBridgeRequest(
749
759
  !extraTools.has(toolName) &&
750
760
  !isReadOnlyAction
751
761
  ) {
762
+ // A registered, agent-exposed action that isn't read-only is a mutation.
763
+ // (Unknown tools already returned 404 above, so `entry` is defined here.)
764
+ // Point the caller at the native tool path instead of leaving them to guess
765
+ // (the common trap: retrying create-extension/update-extension through
766
+ // appAction, which cannot work).
767
+ const isMutatingAction =
768
+ entry.agentTool !== false && entry.readOnly !== true;
752
769
  res.writeHead(403, { "Content-Type": "application/json" });
753
770
  res.end(
754
771
  JSON.stringify({
755
- error: `Tool "${toolName}" is not an agent-exposed read-only action or sandbox bridge allowlisted tool.`,
772
+ error: isMutatingAction
773
+ ? `Tool "${toolName}" is a mutating action and cannot be called from run-code (appAction only exposes read-only actions). Call "${toolName}" directly as a native tool. For large content bodies, stage the content and pass "contentFromAttachment" instead of an inline string rather than routing it through run-code.`
774
+ : `Tool "${toolName}" is not an agent-exposed read-only action or sandbox bridge allowlisted tool.`,
756
775
  }),
757
776
  );
758
777
  return;
759
778
  }
760
779
 
761
- if (!entry) {
762
- res.writeHead(404, { "Content-Type": "application/json" });
763
- res.end(JSON.stringify({ error: `Tool "${toolName}" is not registered.` }));
764
- return;
765
- }
766
-
767
780
  const toolArgs = parsed.args ?? {};
768
781
  usedTools.add(toolName);
769
782
  // Run the tool with the parent request context so auth/org/owner resolution
@@ -1534,12 +1547,52 @@ async function webRead(url, init = {}) {
1534
1547
  /**
1535
1548
  * Read a Resources-backed workspace file by path. Returns the file content as
1536
1549
  * a string, or null if not found.
1537
- * Supports optional offset and maxChars for paging large files.
1550
+ *
1551
+ * By default this returns the WHOLE file: the underlying store caps a single
1552
+ * read at 100k chars, so this auto-pages across chunks and concatenates them,
1553
+ * so callers never get a silently truncated body. Pass an explicit \`offset\`
1554
+ * or \`maxChars\` to take manual control of a single page instead.
1538
1555
  */
1539
1556
  async function workspaceRead(path, opts = {}) {
1540
- const parsed = await workspaceReadMeta(path, opts);
1541
- if (parsed && parsed.ok === false) return null;
1542
- return parsed && typeof parsed.content === "string" ? parsed.content : null;
1557
+ // Explicit paging requested → single read, caller owns the window.
1558
+ if (opts.offset !== undefined || opts.maxChars !== undefined) {
1559
+ const parsed = await workspaceReadMeta(path, opts);
1560
+ if (parsed && parsed.ok === false) return null;
1561
+ return parsed && typeof parsed.content === "string" ? parsed.content : null;
1562
+ }
1563
+ // Default → assemble the full file by paging until the store reports no more.
1564
+ let offset = 0;
1565
+ let out = "";
1566
+ let found = false;
1567
+ let complete = false;
1568
+ // Bounded loop (files cap at 2 MB / 100k-char reads) so a misbehaving store
1569
+ // can never spin forever.
1570
+ for (let i = 0; i < 512; i++) {
1571
+ const parsed = await workspaceReadMeta(path, { offset, maxChars: 100000 });
1572
+ // A failed/missing page aborts. If it failed before the first page we never
1573
+ // found the file (return null below); if it failed part-way through a
1574
+ // truncated read, complete stays false and we return null rather than a
1575
+ // silently truncated body.
1576
+ if (!parsed || parsed.ok === false) break;
1577
+ found = true;
1578
+ const chunk = typeof parsed.content === "string" ? parsed.content : "";
1579
+ out += chunk;
1580
+ if (!parsed.truncated) {
1581
+ complete = true;
1582
+ break;
1583
+ }
1584
+ const next =
1585
+ typeof parsed.nextOffset === "number"
1586
+ ? parsed.nextOffset
1587
+ : offset + chunk.length;
1588
+ if (next <= offset) break; // no forward progress; stop rather than loop
1589
+ offset = next;
1590
+ }
1591
+ if (!found) return null;
1592
+ // Only hand back a body we know is whole. A truncated read followed by a
1593
+ // failed/stalled page would otherwise corrupt a large clone with a partial
1594
+ // prefix and no error signal.
1595
+ return complete ? out : null;
1543
1596
  }
1544
1597
 
1545
1598
  /**