@songsid/agend 2.1.4-beta.34 → 2.1.4-beta.36

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/dist/daemon.d.ts CHANGED
@@ -246,6 +246,26 @@ export declare function extractProxyReplyText(pane: string, opts?: {
246
246
  maxLines?: number;
247
247
  maxChars?: number;
248
248
  }): string | null;
249
+ export interface MalformedClaudeToolCallDetection {
250
+ /** Stable, non-secret fingerprint used to suppress a stale pane fragment. */
251
+ signature: string;
252
+ /** Sanitized reply argument, or null when only a dangling closing tag survived. */
253
+ text: string | null;
254
+ }
255
+ /**
256
+ * Find the narrow Claude XML failure signature reported in #648.
257
+ *
258
+ * This intentionally runs only at the daemon's busy→idle edge (the caller
259
+ * enforces that) and only scans output after the current inbound marker. A
260
+ * partial tool call while Claude is still streaming is therefore never acted
261
+ * on. The extracted argument goes through the same credential redaction as the
262
+ * dead-MCP pane proxy before it can leave the host.
263
+ */
264
+ export declare function detectMalformedClaudeToolCall(pane: string, opts?: {
265
+ inboundMarker?: string;
266
+ maxLines?: number;
267
+ maxChars?: number;
268
+ }): MalformedClaudeToolCallDetection | null;
249
269
  export type InteractivePromptKind = "sudo_password" | "password" | "confirmation" | "press_enter";
250
270
  export interface InteractivePromptDetection {
251
271
  kind: InteractivePromptKind;
@@ -372,8 +392,12 @@ export declare class Daemon extends EventEmitter {
372
392
  private statePollInFlight;
373
393
  private turnHadInbound;
374
394
  private turnOutboundDelivered;
395
+ /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
396
+ private turnReplyDelivered;
375
397
  private turnCorrelationId;
376
398
  private turnInboundMarker;
399
+ /** Prevent a visible stale XML fragment from being recovered on later turns. */
400
+ private lastMalformedToolCallSignature;
377
401
  private proxyReplySeq;
378
402
  private autoPauseController;
379
403
  private pauseRequested;
@@ -394,6 +418,17 @@ export declare class Daemon extends EventEmitter {
394
418
  private authFailureUnresolved;
395
419
  /** One login-screen auth report per spawn — the screen persists across polls. */
396
420
  private loginScreenReported;
421
+ /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
422
+ private fatalStartupReported;
423
+ /**
424
+ * The CLI is parked on a fatal startup screen and every pane write must be
425
+ * refused. This is deliberately separate from the pause that the reported
426
+ * pty_error triggers: the pause is applied by an async handler, and a queued
427
+ * delivery would race it into the modal (where paste+Enter can confirm
428
+ * "Exit and fix manually"). Set synchronously at dialog-match time; cleared
429
+ * when a startup scan reaches a real ready prompt, or on wake().
430
+ */
431
+ private fatalStartupBlocked;
397
432
  /** model_error seen mid-turn: notify only if it survives to the idle screen. */
398
433
  private pendingModelErrorKey;
399
434
  /**
@@ -626,8 +661,13 @@ export declare class Daemon extends EventEmitter {
626
661
  * here (edge-triggered, then reset) is what makes it at most once per turn.
627
662
  */
628
663
  private maybeProxyReplyOnTurnEnd;
664
+ private isClaudeCodeBackend;
629
665
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
630
666
  private sendProxyReply;
667
+ /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
668
+ private sendRecoveredMalformedReply;
669
+ /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
670
+ private deliverDaemonReply;
631
671
  private clearInstanceStateIdleTimer;
632
672
  private clearInstanceStateOutputProbeTimer;
633
673
  /**
@@ -761,6 +801,15 @@ export declare class Daemon extends EventEmitter {
761
801
  * serial in both cases so separate PTY writes can never overlap.
762
802
  */
763
803
  private deliverMessage;
804
+ /**
805
+ * While the CLI is parked on a fatal startup screen (see fatalStartupBlocked),
806
+ * no pane write may happen: paste+Enter into the corrupt-config modal would
807
+ * confirm "Exit and fix manually" and exit the CLI. The message is failed (❌)
808
+ * rather than held — the accompanying config_error notification tells the user
809
+ * what to fix, and holding would only build a queue that floods the pane the
810
+ * moment the flag clears.
811
+ */
812
+ private refuseFatalStartupDelivery;
764
813
  /**
765
814
  * A raw `%output` silence check is authoritative for ordinary TUIs. It is not
766
815
  * for a backend such as agy whose status-line hook periodically redraws an
package/dist/daemon.js CHANGED
@@ -546,6 +546,66 @@ export function extractProxyReplyText(pane, opts = {}) {
546
546
  // The end of the turn is the answer — when over budget, keep the tail.
547
547
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
548
548
  }
549
+ /**
550
+ * Find the narrow Claude XML failure signature reported in #648.
551
+ *
552
+ * This intentionally runs only at the daemon's busy→idle edge (the caller
553
+ * enforces that) and only scans output after the current inbound marker. A
554
+ * partial tool call while Claude is still streaming is therefore never acted
555
+ * on. The extracted argument goes through the same credential redaction as the
556
+ * dead-MCP pane proxy before it can leave the host.
557
+ */
558
+ export function detectMalformedClaudeToolCall(pane, opts = {}) {
559
+ let clean = pane
560
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
561
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
562
+ const marker = opts.inboundMarker?.trim();
563
+ if (marker) {
564
+ const markerAt = clean.lastIndexOf(marker);
565
+ if (markerAt >= 0)
566
+ clean = clean.slice(markerAt + marker.length);
567
+ }
568
+ const lines = clean.split(/\r?\n/).slice(-(opts.maxLines ?? 120));
569
+ const tail = lines.join("\n");
570
+ const closingTags = [...tail.matchAll(/<\/(?:parameter|invoke|function_calls)>/gi)];
571
+ if (closingTags.length === 0)
572
+ return null;
573
+ const parameterOpen = /<parameter\b[^>]*\bname\s*=\s*["']text["'][^>]*>/gi;
574
+ const parameterOpens = [...tail.matchAll(parameterOpen)];
575
+ const open = parameterOpens.at(-1);
576
+ if (!open || open.index === undefined) {
577
+ // Base stale suppression on the XML artifact itself, not unrelated pane
578
+ // redraws appended after it. The occurrence counts still distinguish a
579
+ // genuinely new identical malformed call while both remain visible.
580
+ const signature = createHash("sha256")
581
+ .update(`0:${closingTags.length}:${closingTags.map(tag => tag[0].toLowerCase()).join(":")}`)
582
+ .digest("hex");
583
+ return { signature, text: null };
584
+ }
585
+ const contentAt = open.index + open[0].length;
586
+ const closeAt = tail.slice(contentAt).search(/<\/parameter>/i);
587
+ if (closeAt < 0) {
588
+ const signature = createHash("sha256")
589
+ .update(`${parameterOpens.length}:${closingTags.length}:${open[0]}`)
590
+ .digest("hex");
591
+ return { signature, text: null };
592
+ }
593
+ const fragment = tail.slice(contentAt, contentAt + closeAt);
594
+ // Fingerprint only the malformed XML artifact; never put its contents in logs.
595
+ const signature = createHash("sha256")
596
+ .update(`${parameterOpens.length}:${closingTags.length}:${open[0]}${fragment}</parameter>`)
597
+ .digest("hex");
598
+ let text = sanitizePaneTail(fragment, opts.maxLines ?? 120)
599
+ .join("\n")
600
+ .replace(/\n{3,}/g, "\n\n")
601
+ .trim();
602
+ if (text.replace(/[^\p{L}\p{N}]/gu, "").length < 2)
603
+ return { signature, text: null };
604
+ const maxChars = opts.maxChars ?? 12_000;
605
+ if (text.length > maxChars)
606
+ text = text.slice(0, maxChars);
607
+ return { signature, text };
608
+ }
549
609
  const INTERACTIVE_PROMPT_PATTERNS = [
550
610
  { kind: "sudo_password", pattern: /^\s*\[sudo\]\s+password\s+for\s+[^:\n]+:\s*$/im },
551
611
  // macOS sudo and several package installers use this exact no-echo prompt.
@@ -761,8 +821,12 @@ export class Daemon extends EventEmitter {
761
821
  // between, the agent's answer exists only on screen — the daemon relays it.
762
822
  turnHadInbound = false;
763
823
  turnOutboundDelivered = false;
824
+ /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
825
+ turnReplyDelivered = false;
764
826
  turnCorrelationId;
765
827
  turnInboundMarker;
828
+ /** Prevent a visible stale XML fragment from being recovered on later turns. */
829
+ lastMalformedToolCallSignature;
766
830
  proxyReplySeq = 0;
767
831
  autoPauseController;
768
832
  pauseRequested = false;
@@ -783,6 +847,17 @@ export class Daemon extends EventEmitter {
783
847
  authFailureUnresolved = false;
784
848
  /** One login-screen auth report per spawn — the screen persists across polls. */
785
849
  loginScreenReported = false;
850
+ /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
851
+ fatalStartupReported = false;
852
+ /**
853
+ * The CLI is parked on a fatal startup screen and every pane write must be
854
+ * refused. This is deliberately separate from the pause that the reported
855
+ * pty_error triggers: the pause is applied by an async handler, and a queued
856
+ * delivery would race it into the modal (where paste+Enter can confirm
857
+ * "Exit and fix manually"). Set synchronously at dialog-match time; cleared
858
+ * when a startup scan reaches a real ready prompt, or on wake().
859
+ */
860
+ fatalStartupBlocked = false;
786
861
  /** model_error seen mid-turn: notify only if it survives to the idle screen. */
787
862
  pendingModelErrorKey = null;
788
863
  /**
@@ -2273,6 +2348,8 @@ export class Daemon extends EventEmitter {
2273
2348
  this.pausePending = false;
2274
2349
  this.authFailureUnresolved = false;
2275
2350
  this.loginScreenReported = false;
2351
+ this.fatalStartupReported = false;
2352
+ this.fatalStartupBlocked = false;
2276
2353
  if (this.pauseWakeState === "active")
2277
2354
  return;
2278
2355
  if (this.pauseWakeState === "pausing")
@@ -2405,6 +2482,7 @@ export class Daemon extends EventEmitter {
2405
2482
  return;
2406
2483
  this.turnHadInbound = true;
2407
2484
  this.turnOutboundDelivered = false;
2485
+ this.turnReplyDelivered = false;
2408
2486
  this.turnCorrelationId = meta.correlation_id || undefined;
2409
2487
  // The last non-empty line of what we pasted: everything on screen after it
2410
2488
  // is the agent's own output.
@@ -2420,13 +2498,38 @@ export class Daemon extends EventEmitter {
2420
2498
  maybeProxyReplyOnTurnEnd(pane) {
2421
2499
  const hadInbound = this.turnHadInbound;
2422
2500
  const delivered = this.turnOutboundDelivered;
2501
+ const replyDelivered = this.turnReplyDelivered;
2423
2502
  const correlationId = this.turnCorrelationId;
2424
2503
  const inboundMarker = this.turnInboundMarker;
2425
2504
  this.turnHadInbound = false;
2426
2505
  this.turnOutboundDelivered = false;
2506
+ this.turnReplyDelivered = false;
2427
2507
  this.turnCorrelationId = undefined;
2428
2508
  this.turnInboundMarker = undefined;
2429
- if (!hadInbound || delivered || this.isPaused)
2509
+ if (!hadInbound || this.isPaused)
2510
+ return;
2511
+ // Claude sometimes prints a broken XML tool call as plain text and returns
2512
+ // idle without ever invoking `reply`. This is independent of MCP liveness:
2513
+ // the model malformed the call before the server could receive it.
2514
+ if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2515
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2516
+ if (malformed) {
2517
+ const stale = malformed.signature === this.lastMalformedToolCallSignature;
2518
+ this.lastMalformedToolCallSignature = malformed.signature;
2519
+ if (!stale) {
2520
+ const recovered = malformed.text != null;
2521
+ this.logger.warn({ correlationId, recovered }, recovered
2522
+ ? "Malformed Claude tool call detected — attempting to recover reply text"
2523
+ : "Malformed Claude tool call detected — reply text could not be extracted");
2524
+ this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2525
+ if (malformed.text)
2526
+ this.sendRecoveredMalformedReply(malformed.text, correlationId);
2527
+ }
2528
+ // Never let the broader dead-MCP fallback relay the XML/chrome too.
2529
+ return;
2530
+ }
2531
+ }
2532
+ if (delivered)
2430
2533
  return;
2431
2534
  // Opt-in: raw pane text can carry secrets past the regex redaction.
2432
2535
  if (this.config.mcp_proxy_reply !== true)
@@ -2436,6 +2539,11 @@ export class Daemon extends EventEmitter {
2436
2539
  return;
2437
2540
  void this.sendProxyReply(pane, inboundMarker, correlationId);
2438
2541
  }
2542
+ isClaudeCodeBackend() {
2543
+ return this.runtimeIdentity?.backend === "claude-code"
2544
+ || this.config.backend === "claude-code"
2545
+ || this.backend?.binaryName === "claude";
2546
+ }
2439
2547
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2440
2548
  async sendProxyReply(pane, inboundMarker, correlationId) {
2441
2549
  try {
@@ -2453,50 +2561,68 @@ export class Daemon extends EventEmitter {
2453
2561
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2454
2562
  if (correlationId)
2455
2563
  body += `\n\n(correlation_id: ${correlationId})`;
2456
- const args = { text: body };
2457
- // Same context-bound routing as the reply tool in handleToolCall.
2458
- if (this.lastChatId) {
2459
- args.chat_id = this.lastChatId;
2460
- if (this.lastThreadId)
2461
- args.thread_id = this.lastThreadId;
2462
- }
2463
2564
  this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2464
2565
  this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2465
- const adapters = this.messageBus.getAllAdapters();
2466
- if (adapters.length > 0) {
2467
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2468
- if (error)
2469
- this.logger.error({ error }, "Dead-MCP proxy reply failed");
2470
- });
2471
- return;
2472
- }
2473
- if (!this.ipcServer)
2474
- return;
2475
- const fleetReqId = `proxyreply_${++this.proxyReplySeq}`;
2476
- this.ipcServer.broadcast({
2477
- type: "fleet_outbound",
2478
- tool: "reply",
2479
- args,
2480
- fleetRequestId: fleetReqId,
2481
- adapterId: this.lastAdapterId,
2482
- });
2483
- const timeout = setTimeout(() => {
2484
- this.pendingIpcRequests.delete(fleetReqId);
2485
- this.logger.error("Dead-MCP proxy reply timed out waiting for the fleet manager");
2486
- }, daemonBudgetMs("reply"));
2487
- timeout.unref?.();
2488
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2489
- clearTimeout(timeout);
2490
- if (respMsg.error)
2491
- this.logger.error({ error: respMsg.error }, "Dead-MCP proxy reply failed");
2492
- else
2493
- this.logger.info("Dead-MCP proxy reply delivered");
2494
- });
2566
+ this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply");
2495
2567
  }
2496
2568
  catch (err) {
2497
2569
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2498
2570
  }
2499
2571
  }
2572
+ /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2573
+ sendRecoveredMalformedReply(text, correlationId) {
2574
+ try {
2575
+ this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2576
+ }
2577
+ catch (err) {
2578
+ this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2579
+ }
2580
+ }
2581
+ /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2582
+ deliverDaemonReply(body, requestPrefix, logLabel) {
2583
+ const args = { text: body };
2584
+ if (this.lastChatId) {
2585
+ args.chat_id = this.lastChatId;
2586
+ if (this.lastThreadId)
2587
+ args.thread_id = this.lastThreadId;
2588
+ }
2589
+ const adapters = this.messageBus.getAllAdapters();
2590
+ if (adapters.length > 0) {
2591
+ routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2592
+ if (error)
2593
+ this.logger.error({ error }, `${logLabel} failed`);
2594
+ else
2595
+ this.logger.info(`${logLabel} delivered`);
2596
+ });
2597
+ return;
2598
+ }
2599
+ if (!this.ipcServer) {
2600
+ this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2601
+ return;
2602
+ }
2603
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2604
+ const timeout = setTimeout(() => {
2605
+ this.pendingIpcRequests.delete(fleetReqId);
2606
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2607
+ }, daemonBudgetMs("reply"));
2608
+ timeout.unref?.();
2609
+ // Register before broadcast: an in-process/fast adapter can answer in the
2610
+ // same tick, and a waiter installed afterwards would miss that response.
2611
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2612
+ clearTimeout(timeout);
2613
+ if (respMsg.error)
2614
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
2615
+ else
2616
+ this.logger.info(`${logLabel} delivered`);
2617
+ });
2618
+ this.ipcServer.broadcast({
2619
+ type: "fleet_outbound",
2620
+ tool: "reply",
2621
+ args,
2622
+ fleetRequestId: fleetReqId,
2623
+ adapterId: this.lastAdapterId,
2624
+ });
2625
+ }
2500
2626
  clearInstanceStateIdleTimer() {
2501
2627
  if (this.instanceStateIdleTimer)
2502
2628
  clearTimeout(this.instanceStateIdleTimer);
@@ -3219,6 +3345,8 @@ export class Daemon extends EventEmitter {
3219
3345
  await this.waitForSpawnToSettle();
3220
3346
  if (cancelled())
3221
3347
  return false;
3348
+ if (this.refuseFatalStartupDelivery(status))
3349
+ return false;
3222
3350
  let windowId = this.getWindowId();
3223
3351
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
3224
3352
  let handingOffToNativeQueue = false;
@@ -3260,9 +3388,29 @@ export class Daemon extends EventEmitter {
3260
3388
  return this.paneWriteLock.run(async () => {
3261
3389
  if (cancelled())
3262
3390
  return false;
3391
+ // Re-checked here because the flag can be set during the waits above
3392
+ // (a spawn's dialog scan runs concurrently with a queued delivery).
3393
+ if (this.refuseFatalStartupDelivery(status))
3394
+ return false;
3263
3395
  return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3264
3396
  });
3265
3397
  }
3398
+ /**
3399
+ * While the CLI is parked on a fatal startup screen (see fatalStartupBlocked),
3400
+ * no pane write may happen: paste+Enter into the corrupt-config modal would
3401
+ * confirm "Exit and fix manually" and exit the CLI. The message is failed (❌)
3402
+ * rather than held — the accompanying config_error notification tells the user
3403
+ * what to fix, and holding would only build a queue that floods the pane the
3404
+ * moment the flag clears.
3405
+ */
3406
+ refuseFatalStartupDelivery(status) {
3407
+ if (!this.fatalStartupBlocked)
3408
+ return false;
3409
+ this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
3410
+ if (status)
3411
+ this.emit("message_failed", status); // ❌
3412
+ return true;
3413
+ }
3266
3414
  /**
3267
3415
  * A raw `%output` silence check is authoritative for ordinary TUIs. It is not
3268
3416
  * for a backend such as agy whose status-line hook periodically redraws an
@@ -3614,6 +3762,8 @@ export class Daemon extends EventEmitter {
3614
3762
  // for this turn: the agent proved it can still speak for itself.
3615
3763
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
3616
3764
  this.turnOutboundDelivered = true;
3765
+ if (tool === "reply")
3766
+ this.turnReplyDelivered = true;
3617
3767
  }
3618
3768
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
3619
3769
  if (!sent) {
@@ -4243,7 +4393,10 @@ export class Daemon extends EventEmitter {
4243
4393
  if (!await this.tmux.isWindowAlive())
4244
4394
  return false;
4245
4395
  const ready = await this.dismissDialogsUntilReady(3);
4246
- if (ready)
4396
+ // A fatal startup screen returns true (the spawn is over — respawning
4397
+ // would only re-show the same screen) but is NOT ready-for-delivery:
4398
+ // fatalStartupBlocked gates every pane write in deliverMessage.
4399
+ if (ready && !this.fatalStartupBlocked)
4247
4400
  this.firstDeliveryDelay.recordReady();
4248
4401
  return ready;
4249
4402
  }
@@ -4266,6 +4419,26 @@ export class Daemon extends EventEmitter {
4266
4419
  let matched = false;
4267
4420
  for (const dialog of startupDialogs) {
4268
4421
  if (dialog.pattern.test(pane)) {
4422
+ // A fatal startup screen cannot be dismissed by keypresses (e.g. a
4423
+ // corrupt claude.json modal offering only "exit" / "reset config").
4424
+ // It must be caught HERE and not fall through to the ready check:
4425
+ // the modal contains the `❯` selector the ready pattern matches, so
4426
+ // it would otherwise pass as ready and queued messages would be
4427
+ // typed into the modal. Report the error (pause stops delivery) and
4428
+ // stop the retry loop — like the sign-in screen case below, only a
4429
+ // human fixing the config ends this state.
4430
+ if (dialog.fatal) {
4431
+ // Block deliveries BEFORE the emit: the pty_error pause handler
4432
+ // is async, and a queued message would otherwise win the race
4433
+ // and be pasted into the modal.
4434
+ this.fatalStartupBlocked = true;
4435
+ if (!this.fatalStartupReported) {
4436
+ this.fatalStartupReported = true;
4437
+ this.logger.warn({ description: dialog.description }, "Fatal startup screen detected — reporting instead of dismissing");
4438
+ this.emit("pty_error", { name: this.name, ...dialog.fatal });
4439
+ }
4440
+ return true;
4441
+ }
4269
4442
  this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
4270
4443
  // Restart is exactly when inbound messages pile up, and nothing gates
4271
4444
  // delivery on `spawning`. Take the pane lock for the key sequence so a
@@ -4298,8 +4471,13 @@ export class Daemon extends EventEmitter {
4298
4471
  if (matched)
4299
4472
  continue;
4300
4473
  // CLI is ready (pattern defined by each backend)
4301
- if (this.backend.getReadyPattern().test(pane))
4474
+ if (this.backend.getReadyPattern().test(pane)) {
4475
+ // A real ready prompt (with no fatal dialog on screen — those are
4476
+ // matched above, before this check) means the fatal screen is gone,
4477
+ // e.g. the user fixed the config and the instance respawned.
4478
+ this.fatalStartupBlocked = false;
4302
4479
  return true;
4480
+ }
4303
4481
  // A CLI parked at its own sign-in screen is an AUTH incident: no dialog
4304
4482
  // key can dismiss it and no ready pattern will ever appear. Report it as
4305
4483
  // an auth error (the lifecycle double-checks with the token-free probe)