@estebanforge/pi-antigravity-bridge 1.3.1 → 1.3.3
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/CHANGELOG.md +18 -0
- package/README.md +13 -15
- package/docs/ARCHITECTURE.md +9 -40
- package/docs/DEVELOPMENT.md +5 -20
- package/docs/PI-BRIDGE-GAPS.md +7 -1
- package/extensions/index.ts +19 -7
- package/package.json +1 -3
- package/src/config.ts +17 -13
- package/src/provider.ts +51 -182
- package/src/poller.ts +0 -202
- package/src/protobuf.ts +0 -184
- package/src/runner.ts +0 -390
package/src/runner.ts
DELETED
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
// Core agy turn runner: spawn `agy -p`, poll its conversation DB for new steps,
|
|
2
|
-
// decode each, and emit structured events via a callback. Shared by the
|
|
3
|
-
// standalone CLI (scripts/run-agy.ts) and the pi provider (src/provider.ts).
|
|
4
|
-
//
|
|
5
|
-
// Streaming contract:
|
|
6
|
-
// - agy writes steps to SQLite incrementally as it works.
|
|
7
|
-
// - We poll the DB every POLL_INTERVAL_MS (250), decode new rows, emit.
|
|
8
|
-
// - After agy exits, TRAILING_POLLS x 100ms catch late flushes.
|
|
9
|
-
// - agy does NOT print the conversation id; we bind it by snapshot/diff
|
|
10
|
-
// (see discovery.ts).
|
|
11
|
-
//
|
|
12
|
-
// agy runs its OWN closed tool loop (read/write/edit/exec against --add-dir).
|
|
13
|
-
// We cannot bridge those tools to pi. Tool steps surface as "tool" events so
|
|
14
|
-
// the UI can show "agy: editing foo.ts" - the edit itself already landed.
|
|
15
|
-
|
|
16
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
-
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
18
|
-
import { ConversationPoller, type Step } from "./poller.js";
|
|
19
|
-
import {
|
|
20
|
-
CONVERSATIONS_DIR,
|
|
21
|
-
conversationDbPath,
|
|
22
|
-
newConversationId,
|
|
23
|
-
snapshotConversations,
|
|
24
|
-
} from "./discovery.js";
|
|
25
|
-
import { extractAgentText, extractToolCall, extractTitle } from "./protobuf.js";
|
|
26
|
-
|
|
27
|
-
// --- tuning -----------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
export const POLL_INTERVAL_MS = 250;
|
|
30
|
-
export const TRAILING_POLLS = 3;
|
|
31
|
-
export const TRAILING_POLL_MS = 100;
|
|
32
|
-
export const DEFAULT_TIMEOUT_MIN = 10;
|
|
33
|
-
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
34
|
-
// Cap on poll-tick retries to bind the conversation id when the snapshot is
|
|
35
|
-
// ambiguous (a concurrent agy also created a .db). Each retry can run a full
|
|
36
|
-
// /proc FD scan, so we stop after this many ticks (~15s at 250ms) and let the
|
|
37
|
-
// turn fail safe rather than scan indefinitely. Generous enough to cover a
|
|
38
|
-
// slow agy startup (OAuth refresh + cold init).
|
|
39
|
-
const MAX_BIND_ATTEMPTS = 60;
|
|
40
|
-
|
|
41
|
-
// agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
|
|
42
|
-
// leading-dash value can't misbind on agy's arg parser as the token after
|
|
43
|
-
// --conversation (flag injection). Hyphens allowed in the body (real UUIDs).
|
|
44
|
-
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
45
|
-
|
|
46
|
-
// --- events -----------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
export type AgyEvent =
|
|
49
|
-
| { kind: "text"; text: string }
|
|
50
|
-
| { kind: "thinking"; text: string }
|
|
51
|
-
| { kind: "tool"; name: string; inputJson: string }
|
|
52
|
-
| { kind: "title"; title: string };
|
|
53
|
-
|
|
54
|
-
// step_type values observed in real DBs. Tool steps share the same payload
|
|
55
|
-
// layout (field 5 -> toolCall), so we decode them uniformly. Unknown types
|
|
56
|
-
// are skipped (return null) - forward-compatible with future agy additions.
|
|
57
|
-
const TOOL_STEP_TYPES = new Set([5, 7, 8, 9, 17, 21, 33, 101, 132, 138]);
|
|
58
|
-
|
|
59
|
-
/** Map a raw step row to a decoded event, or null when nothing to emit.
|
|
60
|
-
* Pure: no I/O, no side effects. Exported for testing. */
|
|
61
|
-
export function decodeStep(step: Step): AgyEvent | null {
|
|
62
|
-
if (step.stepType === 15) {
|
|
63
|
-
const t = extractAgentText(step.payload);
|
|
64
|
-
return t ? { kind: "text", text: t.text } : null;
|
|
65
|
-
}
|
|
66
|
-
if (step.stepType === 14) {
|
|
67
|
-
// Thinking steps reuse the agentText layout (field 20.1) in observed DBs.
|
|
68
|
-
const t = extractAgentText(step.payload);
|
|
69
|
-
return t ? { kind: "thinking", text: t.text } : null;
|
|
70
|
-
}
|
|
71
|
-
if (step.stepType === 23) {
|
|
72
|
-
const title = extractTitle(step.payload);
|
|
73
|
-
return title ? { kind: "title", title } : null;
|
|
74
|
-
}
|
|
75
|
-
if (TOOL_STEP_TYPES.has(step.stepType)) {
|
|
76
|
-
const tc = extractToolCall(step.payload);
|
|
77
|
-
if (tc?.name) return { kind: "tool", name: tc.name, inputJson: tc.inputJson };
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// --- options + result -------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
export interface AgyRunOptions {
|
|
86
|
-
/** Workspace root agy operates in (passed as --add-dir). */
|
|
87
|
-
cwd: string;
|
|
88
|
-
/** Exact agy model string, e.g. "Gemini 3.6 Flash (Medium)". Caller
|
|
89
|
-
* resolves aliases; the runner passes this through verbatim. */
|
|
90
|
-
model?: string;
|
|
91
|
-
/** Reasoning effort passed to agy --effort (low|medium|high). Omit to let
|
|
92
|
-
* the model slug's own effort stand (requires agy >= 1.1.5). */
|
|
93
|
-
effort?: "low" | "medium" | "high";
|
|
94
|
-
/** agy execution mode. accept-edits = agy applies edits; plan = review-only. */
|
|
95
|
-
mode?: "accept-edits" | "plan";
|
|
96
|
-
/** Pass --dangerously-skip-permissions so commands don't hang on an
|
|
97
|
-
* unanswerable y/n prompt in non-interactive `-p` mode. Default true.
|
|
98
|
-
* Required for accept-edits to function; harmless under plan mode. */
|
|
99
|
-
skipPermissions?: boolean;
|
|
100
|
-
/** The prompt. Required. */
|
|
101
|
-
prompt: string;
|
|
102
|
-
/** Existing conversation id to resume. When set, agy reuses it (no snapshot). */
|
|
103
|
-
conversationId?: string | null;
|
|
104
|
-
/** Highest step idx already streamed in a prior turn (resume only). The
|
|
105
|
-
* poller starts reading AFTER this idx so resumed turns don't replay
|
|
106
|
-
* history. Default -1 (read everything). */
|
|
107
|
-
baseStepIdx?: number;
|
|
108
|
-
/** Hard cap on the run, in minutes. */
|
|
109
|
-
timeoutMin?: number;
|
|
110
|
-
/** Optional AbortSignal for cancellation. */
|
|
111
|
-
signal?: AbortSignal;
|
|
112
|
-
/** Conversations dir override (testing / isolation). */
|
|
113
|
-
conversationsDir?: string;
|
|
114
|
-
/** agy binary path. Defaults to AGY_BIN env or "agy". */
|
|
115
|
-
binary?: string;
|
|
116
|
-
/** Extra args appended (split from AGY_EXTRA_ARGS by the caller). */
|
|
117
|
-
extraArgs?: string[];
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export interface AgyRunResult {
|
|
121
|
-
exitCode: number;
|
|
122
|
-
conversationId: string | null;
|
|
123
|
-
lastIdx: number;
|
|
124
|
-
aborted: boolean;
|
|
125
|
-
timedOut: boolean;
|
|
126
|
-
stderr: string;
|
|
127
|
-
durationMs: number;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- spawn helpers ----------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
function resolveBinary(explicit?: string): string {
|
|
133
|
-
return explicit || process.env.AGY_BIN || "agy";
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function extraArgsFromEnv(): string[] {
|
|
137
|
-
const raw = process.env.AGY_EXTRA_ARGS;
|
|
138
|
-
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
142
|
-
|
|
143
|
-
// --- the turn ---------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
/** Spawn agy and stream decoded events until it exits. Calls `onEvent` for
|
|
146
|
-
* every decoded step in order. Returns the run outcome (exit code, discovered
|
|
147
|
-
* conversation id, last step idx seen). Never throws on agy failure -
|
|
148
|
-
* surfaces non-zero exit / timeout / abort in the result. */
|
|
149
|
-
export async function runAgyTurn(
|
|
150
|
-
opts: AgyRunOptions,
|
|
151
|
-
onEvent: (event: AgyEvent) => void,
|
|
152
|
-
): Promise<AgyRunResult> {
|
|
153
|
-
const start = Date.now();
|
|
154
|
-
const dir = opts.conversationsDir || CONVERSATIONS_DIR;
|
|
155
|
-
const mode = opts.mode ?? "accept-edits";
|
|
156
|
-
const timeoutMin = opts.timeoutMin ?? DEFAULT_TIMEOUT_MIN;
|
|
157
|
-
const binary = resolveBinary(opts.binary);
|
|
158
|
-
const extra = [...extraArgsFromEnv(), ...(opts.extraArgs ?? [])];
|
|
159
|
-
|
|
160
|
-
const rawConvId = opts.conversationId ?? null;
|
|
161
|
-
const isContinuation =
|
|
162
|
-
typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
|
|
163
|
-
const snapshot = isContinuation ? null : snapshotConversations(dir);
|
|
164
|
-
let lastIdx = opts.baseStepIdx ?? -1;
|
|
165
|
-
|
|
166
|
-
// Build argv. --add-dir first so agy binds the workspace before anything.
|
|
167
|
-
const args = ["--add-dir", opts.cwd, ...extra];
|
|
168
|
-
// When the MCP tool bridge is running, add its config dir so this agy
|
|
169
|
-
// discovers pi's tools (memory, codegraph, search, ...). agy reads
|
|
170
|
-
// .agents/mcp_config.json from --add-dir dirs. AskAntigravity does NOT pass
|
|
171
|
-
// this dir, so its agy stays plain (no recursion).
|
|
172
|
-
if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
|
|
173
|
-
if (opts.model) args.push("--model", opts.model);
|
|
174
|
-
if (opts.effort) args.push("--effort", opts.effort);
|
|
175
|
-
args.push("--mode", mode);
|
|
176
|
-
// Without this, any run_command triggers an interactive permission prompt
|
|
177
|
-
// that hangs forever in non-interactive print mode (no TTY to answer y/n).
|
|
178
|
-
// accept-edits only auto-approves file writes, NOT commands. See PLAN.md.
|
|
179
|
-
if (opts.skipPermissions !== false) args.push("--dangerously-skip-permissions");
|
|
180
|
-
if (isContinuation && rawConvId) args.push("--conversation", rawConvId);
|
|
181
|
-
args.push("--print-timeout", `${timeoutMin}m`);
|
|
182
|
-
args.push("-p", opts.prompt);
|
|
183
|
-
|
|
184
|
-
let stderr = "";
|
|
185
|
-
let boundId = isContinuation ? rawConvId : null;
|
|
186
|
-
// Counts bind attempts so the /proc FD scan can't run on every tick forever
|
|
187
|
-
// when the snapshot stays ambiguous (see MAX_BIND_ATTEMPTS).
|
|
188
|
-
let bindAttempts = 0;
|
|
189
|
-
|
|
190
|
-
// One poller per turn, lazily opened once we know the conversation id.
|
|
191
|
-
let poller: ConversationPoller | null = null;
|
|
192
|
-
// agy extends the step it is currently writing in place (same idx, growing
|
|
193
|
-
// text). poll() returns only idx > lastIdx, so we re-read the last
|
|
194
|
-
// text/thinking step each tick and emit the grown suffix as a delta.
|
|
195
|
-
// flushStreamStep also runs BEFORE the loop so a step boundary landing
|
|
196
|
-
// inside this tick doesn't drop the outgoing step's final in-place tail
|
|
197
|
-
// (the loop may switch streamIdx to a new idx before we'd re-read).
|
|
198
|
-
let streamIdx = -1;
|
|
199
|
-
let streamKind: "text" | "thinking" | null = null;
|
|
200
|
-
let streamEmitted = 0;
|
|
201
|
-
const flushStreamStep = (): void => {
|
|
202
|
-
if (streamIdx < 0 || !poller || !streamKind) return;
|
|
203
|
-
const s = poller.readStepAt(streamIdx);
|
|
204
|
-
if (!s) return;
|
|
205
|
-
try {
|
|
206
|
-
const t = extractAgentText(s.payload);
|
|
207
|
-
if (t && t.text.length > streamEmitted) {
|
|
208
|
-
onEvent({ kind: streamKind, text: t.text.slice(streamEmitted) });
|
|
209
|
-
streamEmitted = t.text.length;
|
|
210
|
-
}
|
|
211
|
-
} catch {
|
|
212
|
-
/* torn re-read; next poll retries */
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
const pollOnce = (): boolean => {
|
|
216
|
-
// Bind the conversation id on a fresh run (agy doesn't print it).
|
|
217
|
-
// pid lets newConversationId disambiguate when a concurrent agy also
|
|
218
|
-
// drops a new .db in the dir during our turn (see discovery.ts).
|
|
219
|
-
if (!boundId && snapshot && bindAttempts < MAX_BIND_ATTEMPTS) {
|
|
220
|
-
boundId = newConversationId(dir, snapshot, {
|
|
221
|
-
pid: proc?.pid,
|
|
222
|
-
// Only the genuinely-ambiguous case (>1 new DB, unresolved) counts
|
|
223
|
-
// against the cap. The ordinary "agy hasn't written its DB yet" wait
|
|
224
|
-
// must keep polling for the full turn timeout, not burn a budget
|
|
225
|
-
// meant to bound the expensive /proc FD-scan retries.
|
|
226
|
-
onAmbiguous: () => { bindAttempts++; },
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
if (!boundId) return false;
|
|
230
|
-
|
|
231
|
-
if (!poller) {
|
|
232
|
-
poller = new ConversationPoller(conversationDbPath(boundId, dir), lastIdx);
|
|
233
|
-
}
|
|
234
|
-
if (!poller.isOpen && !poller.tryOpen()) return false;
|
|
235
|
-
|
|
236
|
-
// Coalesce: one data_version check per tick gates BOTH the in-place
|
|
237
|
-
// re-read (flushStreamStep -> readStepAt) and the new-row read. While
|
|
238
|
-
// agy is thinking and hasn't committed, hasChanged() is false and we
|
|
239
|
-
// skip both SELECTs, so no row read fires on an idle tick.
|
|
240
|
-
if (!poller.hasChanged()) return false;
|
|
241
|
-
|
|
242
|
-
// Catch the currently-tracked step's final in-place growth BEFORE the
|
|
243
|
-
// loop may switch tracking to a new step.
|
|
244
|
-
flushStreamStep();
|
|
245
|
-
const steps = poller.readNewSteps();
|
|
246
|
-
for (const step of steps) {
|
|
247
|
-
// A torn read (agy mid-write) can throw RangeError out of the protobuf
|
|
248
|
-
// walker. Drop that step rather than aborting the whole turn - the
|
|
249
|
-
// row settles on the next poll. (agy-acp database.ts pattern, lifted
|
|
250
|
-
// to the decode layer where the throw actually originates.)
|
|
251
|
-
try {
|
|
252
|
-
const event = decodeStep(step);
|
|
253
|
-
if (event) {
|
|
254
|
-
onEvent(event);
|
|
255
|
-
if (event.kind === "text" || event.kind === "thinking") {
|
|
256
|
-
streamIdx = step.idx;
|
|
257
|
-
streamKind = event.kind;
|
|
258
|
-
streamEmitted = event.text.length;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
/* drop undecodable step; lastIdx still advances past it */
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
flushStreamStep();
|
|
266
|
-
lastIdx = poller.lastIdx;
|
|
267
|
-
return steps.length > 0;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const result: AgyRunResult = {
|
|
271
|
-
exitCode: 0,
|
|
272
|
-
conversationId: null,
|
|
273
|
-
lastIdx: -1,
|
|
274
|
-
aborted: false,
|
|
275
|
-
timedOut: false,
|
|
276
|
-
stderr: "",
|
|
277
|
-
durationMs: 0,
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
let proc: ChildProcess | null = null;
|
|
281
|
-
let settled = false;
|
|
282
|
-
let timedOut = false;
|
|
283
|
-
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
284
|
-
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
285
|
-
|
|
286
|
-
const killTree = () => {
|
|
287
|
-
try {
|
|
288
|
-
if (proc?.pid) process.kill(-proc.pid, "SIGTERM");
|
|
289
|
-
} catch {
|
|
290
|
-
/* process group already gone */
|
|
291
|
-
}
|
|
292
|
-
if (!sigkillTimer) {
|
|
293
|
-
sigkillTimer = setTimeout(() => {
|
|
294
|
-
try {
|
|
295
|
-
if (proc?.pid) process.kill(-proc.pid, "SIGKILL");
|
|
296
|
-
} catch {
|
|
297
|
-
/* give up */
|
|
298
|
-
}
|
|
299
|
-
}, GRACE_AFTER_TIMEOUT_MS);
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const onAbort = () => killTree();
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
await new Promise<void>((resolveP, rejectP) => {
|
|
307
|
-
// detached: true so we can signal the whole process group. agy
|
|
308
|
-
// spawns its own exec subprocesses in -p mode; a direct kill would
|
|
309
|
-
// orphan those grandchildren.
|
|
310
|
-
proc = spawn(binary, args, {
|
|
311
|
-
cwd: opts.cwd,
|
|
312
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
313
|
-
shell: false,
|
|
314
|
-
detached: true,
|
|
315
|
-
});
|
|
316
|
-
proc.stderr?.setEncoding("utf8");
|
|
317
|
-
proc.stderr?.on("data", (d: string) => (stderr += d));
|
|
318
|
-
|
|
319
|
-
// Drive the DB poll concurrently with the running process. THIS is the
|
|
320
|
-
// streaming: without it, no event reaches the caller until agy exits,
|
|
321
|
-
// defeating the whole point of the provider. Cleared in cleanup().
|
|
322
|
-
const pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
|
323
|
-
pollTimer.unref?.();
|
|
324
|
-
|
|
325
|
-
const cleanup = () => {
|
|
326
|
-
clearInterval(pollTimer);
|
|
327
|
-
if (watchdog) clearTimeout(watchdog);
|
|
328
|
-
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
329
|
-
if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
// Enforce the timeout ourselves (agy's --print-timeout is advisory).
|
|
333
|
-
watchdog = setTimeout(() => {
|
|
334
|
-
timedOut = true;
|
|
335
|
-
killTree();
|
|
336
|
-
}, timeoutMin * 60_000);
|
|
337
|
-
|
|
338
|
-
if (opts.signal) {
|
|
339
|
-
if (opts.signal.aborted) killTree();
|
|
340
|
-
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const finish = (code: number | null) => {
|
|
344
|
-
if (settled) return;
|
|
345
|
-
settled = true;
|
|
346
|
-
cleanup();
|
|
347
|
-
resolveP();
|
|
348
|
-
void code; // exit code read off proc below
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
proc.on("error", (err) => {
|
|
352
|
-
cleanup();
|
|
353
|
-
rejectP(err);
|
|
354
|
-
});
|
|
355
|
-
proc.on("close", finish);
|
|
356
|
-
proc.on("exit", finish);
|
|
357
|
-
});
|
|
358
|
-
} catch (err) {
|
|
359
|
-
// spawn ENOENT etc. - surface as a non-zero result, don't throw.
|
|
360
|
-
stderr += err instanceof Error ? err.message : String(err);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Capture abort state before the trailing-poll loop. On cancel we skip the
|
|
364
|
-
// trailing polls (3 x 100ms) so the stream finalizes promptly with whatever
|
|
365
|
-
// was already streamed, instead of stalling ~300ms after agy was killed.
|
|
366
|
-
const wasAborted = !!opts.signal?.aborted;
|
|
367
|
-
|
|
368
|
-
// Trailing polls: agy may flush a final step moments after exit. The
|
|
369
|
-
// agy-acp pattern (3 x 100ms) catches these without adding noticeable latency.
|
|
370
|
-
if (!wasAborted) {
|
|
371
|
-
for (let i = 0; i < TRAILING_POLLS; i++) {
|
|
372
|
-
pollOnce();
|
|
373
|
-
await sleep(TRAILING_POLL_MS);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// CFA note: poller/proc are assigned inside closures that TS can't track
|
|
378
|
-
// through the await, so they narrow to `null` here. Casts break the
|
|
379
|
-
// narrowing without lying about the runtime type.
|
|
380
|
-
(poller as ConversationPoller | null)?.close();
|
|
381
|
-
|
|
382
|
-
result.exitCode = (proc as ChildProcess | null)?.exitCode ?? (stderr ? 1 : 0);
|
|
383
|
-
result.aborted = wasAborted;
|
|
384
|
-
result.timedOut = timedOut;
|
|
385
|
-
result.conversationId = boundId;
|
|
386
|
-
result.lastIdx = lastIdx;
|
|
387
|
-
result.stderr = stderr;
|
|
388
|
-
result.durationMs = Date.now() - start;
|
|
389
|
-
return result;
|
|
390
|
-
}
|