@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/backend/antigravity.js +1 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +29 -2
- package/dist/backend/claude-code.js +217 -19
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/opencode.js +15 -2
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +17 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +49 -0
- package/dist/daemon.js +218 -40
- package/dist/daemon.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +16 -0
- package/dist/instance-lifecycle.js +102 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +8 -0
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +6 -0
- package/dist/login-flows.js +11 -0
- package/dist/login-flows.js.map +1 -1
- package/dist/topic-commands.d.ts +6 -0
- package/dist/topic-commands.js +27 -1
- package/dist/topic-commands.js.map +1 -1
- package/package.json +1 -1
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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)
|