@glrs-dev/cli 2.2.0 → 2.3.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/{chunk-SB3MLROC.js → chunk-MIWZLETC.js} +7 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/lib/auto-update.js +1 -1
  5. package/dist/vendor/harness-opencode/dist/agents/prompts/build.md +16 -0
  6. package/dist/vendor/harness-opencode/dist/agents/prompts/code-reviewer-thorough.md +6 -7
  7. package/dist/vendor/harness-opencode/dist/agents/prompts/debriefer.md +55 -0
  8. package/dist/vendor/harness-opencode/dist/agents/prompts/plan-reviewer.md +2 -1
  9. package/dist/vendor/harness-opencode/dist/agents/prompts/plan.md +97 -7
  10. package/dist/vendor/harness-opencode/dist/agents/prompts/prime.md +4 -2
  11. package/dist/vendor/harness-opencode/dist/agents/prompts/scoper.md +129 -0
  12. package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.md +0 -1
  13. package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.open.md +0 -1
  14. package/dist/vendor/harness-opencode/dist/autopilot/prompt-template.md +69 -45
  15. package/dist/vendor/harness-opencode/dist/chunk-GCWHRUOK.js +259 -0
  16. package/dist/vendor/harness-opencode/dist/chunk-MJSMBY2Y.js +87 -0
  17. package/dist/vendor/harness-opencode/dist/chunk-NIFAVPNN.js +544 -0
  18. package/dist/vendor/harness-opencode/dist/cli.js +448 -503
  19. package/dist/vendor/harness-opencode/dist/index.js +90 -14
  20. package/dist/vendor/harness-opencode/dist/loop-session-J35NILUZ.js +30 -0
  21. package/dist/vendor/harness-opencode/dist/opencode-server-KPCDFYAX.js +22 -0
  22. package/dist/vendor/harness-opencode/dist/plan-parser-TMHEKT22.js +6 -0
  23. package/dist/vendor/harness-opencode/dist/plan-session-7VS32P52.js +117 -0
  24. package/dist/vendor/harness-opencode/dist/scoper-S77SOK7X.js +326 -0
  25. package/dist/vendor/harness-opencode/dist/skills/spear-protocol/SKILL.md +2 -1
  26. package/dist/vendor/harness-opencode/package.json +1 -1
  27. package/package.json +3 -1
  28. package/dist/vendor/harness-opencode/dist/bin/plan-check.sh +0 -255
@@ -0,0 +1,544 @@
1
+ import {
2
+ parsePlanState
3
+ } from "./chunk-MJSMBY2Y.js";
4
+ import {
5
+ createSession,
6
+ getLastAssistantMessage,
7
+ getSessionCost,
8
+ sendAndWait,
9
+ startServer
10
+ } from "./chunk-GCWHRUOK.js";
11
+
12
+ // src/autopilot/loop.ts
13
+ import { execFile as execFileCb } from "child_process";
14
+ import { promisify } from "util";
15
+ import { readFileSync } from "fs";
16
+ import { join as join3 } from "path";
17
+
18
+ // src/lib/logger.ts
19
+ import pino from "pino";
20
+ import PinoPretty from "pino-pretty";
21
+ import { existsSync, mkdirSync } from "fs";
22
+ import { dirname, join } from "path";
23
+ function resolveStderrLevel() {
24
+ const env = process.env["GLRS_LOG_LEVEL"];
25
+ if (env && ["fatal", "error", "warn", "info", "debug", "trace", "silent"].includes(env)) {
26
+ return env;
27
+ }
28
+ return "info";
29
+ }
30
+ function shouldPrettyPrint() {
31
+ if (process.env["GLRS_LOG_FORMAT"] === "json") return false;
32
+ if (process.env["GLRS_LOG_FORMAT"] === "pretty") return true;
33
+ return process.stderr.isTTY ?? false;
34
+ }
35
+ function resolveLogFilePath(cwd) {
36
+ const env = process.env["GLRS_AUTOPILOT_LOG_FILE"];
37
+ if (env === "off") return null;
38
+ if (env) return env;
39
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
40
+ return join(cwd, ".agent", "autopilot-logs", `${timestamp}.log`);
41
+ }
42
+ function buildStderrStream(level) {
43
+ if (shouldPrettyPrint()) {
44
+ const pretty = PinoPretty({
45
+ colorize: true,
46
+ translateTime: "HH:MM:ss.l",
47
+ ignore: "pid,hostname,component",
48
+ messageFormat: "[{component}] {msg}",
49
+ destination: 2
50
+ // stderr
51
+ });
52
+ return { level, stream: pretty };
53
+ }
54
+ return {
55
+ level,
56
+ stream: pino.destination({ fd: 2, sync: false })
57
+ };
58
+ }
59
+ function buildFileStream(cwd) {
60
+ const filePath = resolveLogFilePath(cwd);
61
+ if (!filePath) return null;
62
+ const parent = dirname(filePath);
63
+ if (!existsSync(parent)) {
64
+ mkdirSync(parent, { recursive: true });
65
+ }
66
+ return {
67
+ path: filePath,
68
+ entry: {
69
+ level: "trace",
70
+ // sync: true gives deterministic flushSync semantics so the file
71
+ // log is safe to read immediately after flush() returns (critical
72
+ // for tests and for reliable postmortem after a crash). Autopilot
73
+ // runs are long-lived enough that sync writes aren't a bottleneck.
74
+ stream: pino.destination({ dest: filePath, sync: true, mkdir: true })
75
+ }
76
+ };
77
+ }
78
+ function createAutopilotLogger(opts) {
79
+ const stderrLevel = resolveStderrLevel();
80
+ const fileSink = buildFileStream(opts.cwd);
81
+ const streams = [buildStderrStream(stderrLevel)];
82
+ if (fileSink) streams.push(fileSink.entry);
83
+ const ms = pino.multistream(streams);
84
+ const root = pino(
85
+ {
86
+ level: "trace",
87
+ // router gate; individual streams apply their own levels
88
+ timestamp: pino.stdTimeFunctions.isoTime
89
+ },
90
+ ms
91
+ );
92
+ const flush = async () => {
93
+ try {
94
+ ms.flushSync();
95
+ } catch {
96
+ }
97
+ };
98
+ return {
99
+ root,
100
+ logFilePath: fileSink?.path ?? null,
101
+ flush
102
+ };
103
+ }
104
+ function childLogger(root, component) {
105
+ return root.child({ component });
106
+ }
107
+
108
+ // src/autopilot/status.ts
109
+ function formatElapsed(ms) {
110
+ const totalSeconds = Math.floor(ms / 1e3);
111
+ const hours = Math.floor(totalSeconds / 3600);
112
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
113
+ const seconds = totalSeconds % 60;
114
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
115
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
116
+ return `${seconds}s`;
117
+ }
118
+ function formatCost(usd) {
119
+ if (usd === 0) return "$0.00";
120
+ return `$${usd.toFixed(3)}`;
121
+ }
122
+ function composeStatusMessage(state, now) {
123
+ const elapsed = formatElapsed(now - state.startedAt);
124
+ const cost = formatCost(state.cumulativeCostUsd);
125
+ const iterNote = state.iterationsCompleted === 0 ? "iteration 1 in flight" : `${state.iterationsCompleted} iteration${state.iterationsCompleted === 1 ? "" : "s"} complete`;
126
+ let planNote = "";
127
+ if (state.phaseCount !== void 0 && state.phasesCompleted !== void 0 && state.mainCheckboxesTotal !== void 0 && state.mainCheckboxesCompleted !== void 0) {
128
+ planNote = `, phase ${state.phasesCompleted}/${state.phaseCount}, ${state.mainCheckboxesCompleted}/${state.mainCheckboxesTotal} boxes`;
129
+ }
130
+ if (state.lastIterationErrored) {
131
+ return `working (${iterNote}, ${elapsed} elapsed, ${cost} used${planNote}) \u2014 last iteration errored`;
132
+ }
133
+ return `working (${iterNote}, ${elapsed} elapsed, ${cost} used${planNote})`;
134
+ }
135
+ function createStatusHeartbeat(opts) {
136
+ const now = opts._deps?.now ?? (() => Date.now());
137
+ const setIntervalFn = opts._deps?.setInterval ?? setInterval;
138
+ const clearIntervalFn = opts._deps?.clearInterval ?? clearInterval;
139
+ const state = {
140
+ startedAt: now(),
141
+ iterationsCompleted: 0,
142
+ cumulativeCostUsd: 0,
143
+ lastIterationProgress: false,
144
+ lastIterationErrored: false
145
+ };
146
+ let timerId = null;
147
+ const tick = () => {
148
+ if (opts.pollCost) {
149
+ opts.pollCost().then((cost) => {
150
+ if (cost > 0) state.cumulativeCostUsd = cost;
151
+ }).catch(() => {
152
+ });
153
+ }
154
+ const message = composeStatusMessage(state, now());
155
+ opts.logger.info(
156
+ {
157
+ elapsedMs: now() - state.startedAt,
158
+ iterationsCompleted: state.iterationsCompleted,
159
+ cumulativeCostUsd: state.cumulativeCostUsd,
160
+ lastIterationProgress: state.lastIterationProgress,
161
+ lastIterationErrored: state.lastIterationErrored
162
+ },
163
+ message
164
+ );
165
+ };
166
+ return {
167
+ start() {
168
+ if (timerId !== null) return;
169
+ timerId = setIntervalFn(tick, opts.intervalMs);
170
+ },
171
+ stop() {
172
+ if (timerId === null) return;
173
+ clearIntervalFn(timerId);
174
+ timerId = null;
175
+ },
176
+ update(patch) {
177
+ Object.assign(state, patch);
178
+ },
179
+ getState() {
180
+ return { ...state };
181
+ }
182
+ };
183
+ }
184
+
185
+ // src/autopilot/config.ts
186
+ var MAX_ITERATIONS = 50;
187
+ var STRUGGLE_THRESHOLD = 3;
188
+ var TIMEOUT_MS = 4 * 60 * 60 * 1e3;
189
+ var STALL_MS = 60 * 60 * 1e3;
190
+ var KILL_SWITCH_PATH = ".agent/autopilot-disable";
191
+ var SENTINEL_TAG = "<autopilot-done>";
192
+ var STATUS_INTERVAL_MS = (() => {
193
+ const env = process.env["GLRS_AUTOPILOT_STATUS_INTERVAL_MS"];
194
+ if (!env) return 5 * 60 * 1e3;
195
+ const n = Number.parseInt(env, 10);
196
+ if (Number.isNaN(n) || n < 1e3 || n > 60 * 60 * 1e3) return 5 * 60 * 1e3;
197
+ return n;
198
+ })();
199
+
200
+ // src/autopilot/sentinel.ts
201
+ function detectSentinel(text) {
202
+ if (!text.includes(SENTINEL_TAG)) {
203
+ return false;
204
+ }
205
+ const withoutFences = text.replace(/```[\s\S]*?```/g, "");
206
+ const withoutInline = withoutFences.replace(/`[^`\n]*`/g, "");
207
+ return withoutInline.includes(SENTINEL_TAG);
208
+ }
209
+
210
+ // src/autopilot/struggle.ts
211
+ import * as fs from "fs";
212
+ import * as path from "path";
213
+ var StruggleDetector = class {
214
+ _consecutiveStalls = 0;
215
+ _threshold;
216
+ constructor(threshold) {
217
+ this._threshold = threshold;
218
+ }
219
+ /** Number of consecutive stall iterations recorded so far. */
220
+ get consecutiveStalls() {
221
+ return this._consecutiveStalls;
222
+ }
223
+ /**
224
+ * Record the result of one iteration.
225
+ * @param madeProgress - true if the agent made filesystem changes this iteration.
226
+ */
227
+ record(madeProgress) {
228
+ if (madeProgress) {
229
+ this._consecutiveStalls = 0;
230
+ } else {
231
+ this._consecutiveStalls++;
232
+ }
233
+ }
234
+ /**
235
+ * Returns true if the agent has stalled for `threshold` consecutive
236
+ * iterations without making progress.
237
+ */
238
+ isStruggling() {
239
+ return this._consecutiveStalls >= this._threshold;
240
+ }
241
+ };
242
+ function checkKillSwitch(cwd) {
243
+ const killSwitchFile = path.join(cwd, KILL_SWITCH_PATH);
244
+ return fs.existsSync(killSwitchFile);
245
+ }
246
+
247
+ // src/autopilot/loop.ts
248
+ var execFile = promisify(execFileCb);
249
+ function buildFullPrompt(userPrompt) {
250
+ const candidates = [
251
+ join3(import.meta.dir, "prompt-template.md"),
252
+ join3(import.meta.dir, "..", "..", "src", "autopilot", "prompt-template.md")
253
+ ];
254
+ let template = "";
255
+ for (const candidate of candidates) {
256
+ try {
257
+ const raw = readFileSync(candidate, "utf8");
258
+ template = raw.replace(/^---\n[\s\S]*?\n---\n/, "");
259
+ break;
260
+ } catch {
261
+ }
262
+ }
263
+ const withArgs = template.replace("$ARGUMENTS", userPrompt);
264
+ return withArgs || userPrompt;
265
+ }
266
+ async function checkProgress(cwd, baseRef) {
267
+ try {
268
+ const { stdout } = await execFile("git", ["diff", "--stat", baseRef], { cwd });
269
+ return stdout.trim().length > 0;
270
+ } catch {
271
+ return true;
272
+ }
273
+ }
274
+ async function getHeadSha(cwd) {
275
+ try {
276
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd });
277
+ return stdout.trim();
278
+ } catch {
279
+ return "HEAD";
280
+ }
281
+ }
282
+ async function runRalphLoop(opts) {
283
+ const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
284
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
285
+ const stallMs = opts.stallMs ?? STALL_MS;
286
+ const struggleThreshold = opts.struggleThreshold ?? STRUGGLE_THRESHOLD;
287
+ const _startServer = opts._deps?.startServer ?? startServer;
288
+ const _createSession = opts._deps?.createSession ?? createSession;
289
+ const _sendAndWait = opts._deps?.sendAndWait ?? sendAndWait;
290
+ const _getLastAssistantMessage = opts._deps?.getLastAssistantMessage ?? getLastAssistantMessage;
291
+ const fullPrompt = buildFullPrompt(opts.prompt);
292
+ const struggle = new StruggleDetector(struggleThreshold);
293
+ const startTime = Date.now();
294
+ const autopilotLog = createAutopilotLogger({ cwd: opts.cwd });
295
+ const log = childLogger(autopilotLog.root, "autopilot.loop");
296
+ const toolLog = childLogger(autopilotLog.root, "autopilot.tool");
297
+ const streamLog = childLogger(autopilotLog.root, "autopilot.stream");
298
+ const statusLog = childLogger(autopilotLog.root, "autopilot.status");
299
+ let heartbeat = null;
300
+ if (autopilotLog.logFilePath) {
301
+ log.info({ file: autopilotLog.logFilePath }, `Logging to ${autopilotLog.logFilePath}`);
302
+ }
303
+ log.info({ cwd: opts.cwd, maxIterations, timeoutMs }, "Starting OpenCode server");
304
+ const server = await _startServer({ cwd: opts.cwd });
305
+ log.info({ url: server.url }, "Server ready");
306
+ const abort = new AbortController();
307
+ const timeoutHandle = setTimeout(() => {
308
+ abort.abort();
309
+ }, timeoutMs);
310
+ let sessionId;
311
+ try {
312
+ sessionId = await _createSession(server.client, {
313
+ cwd: opts.cwd,
314
+ agentName: "autopilot-prime"
315
+ });
316
+ log.info({ sessionId }, "Session created with autopilot-prime");
317
+ heartbeat = createStatusHeartbeat({
318
+ logger: statusLog,
319
+ intervalMs: STATUS_INTERVAL_MS,
320
+ pollCost: async () => getSessionCost(server.client, sessionId)
321
+ });
322
+ heartbeat.start();
323
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
324
+ if (checkKillSwitch(opts.cwd)) {
325
+ log.warn({ iteration: iteration - 1 }, "Kill switch active \u2014 stopping");
326
+ return {
327
+ exitReason: "kill-switch",
328
+ iterations: iteration - 1,
329
+ message: `Kill switch active (.agent/autopilot-disable exists). Stopping after ${iteration - 1} iteration(s).`,
330
+ sessionId
331
+ };
332
+ }
333
+ if (Date.now() - startTime >= timeoutMs) {
334
+ log.warn({ iteration: iteration - 1, timeoutMs }, "Total timeout exceeded");
335
+ return {
336
+ exitReason: "timeout",
337
+ iterations: iteration - 1,
338
+ message: `Total timeout (${timeoutMs}ms) exceeded after ${iteration - 1} iteration(s).`,
339
+ sessionId
340
+ };
341
+ }
342
+ const headBefore = await getHeadSha(opts.cwd);
343
+ const iterationBaseCost = heartbeat.getState().cumulativeCostUsd;
344
+ const iterStart = Date.now();
345
+ log.debug({ iteration, maxIterations }, `Iteration ${iteration}/${maxIterations} \u2014 sending prompt`);
346
+ let streamDeltaCount = 0;
347
+ let streamCharCount = 0;
348
+ let lastStreamLogAt = 0;
349
+ let lastToolOrStreamLogAt = Date.now();
350
+ const DEBUG_STREAM_INTERVAL_MS = 15e3;
351
+ const INFO_STREAM_INTERVAL_MS = 6e4;
352
+ const result = await _sendAndWait(server.client, {
353
+ sessionId,
354
+ message: fullPrompt,
355
+ stallMs,
356
+ abortSignal: abort.signal,
357
+ // Autopilot is lights-out: auto-reject every permission prompt
358
+ // (question tool, edit gates, bash confirmations) so the agent
359
+ // can't deadlock waiting on a human response.
360
+ autoRejectPermissions: true,
361
+ serverUrl: server.url,
362
+ onPermissionRejected: (perm) => {
363
+ log.warn(
364
+ { iteration, permissionId: perm.id, permissionType: perm.type, title: perm.title },
365
+ `Auto-rejected permission: ${perm.type} \u2014 "${perm.title}"`
366
+ );
367
+ },
368
+ onToolCall: (toolName) => {
369
+ toolLog.debug({ iteration, tool: toolName }, toolName);
370
+ lastToolOrStreamLogAt = Date.now();
371
+ streamDeltaCount = 0;
372
+ streamCharCount = 0;
373
+ lastStreamLogAt = Date.now();
374
+ },
375
+ onCostUpdate: (cost, tokens) => {
376
+ heartbeat.update({
377
+ cumulativeCostUsd: iterationBaseCost + cost
378
+ });
379
+ },
380
+ onTextDelta: (charCount) => {
381
+ streamDeltaCount += 1;
382
+ streamCharCount += charCount;
383
+ const now = Date.now();
384
+ if (now - lastStreamLogAt >= DEBUG_STREAM_INTERVAL_MS) {
385
+ streamLog.debug(
386
+ { iteration, deltas: streamDeltaCount, chars: streamCharCount },
387
+ `streaming (${streamDeltaCount} deltas, ${streamCharCount} chars)`
388
+ );
389
+ lastStreamLogAt = now;
390
+ }
391
+ const silenceSinceLastTool = now - lastToolOrStreamLogAt;
392
+ if (silenceSinceLastTool >= INFO_STREAM_INTERVAL_MS) {
393
+ streamLog.info(
394
+ {
395
+ iteration,
396
+ deltas: streamDeltaCount,
397
+ chars: streamCharCount,
398
+ silenceMs: silenceSinceLastTool
399
+ },
400
+ `still streaming (${streamDeltaCount} deltas, ${streamCharCount} chars, ${Math.round(silenceSinceLastTool / 1e3)}s since last tool)`
401
+ );
402
+ lastToolOrStreamLogAt = now;
403
+ }
404
+ }
405
+ });
406
+ const iterDurationMs = Date.now() - iterStart;
407
+ if (result.kind === "abort") {
408
+ log.warn({ iteration, iterDurationMs }, "Iteration aborted (total timeout)");
409
+ return {
410
+ exitReason: "timeout",
411
+ iterations: iteration,
412
+ message: `Aborted after ${iteration} iteration(s) (total timeout exceeded).`,
413
+ sessionId
414
+ };
415
+ }
416
+ if (result.kind === "stall") {
417
+ log.warn({ iteration, stallMs: result.stallMs }, "Iteration stalled");
418
+ return {
419
+ exitReason: "stall",
420
+ iterations: iteration,
421
+ message: `Iteration ${iteration} stalled for ${result.stallMs}ms with no idle signal.`,
422
+ sessionId
423
+ };
424
+ }
425
+ if (result.kind === "error") {
426
+ log.error({ iteration, err: result.message }, "Iteration errored");
427
+ heartbeat.update({
428
+ iterationsCompleted: iteration,
429
+ lastIterationErrored: true
430
+ });
431
+ return {
432
+ exitReason: "error",
433
+ iterations: iteration,
434
+ message: `Error in iteration ${iteration}: ${result.message}`,
435
+ sessionId
436
+ };
437
+ }
438
+ if (result.kind === "question_rejected") {
439
+ log.warn(
440
+ { iteration, questionTitle: result.title },
441
+ `Question rejected \u2014 re-sending prompt with reminder (iteration ${iteration})`
442
+ );
443
+ const reminderResult = await _sendAndWait(server.client, {
444
+ sessionId,
445
+ message: fullPrompt + "\n\nIMPORTANT: Your previous attempt to use the question tool was rejected. The question tool is not available in autopilot mode. You must solve this without asking the user. Pick a sensible default, document the decision in the plan's ## Open questions, and continue working.",
446
+ stallMs,
447
+ abortSignal: abort.signal,
448
+ autoRejectPermissions: true,
449
+ serverUrl: server.url,
450
+ onPermissionRejected: (perm) => {
451
+ log.warn(
452
+ { iteration, permissionId: perm.id, permissionType: perm.type },
453
+ `Auto-rejected permission on retry: ${perm.type}`
454
+ );
455
+ },
456
+ onToolCall: (toolName) => {
457
+ toolLog.debug({ iteration, tool: toolName }, toolName);
458
+ }
459
+ });
460
+ if (reminderResult.kind === "question_rejected") {
461
+ log.error(
462
+ { iteration },
463
+ "Agent invoked question tool twice in same iteration \u2014 giving up on this iteration"
464
+ );
465
+ } else if (reminderResult.kind !== "idle") {
466
+ log.warn(
467
+ { iteration, kind: reminderResult.kind },
468
+ `Retry after question rejection returned ${reminderResult.kind}`
469
+ );
470
+ }
471
+ }
472
+ const lastMessage = await _getLastAssistantMessage(server.client, sessionId);
473
+ if (detectSentinel(lastMessage)) {
474
+ log.info({ iteration, iterDurationMs }, "Sentinel detected \u2014 autopilot done");
475
+ return {
476
+ exitReason: "sentinel",
477
+ iterations: iteration,
478
+ message: `Agent emitted <autopilot-done> at iteration ${iteration}.`,
479
+ sessionId
480
+ };
481
+ }
482
+ const madeProgress = await checkProgress(opts.cwd, headBefore);
483
+ struggle.record(madeProgress);
484
+ const cumulativeCostUsd = await getSessionCost(server.client, sessionId);
485
+ const planPathMatch = opts.prompt.match(/plans\/([^/\s]+(?:\/[^/\s]+)?)/);
486
+ let planProgressPatch = {};
487
+ if (planPathMatch) {
488
+ try {
489
+ const planPath = planPathMatch[0];
490
+ const planState = parsePlanState(planPath);
491
+ if (planState.type === "multi") {
492
+ planProgressPatch = {
493
+ phaseCount: planState.phaseCount,
494
+ phasesCompleted: planState.phasesCompleted,
495
+ mainCheckboxesTotal: planState.totalItems,
496
+ mainCheckboxesCompleted: planState.checkedItems
497
+ };
498
+ }
499
+ } catch (err) {
500
+ log.debug({ err }, "plan-parser error \u2014 falling back to plan-blind heartbeat");
501
+ }
502
+ }
503
+ heartbeat.update({
504
+ iterationsCompleted: iteration,
505
+ cumulativeCostUsd,
506
+ lastIterationProgress: madeProgress,
507
+ lastIterationErrored: false,
508
+ ...planProgressPatch
509
+ });
510
+ log.debug(
511
+ { iteration, iterDurationMs, madeProgress, cumulativeCostUsd },
512
+ `Iteration ${iteration} idle (${(iterDurationMs / 1e3).toFixed(1)}s, ${madeProgress ? "progress" : "no progress"})`
513
+ );
514
+ if (struggle.isStruggling()) {
515
+ log.warn({ iteration, struggleThreshold }, "Struggle detected \u2014 stopping");
516
+ return {
517
+ exitReason: "struggle",
518
+ iterations: iteration,
519
+ message: `Agent made no filesystem progress for ${struggleThreshold} consecutive iteration(s). Stopping at iteration ${iteration}.`,
520
+ sessionId
521
+ };
522
+ }
523
+ }
524
+ log.warn({ maxIterations }, "Reached max iterations");
525
+ return {
526
+ exitReason: "max-iterations",
527
+ iterations: maxIterations,
528
+ message: `Reached maximum iterations (${maxIterations}). Stopping.`,
529
+ sessionId
530
+ };
531
+ } finally {
532
+ clearTimeout(timeoutHandle);
533
+ heartbeat?.stop();
534
+ log.info({}, "Shutting down server");
535
+ await server.shutdown();
536
+ await autopilotLog.flush();
537
+ }
538
+ }
539
+
540
+ export {
541
+ MAX_ITERATIONS,
542
+ TIMEOUT_MS,
543
+ runRalphLoop
544
+ };