@loadmill/droid-cua 2.25.0 → 2.27.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 (37) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +210 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-execution-config.js +11 -0
  5. package/build/src/cli/headless-report.js +105 -15
  6. package/build/src/cli/headless-runner.js +125 -24
  7. package/build/src/cli/headless-target.js +63 -7
  8. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  9. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  10. package/build/src/commands/help.js +5 -0
  11. package/build/src/core/cua-models.js +7 -0
  12. package/build/src/core/droid-cua-run-usage.js +8 -2
  13. package/build/src/core/execution-engine.js +6 -2
  14. package/build/src/core/report/render-html-report.js +69 -12
  15. package/build/src/core/runtime-config.js +10 -4
  16. package/build/src/device/assertions.js +3 -0
  17. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  18. package/build/src/device/cloud/connection.js +18 -6
  19. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  20. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  21. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  22. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  23. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  24. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  25. package/build/src/device/cloud/progress.js +29 -0
  26. package/build/src/device/cloud/registry.js +2 -1
  27. package/build/src/device/connection.js +3 -0
  28. package/build/src/device/openai.js +1 -0
  29. package/build/src/integrations/loadmill/client.js +14 -0
  30. package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
  31. package/build/src/integrations/loadmill/run-reporter.js +6 -2
  32. package/build/src/modes/execution-mode.js +2 -1
  33. package/build/src/prompts/design.js +9 -1
  34. package/build/src/prompts/execution.js +10 -7
  35. package/build/src/utils/console-output.js +3 -0
  36. package/package.json +1 -1
  37. package/skill/droid-cua/references/running-and-healing.md +3 -3
package/README.md CHANGED
@@ -48,7 +48,10 @@ Open the desktop app and choose the platform you want to test.
48
48
 
49
49
  **3. Choose your AI provider**
50
50
 
51
- In Settings, choose the CUA model you want to run. `Loadmill - Smart` uses your Loadmill sign-in and usage tracking. The OpenAI models use your own OpenAI API key.
51
+ In Settings, choose the CUA model you want to run. Loadmill Smart is the
52
+ recommended option, Pulse is fast and efficient, and Beacon is experimental.
53
+ The Loadmill models use your Loadmill sign-in and usage tracking. The OpenAI
54
+ models use your own OpenAI API key.
52
55
 
53
56
  **4. Connect a target device**
54
57
 
@@ -75,7 +78,7 @@ Desktop app support:
75
78
  ## Requirements
76
79
 
77
80
  **All platforms:**
78
- - Loadmill sign-in for `Loadmill - Smart`, or an OpenAI API key for the OpenAI models
81
+ - Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
79
82
 
80
83
  **Android:**
81
84
  - Android Debug Bridge (ADB)
@@ -247,6 +250,7 @@ Supported CLI options include:
247
250
  - `--no-context`
248
251
  - `--context-optimization-threshold`
249
252
  - `--no-context-optimization`
253
+ - `--explain`
250
254
  - `--base-prompt`
251
255
  - `--execution-prompt`
252
256
  - `--base-prompt-file`
@@ -254,11 +258,12 @@ Supported CLI options include:
254
258
  - `--secrets`
255
259
  - `--record`
256
260
  - `--report`
261
+ - `--no-cloud-report`
257
262
  - `--debug`
258
263
 
259
- `--cua-model` accepts `loadmill-smart`, `loadmill-beacon`, `gpt-5.6-terra`,
260
- `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI mode and require
261
- `OPENAI_API_KEY`.
264
+ `--cua-model` accepts `loadmill-smart`, `loadmill-pulse`, `loadmill-beacon`,
265
+ `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
266
+ mode and require `OPENAI_API_KEY`.
262
267
 
263
268
  Config and precedence rules:
264
269
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
@@ -268,6 +273,8 @@ Config and precedence rules:
268
273
  - Context Optimization is enabled by default with a `30000` input-token threshold.
269
274
  - `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
270
275
  - `--no-context-optimization` disables Context Optimization.
276
+ - `--explain` enables Explain Mode for the run. Explain Mode asks the agent for concise explanations of non-obvious decisions, skipped actions, and completion evidence; explanations appear in terminal output and reports.
277
+ - Set `"explainMode": true` in the JSON config to enable the same behavior without a flag. Explain Mode is off by default, and `--explain` enables it when the config leaves it off.
271
278
  - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
272
279
  - `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
273
280
  - If both inline and file-based prompt overrides are provided, the inline prompt flags win.
@@ -283,6 +290,7 @@ HTML report (`--report`):
283
290
  - Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
284
291
  - With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
285
292
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
293
+ - Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. Use `--no-cloud-report` to disable that artifact upload. The basic Droid run row is still reported to Loadmill.
286
294
 
287
295
  ```sh
288
296
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --report ./reports
package/build/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ const processStartTime = Date.now();
1
2
  import minimist from "minimist";
2
3
  import dotenv from "dotenv";
3
4
  import path from "path";
@@ -17,7 +18,7 @@ import { resolveExecutionTestData } from "./src/core/test-data.js";
17
18
  import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
18
19
  import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
20
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
- import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
+ import { resolveHeadlessTarget, shouldWaitForLoadmillCloudCompletion } from "./src/cli/headless-target.js";
21
22
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
22
23
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
23
24
  import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
@@ -25,12 +26,33 @@ import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
25
26
  import { planScenarioRuns } from "./src/cli/scenario-planner.js";
26
27
  import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
27
28
  import { resolveAwsDeviceFarmCredentials, uploadAwsDeviceFarmCliApp } from "./src/cli/aws-device-farm-app-upload.js";
29
+ import { resolveLoadmillCloudCredentials } from "./src/cli/loadmill-cloud-credentials.js";
30
+ import { formatCloudUsageReport } from "./src/cli/loadmill-cloud-usage.js";
31
+ import { LoadmillCloudControlPlaneClient } from "./src/device/cloud/loadmill-cloud/control-plane-client.js";
28
32
  import { createStatusLine } from "./src/cli/cloud-status-line.js";
29
33
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
34
+ import { droidReportUploadManager } from "./src/integrations/loadmill/report-upload-manager.js";
30
35
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
31
36
  import { setCloudProgressListener } from "./src/device/cloud/progress.js";
32
37
  import { runSkillCommand } from "./src/cli/skill-command.js";
38
+ import { isLoadmillCloudUsageLimitError } from "./src/device/cloud/loadmill-cloud/errors.js";
39
+ import { loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
40
+ import { formatDuration } from "./src/core/report/run-log-model.js";
33
41
  dotenv.config();
42
+ process.once("SIGTERM", async () => {
43
+ process.exitCode = 143;
44
+ // Registering a SIGTERM listener cancels Node's default terminate, so exit
45
+ // explicitly; await the /stop because a bare fire-and-forget fetch may not
46
+ // dispatch before exit. Capped so a slow server can't hang the exit.
47
+ await Promise.race([
48
+ loadmillCloudAdapter.releaseActiveSession(),
49
+ new Promise((resolve) => setTimeout(resolve, 1_500)),
50
+ ]);
51
+ process.exit(143);
52
+ });
53
+ process.once("beforeExit", () => {
54
+ loadmillCloudAdapter.releaseActiveSession();
55
+ });
34
56
  const args = minimist(process.argv.slice(2));
35
57
  if (args._[0] === "skill") {
36
58
  try {
@@ -57,6 +79,7 @@ const debugMode = args["debug"] || false;
57
79
  const reportConfig = {
58
80
  enabled: args.report === true || (typeof args.report === "string" && args.report.trim().length > 0),
59
81
  path: typeof args.report === "string" && args.report.trim().length > 0 ? args.report.trim() : null,
82
+ cloudEnabled: args["no-cloud-report"] !== true,
60
83
  };
61
84
  const screenshotDir = path.join("droid-cua-recording-" + Date.now());
62
85
  if (recordScreenshots)
@@ -173,6 +196,84 @@ async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts
173
196
  return null;
174
197
  }
175
198
  async function main() {
199
+ if (args["cloud-usage"] != null) {
200
+ try {
201
+ const credentials = resolveLoadmillCloudCredentials(process.env);
202
+ const client = new LoadmillCloudControlPlaneClient({
203
+ bearerToken: credentials.bearerToken,
204
+ baseUrl: credentials.baseUrl,
205
+ });
206
+ const usage = await client.getUsage();
207
+ for (const line of formatCloudUsageReport(usage)) {
208
+ console.log(line);
209
+ }
210
+ process.exit(0);
211
+ }
212
+ catch (error) {
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ const code = error?.code ? ` [${error.code}]` : "";
215
+ console.error(`Failed to fetch Loadmill Cloud usage: ${message}${code}`);
216
+ process.exit(1);
217
+ }
218
+ }
219
+ // Pure retroactive artifact fetch: `--cloud-session-id <sid> --artifacts <kinds>`
220
+ // with no --instructions. Reattaching to a terminal run and downloading its
221
+ // recordings/logs doesn't require driving anything, so it doesn't need the
222
+ // full headless-instructions machinery below (which requires --instructions
223
+ // for every cloud device-source run).
224
+ if (targetConfig.deviceSource === "loadmill-cloud" &&
225
+ targetConfig.cloudSessionId &&
226
+ Array.isArray(targetConfig.artifacts) &&
227
+ targetConfig.artifacts.length > 0 &&
228
+ !instructionsFile) {
229
+ const credentials = resolveLoadmillCloudCredentials(process.env);
230
+ // Share the exact same rendering the normal run command uses — install
231
+ // the progress listener before touching the adapter, since prepareSession
232
+ // reports poll/download progress unconditionally and would otherwise be
233
+ // silently dropped (no listener installed => no output for 10-20s).
234
+ const retroactiveStatusLine = createStatusLine();
235
+ setCloudProgressListener((event) => {
236
+ // The run may still be finishing up on the cloud device when this
237
+ // starts — only switch the umbrella label to "fetching" once the
238
+ // artifact list is actually in hand and downloads are starting.
239
+ if (event.kind === "note" && event.message === "Fetching artifacts") {
240
+ retroactiveStatusLine.rename("Fetching Loadmill Cloud artifacts");
241
+ }
242
+ retroactiveStatusLine.handleEvent(event);
243
+ });
244
+ try {
245
+ retroactiveStatusLine.start("Waiting for Loadmill Cloud artifacts");
246
+ await connectToDevice({
247
+ source: "loadmill-cloud",
248
+ providerId: "loadmill-cloud",
249
+ platform: targetConfig.platform,
250
+ deviceName: targetConfig.deviceName,
251
+ osVersion: targetConfig.osVersion,
252
+ appRef: targetConfig.appPath,
253
+ credentials,
254
+ cloudSessionId: targetConfig.cloudSessionId,
255
+ artifacts: targetConfig.artifacts,
256
+ }, null, { log: () => { } });
257
+ retroactiveStatusLine.stop();
258
+ // A resumable (non-terminal) session reconnected successfully instead of
259
+ // exiting via the terminal/artifacts branch above — there's nothing to
260
+ // drive without --instructions, so release the slot and say so.
261
+ await loadmillCloudAdapter.releaseSession(credentials, { waitForCompletion: false });
262
+ console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
263
+ process.exit(1);
264
+ }
265
+ catch (error) {
266
+ retroactiveStatusLine.stop();
267
+ if (error?.artifactsOnlyExit) {
268
+ console.log(error.message);
269
+ console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
270
+ process.exit(0);
271
+ }
272
+ const message = error instanceof Error ? error.message : String(error);
273
+ console.error(`Loadmill Cloud artifact fetch failed: ${message}`);
274
+ process.exit(1);
275
+ }
276
+ }
176
277
  if (targetConfig.listDevices) {
177
278
  const credentials = resolveAwsDeviceFarmCredentials(process.env);
178
279
  const adapter = getCloudProviderAdapter("aws-device-farm");
@@ -188,12 +289,15 @@ async function main() {
188
289
  const isBrowserTarget = targetConfig.target === "web";
189
290
  const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
190
291
  const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
191
- const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget;
292
+ const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
293
+ const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget || isLoadmillCloudTarget;
192
294
  let lambdaTestSessionConnected = false;
193
295
  let lambdaTestSessionUrl = null;
194
296
  let lambdaTestCredentials = null;
195
297
  let awsSessionConnected = false;
196
298
  let awsDeviceFarmStatusLine = null;
299
+ let loadmillCloudSessionConnected = false;
300
+ let loadmillCloudStatusLine = null;
197
301
  const headlessDebug = createHeadlessDebugArtifacts({
198
302
  cwd: process.cwd(),
199
303
  enabled: isHeadlessInstructionsRun && debugMode
@@ -407,6 +511,94 @@ async function main() {
407
511
  console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
408
512
  console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
409
513
  }
514
+ else if (isLoadmillCloudTarget) {
515
+ const loadmillCloudCredentials = resolveLoadmillCloudCredentials(process.env);
516
+ loadmillCloudStatusLine = createStatusLine();
517
+ const statusLine = loadmillCloudStatusLine;
518
+ setCloudProgressListener((event) => statusLine.handleEvent(event));
519
+ const provisioningAbortController = new AbortController();
520
+ let loadmillCloudSigintHandled = false;
521
+ let loadmillCloudInterruptRequested = false;
522
+ process.once("SIGINT", async () => {
523
+ if (loadmillCloudSigintHandled)
524
+ return;
525
+ loadmillCloudSigintHandled = true;
526
+ loadmillCloudInterruptRequested = true;
527
+ console.log("\nStopping Loadmill Cloud session...");
528
+ provisioningAbortController.abort(new Error("Stopped by user"));
529
+ if (!loadmillCloudSessionConnected) {
530
+ process.exitCode = 130;
531
+ return;
532
+ }
533
+ await disconnectDevice({ waitForCompletion: false });
534
+ loadmillCloudAdapter.releaseActiveSession();
535
+ process.exit(130);
536
+ });
537
+ try {
538
+ statusLine.start("Starting Loadmill Cloud session");
539
+ targetId = await connectToDevice({
540
+ source: "loadmill-cloud",
541
+ providerId: "loadmill-cloud",
542
+ platform: targetConfig.platform,
543
+ deviceName: targetConfig.deviceName,
544
+ osVersion: targetConfig.osVersion,
545
+ appRef: targetConfig.appPath,
546
+ credentials: loadmillCloudCredentials,
547
+ sessionName: `droid-cua-cli-${new Date().toISOString()}`,
548
+ cloudSessionId: targetConfig.cloudSessionId,
549
+ artifacts: targetConfig.artifacts,
550
+ signal: provisioningAbortController.signal,
551
+ }, null,
552
+ // These are one-off descriptive messages (not the name of the
553
+ // in-progress step), so they must render as persistent ℹ lines —
554
+ // never painted into the volatile detail slot, where a stray message
555
+ // like "Connected to ..." would linger after its moment has passed
556
+ // and contradict an already-closed "✔ <phase>" line above it.
557
+ { log: (message) => statusLine.handleEvent({ kind: "note", message }) });
558
+ loadmillCloudSessionConnected = true;
559
+ if (loadmillCloudInterruptRequested) {
560
+ try {
561
+ await disconnectDevice({ waitForCompletion: false });
562
+ loadmillCloudAdapter.releaseActiveSession();
563
+ }
564
+ finally {
565
+ statusLine.stop();
566
+ loadmillCloudSessionConnected = false;
567
+ process.exitCode = 130;
568
+ }
569
+ return;
570
+ }
571
+ deviceInfo = await getDeviceInfo(targetId);
572
+ statusLine.stop();
573
+ }
574
+ catch (error) {
575
+ statusLine.stop();
576
+ if (error?.artifactsOnlyExit) {
577
+ console.log(error.message);
578
+ process.exitCode = 0;
579
+ return;
580
+ }
581
+ if (loadmillCloudSessionConnected) {
582
+ await disconnectDevice({ waitForCompletion: false });
583
+ loadmillCloudAdapter.releaseActiveSession();
584
+ loadmillCloudSessionConnected = false;
585
+ }
586
+ if (loadmillCloudInterruptRequested) {
587
+ process.exitCode = 130;
588
+ return;
589
+ }
590
+ if (isLoadmillCloudUsageLimitError(error)) {
591
+ const suffix = error.code ? ` (${error.code})` : "";
592
+ console.error(`\n${error.message}${suffix}`);
593
+ process.exitCode = 1;
594
+ return;
595
+ }
596
+ throw error;
597
+ }
598
+ console.log(`Connected to Loadmill Cloud device "${targetConfig.deviceName}" (${targetId})`);
599
+ console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
600
+ console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
601
+ }
410
602
  else {
411
603
  targetId = await connectToDevice(avdName, platform);
412
604
  deviceInfo = await getDeviceInfo(targetId);
@@ -420,6 +612,7 @@ async function main() {
420
612
  // If --instructions provided, run in headless mode
421
613
  if (instructionsFile) {
422
614
  let exitCode = null;
615
+ let succeeded = false;
423
616
  try {
424
617
  const headlessConfig = preloadedHeadlessConfig;
425
618
  const appContext = preloadedAppContext;
@@ -427,6 +620,7 @@ async function main() {
427
620
  const runtimeContext = createCliRuntimeContext({
428
621
  llmProviderMode: headlessConfig.llmProviderMode,
429
622
  cuaModel: headlessConfig.cuaModel,
623
+ explainMode: headlessConfig.explainMode,
430
624
  promptCustomizations: headlessConfig.promptCustomizations,
431
625
  contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
432
626
  contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
@@ -477,8 +671,10 @@ async function main() {
477
671
  report: reportConfig
478
672
  });
479
673
  exitCode = result.exitCode;
674
+ succeeded = true;
480
675
  }
481
676
  finally {
677
+ await droidReportUploadManager.drain();
482
678
  if (lambdaTestSessionConnected) {
483
679
  console.log("Ending LambdaTest session...");
484
680
  await disconnectDevice();
@@ -505,6 +701,18 @@ async function main() {
505
701
  awsSessionConnected = false;
506
702
  console.log("AWS Device Farm session ended.");
507
703
  }
704
+ if (loadmillCloudSessionConnected) {
705
+ console.log("Ending Loadmill Cloud session...");
706
+ loadmillCloudStatusLine.start("Ending Loadmill Cloud session");
707
+ await disconnectDevice({ waitForCompletion: shouldWaitForLoadmillCloudCompletion(targetConfig) });
708
+ loadmillCloudAdapter.releaseActiveSession();
709
+ loadmillCloudStatusLine.stop();
710
+ loadmillCloudSessionConnected = false;
711
+ console.log("Loadmill Cloud session ended.");
712
+ if (succeeded) {
713
+ console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
714
+ }
715
+ }
508
716
  }
509
717
  if (exitCode !== null) {
510
718
  process.exit(exitCode);
@@ -1,14 +1,5 @@
1
1
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
- /**
3
- * Heroku-style volatile status line for long-running cloud-provider steps.
4
- * On a TTY each progress update overwrites the previous one (spinner + step
5
- * name + elapsed + latest detail) and the whole line is cleared when the step
6
- * finishes. On a non-TTY (CI, piped output) it degrades to plain log lines,
7
- * printed only when the detail changes. Feed it cloud progress events via
8
- * handleEvent: "phase" events time named sub-steps and "note" events keep
9
- * persistent info; both are printed (prefixed with `indent`) when the step
10
- * finishes.
11
- */
2
+ const HEARTBEAT_INTERVAL_MS = 10_000;
12
3
  export function createStatusLine({ stream = process.stderr, indent = "" } = {}) {
13
4
  const tty = stream.isTTY;
14
5
  let active = false;
@@ -18,7 +9,6 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
18
9
  let detail = "";
19
10
  let startedAt = 0;
20
11
  let lastPrintedDetail = "";
21
- let entries = [];
22
12
  let currentPhase = null;
23
13
  const render = () => {
24
14
  frame = (frame + 1) % SPINNER_FRAMES.length;
@@ -26,19 +16,45 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
26
16
  const line = `${SPINNER_FRAMES[frame]} ${stepName} (${seconds}s)${detail ? ` — ${detail}` : ""}`;
27
17
  stream.write(`\r\x1b[2K${line}`);
28
18
  };
19
+ const heartbeat = () => {
20
+ const seconds = Math.floor((Date.now() - startedAt) / 1000);
21
+ console.log(`… ${stepName}: still waiting (${seconds}s)${detail ? ` — ${detail}` : ""}`);
22
+ };
23
+ const printPersistent = (text) => {
24
+ if (tty) {
25
+ stream.write(`\r\x1b[2K${text}\n`);
26
+ }
27
+ else {
28
+ console.log(text);
29
+ }
30
+ };
29
31
  const closePhase = () => {
30
32
  if (currentPhase) {
31
- currentPhase.ms = Date.now() - currentPhase.startedAt;
33
+ if (currentPhase.closeText != null) {
34
+ printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.closeText}`);
35
+ }
36
+ else {
37
+ const ms = Date.now() - currentPhase.startedAt;
38
+ printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.name} \x1b[90m(${ms}ms)\x1b[0m`);
39
+ }
32
40
  currentPhase = null;
33
41
  }
34
42
  };
35
43
  return {
44
+ rename(name) {
45
+ if (!active) {
46
+ return;
47
+ }
48
+ stepName = name;
49
+ if (tty) {
50
+ render();
51
+ }
52
+ },
36
53
  start(name) {
37
54
  active = true;
38
55
  stepName = name;
39
56
  detail = "";
40
57
  lastPrintedDetail = "";
41
- entries = [];
42
58
  currentPhase = null;
43
59
  startedAt = Date.now();
44
60
  if (tty) {
@@ -46,6 +62,12 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
46
62
  // on its own row before this step starts drawing.
47
63
  timer = setInterval(render, 120);
48
64
  }
65
+ else {
66
+ timer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
67
+ if (typeof timer.unref === "function") {
68
+ timer.unref();
69
+ }
70
+ }
49
71
  },
50
72
  update(message) {
51
73
  if (!active) {
@@ -66,11 +88,30 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
66
88
  }
67
89
  if (event.kind === "phase") {
68
90
  closePhase();
69
- currentPhase = { kind: "phase", name: event.message, startedAt: Date.now(), ms: 0 };
70
- entries.push(currentPhase);
91
+ currentPhase = { name: event.message, startedAt: Date.now(), closeText: null };
71
92
  }
72
93
  else if (event.kind === "note") {
73
- entries.push({ kind: "note", text: event.message });
94
+ printPersistent(`${indent}\x1b[34mℹ\x1b[0m ${event.message}`);
95
+ return;
96
+ }
97
+ else if (event.kind === "phase-close-text") {
98
+ if (currentPhase) {
99
+ currentPhase.closeText = event.message;
100
+ }
101
+ return;
102
+ }
103
+ else if (event.kind === "phase-flush") {
104
+ closePhase();
105
+ return;
106
+ }
107
+ else if (event.kind === "print") {
108
+ // Closes any still-open phase first (flushing its "✔ ..." line)
109
+ // before printing the caller-supplied text verbatim, so a final
110
+ // summary can never race ahead of / glue onto the last phase's
111
+ // close line or the live spinner.
112
+ closePhase();
113
+ printPersistent(event.message);
114
+ return;
74
115
  }
75
116
  this.update(event.message);
76
117
  },
@@ -80,19 +121,10 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
80
121
  clearInterval(timer);
81
122
  timer = null;
82
123
  }
124
+ closePhase();
83
125
  if (tty) {
84
126
  stream.write("\r\x1b[2K");
85
127
  }
86
- closePhase();
87
- for (const entry of entries) {
88
- if (entry.kind === "phase") {
89
- stream.write(`${indent}\x1b[32m✔\x1b[0m ${entry.name} \x1b[90m(${entry.ms}ms)\x1b[0m\n`);
90
- }
91
- else {
92
- stream.write(`${indent}\x1b[34mℹ\x1b[0m ${entry.text}\n`);
93
- }
94
- }
95
- entries = [];
96
128
  }
97
129
  };
98
130
  }
@@ -129,6 +129,7 @@ async function loadConfigFromFile(configPath) {
129
129
  llmProviderMode: undefined,
130
130
  cuaModel: undefined,
131
131
  aimAssistMode: undefined,
132
+ explainMode: undefined,
132
133
  promptCustomizations: createEmptyPromptCustomizations(),
133
134
  appContextEnabled: undefined,
134
135
  appContextPath: undefined,
@@ -144,6 +145,12 @@ async function loadConfigFromFile(configPath) {
144
145
  if ("aimAssistMode" in rawConfig) {
145
146
  normalized.aimAssistMode = validateAimAssistMode(rawConfig.aimAssistMode, "config.aimAssistMode");
146
147
  }
148
+ if ("explainMode" in rawConfig) {
149
+ if (typeof rawConfig.explainMode !== "boolean") {
150
+ throw new Error("config.explainMode must be a boolean.");
151
+ }
152
+ normalized.explainMode = rawConfig.explainMode;
153
+ }
147
154
  if ("promptCustomizations" in rawConfig) {
148
155
  normalized.promptCustomizations = normalizePromptCustomizations(rawConfig.promptCustomizations, "config.promptCustomizations");
149
156
  }
@@ -217,6 +224,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
217
224
  llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
218
225
  cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
219
226
  aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
227
+ explainMode: fileConfig?.explainMode ?? false,
220
228
  promptCustomizations,
221
229
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
222
230
  appContextPath: fileConfig?.appContextPath || null,
@@ -235,6 +243,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
235
243
  if (typeof args["aim-assist"] === "string") {
236
244
  resolved.aimAssistMode = validateAimAssistMode(args["aim-assist"], "--aim-assist");
237
245
  }
246
+ if (args.explain === true) {
247
+ resolved.explainMode = true;
248
+ }
238
249
  if (hasContextOptimizationThresholdFlag) {
239
250
  resolved.contextOptimizationEnabled = true;
240
251
  resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");