lark-remote 0.5.4 → 0.5.5

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  25. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  26. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  27. package/dist/runner/common/acp/protocol-types.js +3 -1
  28. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  29. package/dist/runner/common/connection-based-runner.js +40 -5
  30. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  31. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  32. package/dist/runner/dsh/approval.d.ts +53 -0
  33. package/dist/runner/dsh/approval.js +161 -0
  34. package/dist/runner/dsh/auth.d.ts +36 -0
  35. package/dist/runner/dsh/auth.js +118 -0
  36. package/dist/runner/dsh/client.d.ts +161 -42
  37. package/dist/runner/dsh/client.js +551 -244
  38. package/dist/runner/dsh/daemon.d.ts +85 -0
  39. package/dist/runner/dsh/daemon.js +272 -0
  40. package/dist/runner/dsh/merge.d.ts +9 -0
  41. package/dist/runner/dsh/merge.js +51 -0
  42. package/dist/runner/dsh/runner.d.ts +94 -11
  43. package/dist/runner/dsh/runner.js +553 -67
  44. package/dist/runner/dsh/translator.d.ts +43 -3
  45. package/dist/runner/dsh/translator.js +115 -38
  46. package/dist/runner/dsh/types.d.ts +189 -27
  47. package/dist/runner/dsh/types.js +11 -5
  48. package/dist/runner/kimi/acp/runner.js +15 -3
  49. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  50. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  51. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  52. package/dist/runner/opencode/acp/runner.js +40 -1
  53. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  54. package/dist/runner/opencode/acp/translator.js +12 -3
  55. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  56. package/dist/runner/pi/rpc/translator.js +27 -1
  57. package/dist/runner/types.d.ts +34 -0
  58. package/dist/runner/types.js +24 -0
  59. package/dist/session/dsh/sessions.d.ts +45 -6
  60. package/dist/session/dsh/sessions.js +171 -27
  61. package/dist/session/opencode/sessions.d.ts +18 -7
  62. package/dist/session/opencode/sessions.js +294 -15
  63. package/dist/session/pi/sessions.js +6 -2
  64. package/package.json +3 -1
@@ -3,14 +3,15 @@
3
3
  *
4
4
  * The AgentSessionReader contract is fully synchronous (file-style readers),
5
5
  * but DSH exposes only async HTTP. The reader therefore bridges with
6
- * `spawnSync('curl', ...)` for its unary calls (session.list / session.history)
6
+ * `spawnSync('curl', ...)` for its unary calls (session/list / session/page)
7
7
  * — a synchronous HTTP round-trip to the local DSH host. This keeps the
8
8
  * router/bridge interface contract intact without touching any upstream logic.
9
9
  *
10
- * listSessions: session.list → filter by cwd → sort by updatedAt desc → local
10
+ * listSessions: session/list → filter by cwd → sort by updatedAt desc → local
11
11
  * pagination (DSH v1 has no cursor pagination).
12
- * readSessionContent: session.history → map SessionEvents to display events +
13
- * session-wide usage accumulation (assistant/message.usage).
12
+ * readSessionContent: session/projections → session/page → map SessionEvents +
13
+ * session-wide usage accumulation (assistant/message.usage);本 run 的 flow
14
+ * 字段 = 末个 turn(turn/start–turn/end)内所有采样之和,与 codex 同口径。
14
15
  */
15
16
  import { spawnProcessSync } from '../../platform/spawn.js';
16
17
  import { cumulativeUsageFields } from '../common/usage-accumulator.js';
@@ -18,22 +19,25 @@ import { sortByRecencyDesc } from '../common/recency.js';
18
19
  import { getLogger } from '../../logger/index.js';
19
20
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
20
21
  import { samePath } from '../../platform/path.js';
22
+ import { isWin32 } from '../../platform/select.js';
23
+ import { dshArgs, extractDshToken } from '../../runner/dsh/client.js';
24
+ import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from '../../runner/dsh/auth.js';
21
25
  import { capEvents, paginate } from '../common/pagination.js';
22
26
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
23
27
  /**
24
- * history 拉取上限。DSH session.history 的 maxMessages 同时限制返回条数,
28
+ * history 拉取上限。DSH session/page 的 maxMessages 同时限制返回条数,
25
29
  * 若用它同时充当展示截断,>50 事件的会话累计 usage 会少算。这里拉取一个足够大的
26
30
  * 上限保证 usage 累加覆盖整个会话;展示仍由 capEvents(opts?.maxEvents ?? 50) 截断。
27
31
  * (若 DSH 支持分页 cursor 可改成分页,当前用有界大拉取)
28
32
  */
29
33
  const DSH_HISTORY_FETCH_LIMIT = 10_000;
30
34
  /** Synchronous unary POST via curl (the reader interface is synchronous). */
31
- function defaultSyncRequest(baseUrl, method, payload) {
35
+ function defaultSyncRequest(baseUrl, method, payload, cookie) {
32
36
  const body = JSON.stringify({
33
37
  type: 'client-request',
34
38
  rpcId: `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
35
39
  method,
36
- payload,
40
+ payload: { args: dshArgs(method, payload) },
37
41
  });
38
42
  const res = spawnProcessSync('curl', [
39
43
  '-s',
@@ -42,6 +46,7 @@ function defaultSyncRequest(baseUrl, method, payload) {
42
46
  `${baseUrl}/api/${method}`,
43
47
  '-H',
44
48
  'content-type: application/json',
49
+ ...(cookie ? ['-H', `Cookie: ${cookie}`] : []),
45
50
  '--data',
46
51
  body,
47
52
  ], { encoding: 'utf-8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
@@ -62,25 +67,105 @@ function defaultSyncRequest(baseUrl, method, payload) {
62
67
  }
63
68
  return parsed.result.value;
64
69
  }
70
+ /** Synchronous index bootstrap; returns the v1 `name=value` cookie pair. */
71
+ function defaultSyncConnect(baseUrl, token) {
72
+ const url = `${baseUrl}/?token=${encodeURIComponent(token)}`;
73
+ const res = spawnProcessSync('curl', ['-sS', '-D', '-', '-o', isWin32() ? 'NUL' : '/dev/null', '--max-time', '5', url], { encoding: 'utf-8', timeout: 10_000, maxBuffer: 1024 * 1024 });
74
+ if (res.error || res.status !== 0) {
75
+ throw new Error(`DSH auth failed: ${(res.error?.message ?? res.stderr) || 'unknown'}`);
76
+ }
77
+ const header = String(res.stdout);
78
+ const match = header.match(/^set-cookie:\s*(.+)$/im);
79
+ const cookie = match?.[1]?.split(';')[0]?.trim();
80
+ if (!cookie)
81
+ throw new Error('DSH auth failed: index handshake did not return a cookie');
82
+ return cookie;
83
+ }
65
84
  export class DshSessionReader {
66
85
  hostProvider;
86
+ dshHomeProvider;
87
+ tokenProvider;
88
+ credentialsFileProvider;
89
+ readCredentials;
90
+ now;
91
+ cookieLifetimeMs;
67
92
  syncRequest;
93
+ syncConnect;
94
+ cookieCache = new Map();
68
95
  constructor(opts = {}) {
69
96
  const fixed = (opts.host ?? DEFAULT_DSH_HOST).replace(/\/$/, '');
70
97
  this.hostProvider = opts.hostProvider ?? (() => fixed);
98
+ this.dshHomeProvider = opts.dshHomeProvider ?? (() => undefined);
99
+ const fixedToken = opts.token;
100
+ this.tokenProvider = opts.tokenProvider ?? (() => fixedToken);
101
+ const fixedCredentialsFile = opts.credentialsFile;
102
+ this.credentialsFileProvider = opts.credentialsFileProvider ?? (() => fixedCredentialsFile);
103
+ this.readCredentials = opts.readCredentials ?? readDshBrowserSecretSync;
104
+ this.now = opts.now ?? Date.now;
105
+ this.cookieLifetimeMs = opts.cookieLifetimeMs ?? DSH_COOKIE_LIFETIME_MS;
71
106
  this.syncRequest = opts.syncRequest ?? defaultSyncRequest;
107
+ this.syncConnect = opts.syncConnect ?? defaultSyncConnect;
72
108
  }
73
109
  /** 解析当前 host(hostProvider 优先,允许 /config 热改 host)。 */
74
110
  resolveBaseUrl() {
75
111
  const host = this.hostProvider();
76
112
  return (host && host.length > 0 ? host : DEFAULT_DSH_HOST).replace(/\/$/, '');
77
113
  }
114
+ resolveCredentialsFile() {
115
+ return (this.credentialsFileProvider()?.trim() ||
116
+ resolveDshCredentialsFile(process.env, this.dshHomeProvider()));
117
+ }
78
118
  unary(method, payload) {
79
- return this.syncRequest(this.resolveBaseUrl(), method, payload);
119
+ const baseUrl = this.resolveBaseUrl();
120
+ try {
121
+ return this.syncRequest(baseUrl, method, payload, this.resolveCookie());
122
+ }
123
+ catch {
124
+ // DSH may have restarted while lark-remote cached a token-derived cookie.
125
+ // Re-read the credentials file and retry once with a fresh cookie.
126
+ this.cookieCache.clear();
127
+ return this.syncRequest(baseUrl, method, payload, this.resolveCookie());
128
+ }
129
+ }
130
+ /** Mint from the durable secret, or bootstrap and cache a token cookie. */
131
+ resolveCookie() {
132
+ const baseUrl = this.resolveBaseUrl();
133
+ const fromCredentials = this.cookieFromCredentials(baseUrl);
134
+ if (fromCredentials)
135
+ return fromCredentials;
136
+ const token = extractDshToken(this.tokenProvider());
137
+ if (!token)
138
+ return undefined;
139
+ const key = `${baseUrl}\u0000${token}`;
140
+ const cached = this.cookieCache.get(key);
141
+ if (cached)
142
+ return cached;
143
+ const cookie = this.syncConnect(baseUrl, token);
144
+ this.cookieCache.set(key, cookie);
145
+ return cookie;
146
+ }
147
+ cookieFromCredentials(baseUrl) {
148
+ const credentialsFile = this.resolveCredentialsFile();
149
+ try {
150
+ const secret = this.readCredentials(credentialsFile);
151
+ return mintDshBrowserCookie(baseUrl, secret, this.now(), this.cookieLifetimeMs);
152
+ }
153
+ catch (err) {
154
+ if (isDshCredentialsNotFound(err))
155
+ return undefined;
156
+ if (extractDshToken(this.tokenProvider())) {
157
+ getLogger().warn(`[dsh-session-reader] cannot use DSH credentials file ${credentialsFile}: ` +
158
+ `${err.message}; falling back to the launch token`);
159
+ return undefined;
160
+ }
161
+ throw new Error(`DSH auth credentials file ${credentialsFile}: ${err.message}`, {
162
+ cause: err,
163
+ });
164
+ }
80
165
  }
81
166
  listSessions(cwd, opts) {
82
167
  try {
83
- const value = this.unary('session.list', {});
168
+ const value = this.unary('session/list', {});
84
169
  const items = value.items ?? [];
85
170
  const filtered = items
86
171
  .filter((i) => i.cwd !== undefined && samePath(i.cwd, cwd))
@@ -88,7 +173,7 @@ export class DshSessionReader {
88
173
  sessionId: String(i.sessionId),
89
174
  summary: '',
90
175
  mtime: i.updatedAt,
91
- ...(i.agentPreset ? { agentPreset: i.agentPreset } : {}),
176
+ ...sessionPreset(i),
92
177
  }));
93
178
  sortByRecencyDesc(filtered, (i) => i.mtime, (i) => i.sessionId);
94
179
  const { items: page, total } = paginate(filtered, opts ?? {});
@@ -102,10 +187,21 @@ export class DshSessionReader {
102
187
  getNewestSession(cwd) {
103
188
  return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
104
189
  }
105
- readSessionContent(sessionId, _cwd, opts) {
190
+ readSessionContent(sessionId, cwd, opts) {
106
191
  try {
107
- const value = this.unary('session.history', {
108
- sessionId,
192
+ // cwd 守卫:sessionId 可能属于另一个 workspace。
193
+ // 只按 id 查 history 会让 /resume 把 cwd A 的卡片绑到 cwd B 的会话上,
194
+ // agent 实际在 B 操作文件、resume.compact 还会压缩别人的会话。与
195
+ // Claude/Kimi/Pi 的 cwd 守卫同口径:找不到同 cwd 的会话 → 空内容(fail-closed)。
196
+ const session = this.resolveSession(sessionId, cwd);
197
+ if (!session) {
198
+ getLogger().warn(`[dsh-session-reader] readSessionContent: session ${sessionId} not found in cwd ${cwd}, refusing cross-workspace read`);
199
+ return { events: [] };
200
+ }
201
+ const throughSeq = this.resolveThroughSeq(sessionId, session);
202
+ const value = this.unary('session/page', {
203
+ address: { kind: 'session', sessionId },
204
+ throughSeq,
109
205
  maxMessages: DSH_HISTORY_FETCH_LIMIT,
110
206
  });
111
207
  const events = [];
@@ -115,10 +211,25 @@ export class DshSessionReader {
115
211
  let cumCacheRead = 0;
116
212
  let cumCacheWrite = 0;
117
213
  let lastUsage;
118
- for (const entry of value.events ?? []) {
214
+ // 末个 turn 内各采样用量之和("本 run / 末轮" scope)。DSH 每次模型调用
215
+ // 一条 usage,工具往返的 turn 会写多条 assistant/message;只取末条会把
216
+ // 多步 run 低报(实测 231/689 显示成整轮真实消耗)。turn 边界取
217
+ // turn/start–turn/end,与 codex `task_started` 汇总同一口径(§9.21)。
218
+ // null = 历史里没有 turn 标记(旧数据/截断)→ 退回末条 usage 语义。
219
+ let turnUsage = null;
220
+ let lastTurnUsage = null;
221
+ for (const entry of value.records ?? []) {
119
222
  const ev = entry.event;
120
223
  if (!ev)
121
224
  continue;
225
+ if (ev.type === 'turn/start') {
226
+ turnUsage = emptyUsageTotals();
227
+ }
228
+ else if (ev.type === 'turn/end') {
229
+ if (turnUsage)
230
+ lastTurnUsage = turnUsage;
231
+ turnUsage = null;
232
+ }
122
233
  const contentEvents = mapEvent(ev);
123
234
  events.push(...contentEvents.events);
124
235
  if (contentEvents.title)
@@ -130,26 +241,35 @@ export class DshSessionReader {
130
241
  cumOutput += u.outputTokens ?? 0;
131
242
  cumCacheRead += u.cacheReadTokens ?? 0;
132
243
  cumCacheWrite += u.cacheWriteTokens ?? 0;
133
- // Per-turn "current window" fields reflect the LAST assistant/message
134
- // with usage (ccusage/`last_token_usage` 口径, AGENTS.md §9.21) — a
135
- // multi-turn session's current context water-mark is the last turn's,
136
- // not the accumulated total.
244
+ // 本 run 的 flow 字段 = 末个 turn 的采样之和;contextLength 用末次采样
245
+ // 的窗口占用(input + cacheRead + cacheWrite,不含 output)。
246
+ if (turnUsage) {
247
+ turnUsage.input += u.inputTokens ?? 0;
248
+ turnUsage.output += u.outputTokens ?? 0;
249
+ turnUsage.cacheRead += u.cacheReadTokens ?? 0;
250
+ turnUsage.cacheWrite += u.cacheWriteTokens ?? 0;
251
+ }
137
252
  lastUsage = u;
138
253
  }
139
254
  }
140
255
  let sessionUsage;
141
256
  if (lastUsage) {
142
257
  const input = lastUsage.inputTokens ?? 0;
143
- const output = lastUsage.outputTokens ?? 0;
144
258
  const cacheRead = lastUsage.cacheReadTokens ?? 0;
145
259
  const cacheWrite = lastUsage.cacheWriteTokens ?? 0;
260
+ const flow = lastTurnUsage ?? {
261
+ input,
262
+ output: lastUsage.outputTokens ?? 0,
263
+ cacheRead,
264
+ cacheWrite,
265
+ };
146
266
  sessionUsage = {
147
- inputTokens: input,
148
- outputTokens: output,
267
+ inputTokens: flow.input,
268
+ outputTokens: flow.output,
149
269
  contextLength: input + cacheRead + cacheWrite,
150
- cacheReadTokens: cacheRead,
151
- cacheCreationTokens: cacheWrite,
152
- totalTokens: input + output + cacheRead + cacheWrite,
270
+ cacheReadTokens: flow.cacheRead,
271
+ cacheCreationTokens: flow.cacheWrite,
272
+ totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheWrite,
153
273
  ...cumulativeUsageFields({
154
274
  input: cumInput,
155
275
  output: cumOutput,
@@ -173,15 +293,39 @@ export class DshSessionReader {
173
293
  }
174
294
  isSessionActive(sessionId, cwd) {
175
295
  try {
176
- const value = this.unary('session.list', {});
177
- const found = (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd !== undefined && samePath(i.cwd, cwd));
178
- return found?.running === true;
296
+ return this.resolveSession(sessionId, cwd)?.running === true;
179
297
  }
180
298
  catch (error) {
181
299
  getLogger().error(`[dsh-session-reader] isSessionActive error: ${error.message}`);
182
300
  return false;
183
301
  }
184
302
  }
303
+ /**
304
+ * Find the session row in `session/list` that matches BOTH id and cwd.
305
+ * Returns undefined when the id belongs to another workspace (or is unknown) —
306
+ * every caller treats that as "not usable here".
307
+ */
308
+ resolveSession(sessionId, cwd) {
309
+ const value = this.unary('session/list', {});
310
+ return (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd !== undefined && samePath(i.cwd, cwd));
311
+ }
312
+ /** `session/page` needs an inclusive cursor; projections carry the watermark. */
313
+ resolveThroughSeq(sessionId, known) {
314
+ const projection = this.unary('session/projections', { sessionId });
315
+ if (typeof projection?.asOfSeq === 'number')
316
+ return projection.asOfSeq;
317
+ const row = known ??
318
+ this.unary('session/list', {}).items?.find((item) => String(item.sessionId) === sessionId);
319
+ return typeof row?.projections?.asOfSeq === 'number' ? row.projections.asOfSeq : -1;
320
+ }
321
+ }
322
+ /** Preset moved from the summary top level to projections in DSH 0.2.x. */
323
+ function sessionPreset(item) {
324
+ const preset = item.projections?.values?.agentPreset ?? item.agentPreset;
325
+ return preset ? { agentPreset: preset } : {};
326
+ }
327
+ function emptyUsageTotals() {
328
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
185
329
  }
186
330
  /** Map one DSH SessionEvent to display content events (+ title/usage side effects). */
187
331
  function mapEvent(ev) {
@@ -1,10 +1,12 @@
1
1
  /**
2
- * OpencodeSessionReader: CLI-based session reader using `opencode session list` and `opencode export`.
2
+ * OpencodeSessionReader: CLI-based session reader using `opencode session list`
3
+ * and `opencode session export` (v2) / `opencode export` (v1).
3
4
  *
4
5
  * Design:
5
6
  * - Uses official CLI commands instead of HTTP API.
6
7
  * - `opencode session list --format json` returns sessions with `directory` field (first-class citizen).
7
- * - `opencode export <sessionID>` returns full session content.
8
+ * - `opencode session export <sessionID>` (v2) or `opencode export <sessionID>`
9
+ * (v1) returns full session content.
8
10
  * - Independent of runner (no setRunner circular dependency).
9
11
  *
10
12
  * Key features:
@@ -15,10 +17,12 @@
15
17
  */
16
18
  import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
17
19
  interface OpencodeSessionReaderOptions {
20
+ /** Path to the opencode binary. Defaults to `opencode`. */
21
+ binary?: string;
18
22
  /** Cache TTL for session list (ms). Default: 10_000. */
19
23
  cacheTtlMs?: number;
20
24
  /**
21
- * Override the raw `opencode export <id>` capture (returns the JSON string).
25
+ * Override the raw export capture (returns the JSON string).
22
26
  * Default routes stdout to a temp file fd to bypass opencode's ~128KB pipe
23
27
  * truncation on large sessions. Injectable for testing.
24
28
  */
@@ -54,6 +58,12 @@ export declare class OpencodeSessionReader implements AgentSessionReader {
54
58
  readSessionContent(sessionId: string, cwd: string, opts?: {
55
59
  maxEvents?: number;
56
60
  }): SessionContent;
61
+ /**
62
+ * v2 export: messages are flat (`type` + `text`/`content`) and assistant
63
+ * usage lives on the message instead of a `step-finish` part. Keep this
64
+ * mapping isolated from the v1 parser so v1 behaviour stays unchanged.
65
+ */
66
+ private readV2SessionContent;
57
67
  /**
58
68
  * Check if a session is active (updated within 1 hour).
59
69
  * Passes cwd to fetchSessionList so it returns sessions for the correct directory.
@@ -63,18 +73,19 @@ export declare class OpencodeSessionReader implements AgentSessionReader {
63
73
  realpath(cwd: string): string;
64
74
  private fetchSessionList;
65
75
  /**
66
- * Capture raw `opencode export <sessionId>` output (JSON string). Injectable
76
+ * Capture raw export output (JSON string). Injectable
67
77
  * via the constructor; the default routes stdout to a temp file fd to bypass
68
78
  * opencode's ~128KB pipe truncation on large sessions.
69
79
  */
70
80
  /** 导出会话内容(public:测试直接调用,替代 as unknown as)。 */
71
81
  captureExport(sessionId: string): string;
72
82
  /**
73
- * default transport: run `opencode export` with stdout pointed at a
83
+ * default transport: run the version-appropriate export command with stdout
84
+ * pointed at a
74
85
  * regular-file fd (NOT a pipe). opencode truncates piped stdout at ~128KB for
75
86
  * large sessions (huge tool_results), breaking JSON.parse; a file fd receives
76
- * the full output (verified: 1.3MB parses OK). execFileSync returns null when
77
- * stdout is not a pipe, so we read the file instead.
87
+ * the full output (verified: 1.3MB parses OK). spawnProcessSync returns null
88
+ * stdout when it is redirected to a file fd, so we read the file instead.
78
89
  */
79
90
  private captureExportViaFileFd;
80
91
  }