@songsid/agend 2.1.8-beta.1 → 2.1.8-beta.11
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/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/codex-session-lookup.d.ts +16 -0
- package/dist/backend/codex-session-lookup.js +76 -2
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -4
- package/dist/backend/codex.js +44 -6
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +37 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +13 -2
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +130 -6
- package/dist/daemon.js +486 -69
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +165 -18
- package/dist/fleet-manager.js +821 -121
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +55 -0
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +34 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +23 -4
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +8 -8
- package/dist/settings-api.js +12 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +28 -1
- package/dist/status-emojis.js +11 -1
- package/dist/status-emojis.js.map +1 -1
- package/dist/target-queue-worker.d.ts +67 -0
- package/dist/target-queue-worker.js +76 -0
- package/dist/target-queue-worker.js.map +1 -0
- package/dist/tool-permissions.js +15 -4
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +64 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +63 -0
- package/dist/transcript-sources.js +171 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +25 -13
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
package/dist/daemon.js
CHANGED
|
@@ -454,6 +454,11 @@ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
|
|
|
454
454
|
* verified prompt before asking the native process to exit. Each instance is
|
|
455
455
|
* bounded so a fleet update can stop all daemons in parallel. */
|
|
456
456
|
const KIRO_STOP_IDLE_BUDGET_MS = 15_000;
|
|
457
|
+
/**
|
|
458
|
+
* Bound after a non-stop quit before a dead pane with no relaunch is a wedge
|
|
459
|
+
* (#1030). Exceeds the wake budget (30s+) so a slow relaunch never trips it.
|
|
460
|
+
*/
|
|
461
|
+
const QUIT_RELAUNCH_WATCH_MS = 90_000;
|
|
457
462
|
const KIRO_STOP_QUIT_GRACE_MS = 5_000;
|
|
458
463
|
const KIRO_STOP_SIGTERM_GRACE_MS = 2_000;
|
|
459
464
|
/**
|
|
@@ -906,6 +911,11 @@ export class BlockingProcessDetector {
|
|
|
906
911
|
this.notified = false;
|
|
907
912
|
}
|
|
908
913
|
}
|
|
914
|
+
/** Response types the fleet sends back for tool calls the daemon forwarded. */
|
|
915
|
+
const FLEET_RESPONSE_TYPES = new Set([
|
|
916
|
+
"fleet_schedule_response", "fleet_outbound_response", "fleet_decision_response", "fleet_task_response",
|
|
917
|
+
"fleet_display_name_response", "fleet_description_response", "fleet_persona_emoji_response",
|
|
918
|
+
]);
|
|
909
919
|
export class Daemon extends EventEmitter {
|
|
910
920
|
name;
|
|
911
921
|
config;
|
|
@@ -1060,6 +1070,16 @@ export class Daemon extends EventEmitter {
|
|
|
1060
1070
|
* would silently no-op exactly when we most need to stop feeding the CLI.
|
|
1061
1071
|
*/
|
|
1062
1072
|
pausePending = false;
|
|
1073
|
+
/**
|
|
1074
|
+
* #1044: pause on the idle edge only if the auth error is still on screen.
|
|
1075
|
+
* Set when nothing could vouch for the hit — the backend has no token-free
|
|
1076
|
+
* auth check (muse), or it could not answer. A real expired session prints
|
|
1077
|
+
* its failure at the end of the turn it broke, so it is still at the bottom
|
|
1078
|
+
* when that turn ends; a phrase the pattern caught mid-turn has scrolled on
|
|
1079
|
+
* by then. Recovery cannot decide this: an expired session is back at its
|
|
1080
|
+
* prompt after every failed turn, which is what "recovered" means.
|
|
1081
|
+
*/
|
|
1082
|
+
pauseReconfirmAuth = false;
|
|
1063
1083
|
/**
|
|
1064
1084
|
* An auth failure was detected and nothing has resolved it yet.
|
|
1065
1085
|
*
|
|
@@ -1070,6 +1090,15 @@ export class Daemon extends EventEmitter {
|
|
|
1070
1090
|
authFailureUnresolved = false;
|
|
1071
1091
|
/** One login-screen auth report per spawn — the screen persists across polls. */
|
|
1072
1092
|
loginScreenReported = false;
|
|
1093
|
+
/**
|
|
1094
|
+
* While the lifecycle is still starting this daemon, `pty_error` has no
|
|
1095
|
+
* listener and `InstanceLifecycle.daemons` has no entry — so an incident the
|
|
1096
|
+
* startup scan raises (sign-in screen, fatal modal) would be dropped on the
|
|
1097
|
+
* floor while its dedupe flag stays set forever. Held here, then re-emitted by
|
|
1098
|
+
* releaseStartupIncidents() once the handlers and the registry are in place.
|
|
1099
|
+
*/
|
|
1100
|
+
holdingStartupIncidents = false;
|
|
1101
|
+
heldStartupIncidents = [];
|
|
1073
1102
|
/** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
|
|
1074
1103
|
fatalStartupReported = false;
|
|
1075
1104
|
/**
|
|
@@ -1140,6 +1169,8 @@ export class Daemon extends EventEmitter {
|
|
|
1140
1169
|
lastProgressBroadcastAt = 0;
|
|
1141
1170
|
// PTY error pattern monitoring
|
|
1142
1171
|
errorMonitorTimer = null;
|
|
1172
|
+
quitWatchTimer = null;
|
|
1173
|
+
quitWatchToken = null;
|
|
1143
1174
|
interactivePromptDetector = new InteractivePromptDetector();
|
|
1144
1175
|
blockingProcessDetector = new BlockingProcessDetector();
|
|
1145
1176
|
/** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
|
|
@@ -1285,6 +1316,24 @@ export class Daemon extends EventEmitter {
|
|
|
1285
1316
|
this.lastErrorCount.set(key, count);
|
|
1286
1317
|
this.seededErrorCountBaselines.set(key, count);
|
|
1287
1318
|
}
|
|
1319
|
+
/** Start buffering `pty_error` until releaseStartupIncidents(); see holdingStartupIncidents. */
|
|
1320
|
+
holdStartupIncidents() {
|
|
1321
|
+
this.holdingStartupIncidents = true;
|
|
1322
|
+
}
|
|
1323
|
+
/** Stop buffering and deliver, in order, every `pty_error` raised while the lifecycle was starting us. */
|
|
1324
|
+
releaseStartupIncidents() {
|
|
1325
|
+
this.holdingStartupIncidents = false;
|
|
1326
|
+
const held = this.heldStartupIncidents.splice(0);
|
|
1327
|
+
for (const args of held)
|
|
1328
|
+
super.emit("pty_error", ...args);
|
|
1329
|
+
}
|
|
1330
|
+
emit(event, ...args) {
|
|
1331
|
+
if (event === "pty_error" && this.holdingStartupIncidents) {
|
|
1332
|
+
this.heldStartupIncidents.push(args);
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
return super.emit(event, ...args);
|
|
1336
|
+
}
|
|
1288
1337
|
/**
|
|
1289
1338
|
* Undo the auth suspicion a pattern match armed, once the backend's token-free
|
|
1290
1339
|
* probe has said the credentials are fine.
|
|
@@ -1441,6 +1490,11 @@ export class Daemon extends EventEmitter {
|
|
|
1441
1490
|
finishDurableDelivery(delivery, outcome, evidence) {
|
|
1442
1491
|
if (!delivery)
|
|
1443
1492
|
return;
|
|
1493
|
+
// Delivered or uncertain means the pane write started: the work reached
|
|
1494
|
+
// the CLI (or may have), so it is activity (Phase 2a). Only a failure is
|
|
1495
|
+
// not — a pre-write failure never gets here; it is retried before begin.
|
|
1496
|
+
if (outcome !== "failed")
|
|
1497
|
+
this.noteWorkActivity();
|
|
1444
1498
|
this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
|
|
1445
1499
|
}
|
|
1446
1500
|
finishDurableSubmission(delivery, verdict) {
|
|
@@ -1459,6 +1513,56 @@ export class Daemon extends EventEmitter {
|
|
|
1459
1513
|
return;
|
|
1460
1514
|
this.retryDurableDeliveryBeforeBegin(delivery, reason);
|
|
1461
1515
|
}
|
|
1516
|
+
/** Restart the idle clock for work that reached the CLI, and persist it like a user inbound. */
|
|
1517
|
+
noteWorkActivity(now = Date.now()) {
|
|
1518
|
+
this.autoPauseController.recordActivity(now);
|
|
1519
|
+
try {
|
|
1520
|
+
writeLastInboundAt(this.instanceDir, now);
|
|
1521
|
+
}
|
|
1522
|
+
catch (err) {
|
|
1523
|
+
this.logger.warn({ err: err.message }, "Failed to persist last activity timestamp");
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* A daemon started to wake, restart or explicitly start an instance begins
|
|
1528
|
+
* its idle window now (Phase 2a). Only the fleet's own boot keeps the
|
|
1529
|
+
* persisted last-inbound seed, so a long-idle fleet still pauses after a
|
|
1530
|
+
* restart; a woken instance used to inherit a week-old timestamp and pause
|
|
1531
|
+
* one second after it became ready.
|
|
1532
|
+
*/
|
|
1533
|
+
seedActivityNow(now = Date.now()) {
|
|
1534
|
+
this.autoPauseController.recordActivity(now);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Phase 2b (design §1.4): why this awake instance is not taking input, in
|
|
1538
|
+
* words for a notice, or null when nothing known holds it. Read from the
|
|
1539
|
+
* daemon's own state only; it never inspects the pane itself.
|
|
1540
|
+
*/
|
|
1541
|
+
notAcceptingReason() {
|
|
1542
|
+
if (this.pauseWakeState !== "active")
|
|
1543
|
+
return `it is ${this.pauseWakeState}`;
|
|
1544
|
+
if (this.inputBlockedDialogKey !== null)
|
|
1545
|
+
return `a dialog is holding its input (${this.inputBlockedDialogKey})`;
|
|
1546
|
+
if (this.authFailureUnresolved)
|
|
1547
|
+
return "its login is failing";
|
|
1548
|
+
if (this.instanceState === "stuck")
|
|
1549
|
+
return "the CLI looks stuck";
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
workLeaseCheck = null;
|
|
1553
|
+
/** Lifecycle-provided: whether the fleet holds a work lease on this instance. */
|
|
1554
|
+
setWorkLeaseCheck(check) {
|
|
1555
|
+
this.workLeaseCheck = check ?? null;
|
|
1556
|
+
}
|
|
1557
|
+
workLeaseActive() {
|
|
1558
|
+
try {
|
|
1559
|
+
return this.workLeaseCheck?.() === true;
|
|
1560
|
+
}
|
|
1561
|
+
catch (err) {
|
|
1562
|
+
this.logger.debug({ err }, "Work lease check failed; treating as no lease");
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1462
1566
|
async start() {
|
|
1463
1567
|
mkdirSync(this.instanceDir, { recursive: true });
|
|
1464
1568
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
@@ -1506,21 +1610,7 @@ export class Daemon extends EventEmitter {
|
|
|
1506
1610
|
await this.ipcServer.listen();
|
|
1507
1611
|
ipcListening = true;
|
|
1508
1612
|
// Permanent IPC dispatcher: routes responses to pending requests by type+id key
|
|
1509
|
-
this.ipcServer.on("message", (msg) =>
|
|
1510
|
-
const type = msg.type;
|
|
1511
|
-
if (!type)
|
|
1512
|
-
return;
|
|
1513
|
-
// Build lookup key matching the pattern used when registering
|
|
1514
|
-
let key;
|
|
1515
|
-
if ((type === "fleet_schedule_response" || type === "fleet_outbound_response" || type === "fleet_decision_response" || type === "fleet_task_response" || type === "fleet_display_name_response" || type === "fleet_description_response") && msg.fleetRequestId) {
|
|
1516
|
-
key = String(msg.fleetRequestId);
|
|
1517
|
-
}
|
|
1518
|
-
if (key && this.pendingIpcRequests.has(key)) {
|
|
1519
|
-
const handler = this.pendingIpcRequests.get(key);
|
|
1520
|
-
this.pendingIpcRequests.delete(key);
|
|
1521
|
-
handler(msg);
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1613
|
+
this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
|
|
1524
1614
|
// IPC message relay: when daemon wants to push a channel message to Claude,
|
|
1525
1615
|
// it broadcasts to all IPC clients (the MCP server is one of them).
|
|
1526
1616
|
// When MCP server sends a tool_call, daemon handles it via the messageBus.
|
|
@@ -2477,6 +2567,13 @@ export class Daemon extends EventEmitter {
|
|
|
2477
2567
|
return;
|
|
2478
2568
|
const pane = await this.tmux.capturePane();
|
|
2479
2569
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2570
|
+
// A sign-in screen that appears AFTER the startup scan ended (first run:
|
|
2571
|
+
// theme → login can take longer than its budget) is the same auth
|
|
2572
|
+
// incident the scan reports. Structural predicate only — the loose
|
|
2573
|
+
// patterns are for a fresh startup pane, not a long-lived transcript.
|
|
2574
|
+
const loginFlow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
2575
|
+
if (loginFlow?.loginScreenActive && this.reportLoginScreen(pane))
|
|
2576
|
+
return;
|
|
2480
2577
|
const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
|
|
2481
2578
|
if (interactivePrompt) {
|
|
2482
2579
|
this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
|
|
@@ -3004,33 +3101,128 @@ export class Daemon extends EventEmitter {
|
|
|
3004
3101
|
this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
|
|
3005
3102
|
}
|
|
3006
3103
|
}
|
|
3007
|
-
/**
|
|
3008
|
-
|
|
3104
|
+
/**
|
|
3105
|
+
* Send the backend-specific graceful quit command/key sequence.
|
|
3106
|
+
*
|
|
3107
|
+
* Observability (#1030): every send is logged with reason + caller, the
|
|
3108
|
+
* outcome is logged, and a non-stop quit arms a bounded relaunch watch —
|
|
3109
|
+
* sol's silent wedge (quit landed, codex exited to its resume screen, no
|
|
3110
|
+
* relaunch, daemon still running) was invisible precisely because none of
|
|
3111
|
+
* this existed.
|
|
3112
|
+
*/
|
|
3113
|
+
async sendQuitSequence(reason = "unspecified", caller = "unknown", opts) {
|
|
3009
3114
|
if (!this.tmux || !this.backend)
|
|
3010
3115
|
return false;
|
|
3116
|
+
const backend = this.backend.binaryName;
|
|
3011
3117
|
const quitCmd = this.backend.getQuitCommand();
|
|
3012
3118
|
const quitKey = this.backend.getQuitKey?.();
|
|
3119
|
+
const quitVia = quitCmd ? `quit command ${quitCmd}` : quitKey ? `quit key ${quitKey}` : "no quit sequence";
|
|
3120
|
+
this.logger.info({ backend, quit: quitCmd ?? quitKey ?? null, reason, caller }, `Sending backend quit sequence (${reason} via ${caller})`);
|
|
3013
3121
|
if (quitCmd || quitKey)
|
|
3014
|
-
this.noteStopRequest(
|
|
3122
|
+
this.noteStopRequest(quitVia, reason);
|
|
3123
|
+
let sent = false;
|
|
3124
|
+
let step = "sendKeys";
|
|
3015
3125
|
if (quitCmd) {
|
|
3016
|
-
if (
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3126
|
+
if (await this.tmux.sendKeys(quitCmd)) {
|
|
3127
|
+
// Delay before Enter to prevent tmux server races when instances stop in
|
|
3128
|
+
// parallel (same pattern as pasteText).
|
|
3129
|
+
await new Promise(r => setTimeout(r, 150));
|
|
3130
|
+
step = "enter";
|
|
3131
|
+
sent = await this.tmux.sendSpecialKey("Enter");
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
else if (quitKey) {
|
|
3135
|
+
step = "quit key";
|
|
3136
|
+
sent = true;
|
|
3137
|
+
const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
|
|
3138
|
+
for (let i = 0; i < presses; i++) {
|
|
3139
|
+
if (!await this.tmux.sendSpecialKey(quitKey)) {
|
|
3140
|
+
sent = false;
|
|
3141
|
+
break;
|
|
3142
|
+
}
|
|
3143
|
+
if (i + 1 < presses)
|
|
3144
|
+
await new Promise(r => setTimeout(r, 250));
|
|
3145
|
+
}
|
|
3022
3146
|
}
|
|
3023
|
-
|
|
3147
|
+
else {
|
|
3148
|
+
this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
|
|
3024
3149
|
return false;
|
|
3025
|
-
const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
|
|
3026
|
-
for (let i = 0; i < presses; i++) {
|
|
3027
|
-
const sent = await this.tmux.sendSpecialKey(quitKey);
|
|
3028
|
-
if (!sent)
|
|
3029
|
-
return false;
|
|
3030
|
-
if (i + 1 < presses)
|
|
3031
|
-
await new Promise(r => setTimeout(r, 250));
|
|
3032
3150
|
}
|
|
3033
|
-
|
|
3151
|
+
if (sent) {
|
|
3152
|
+
this.logger.info({ backend, quit: quitCmd ?? quitKey, reason, caller }, "Backend quit sequence sent");
|
|
3153
|
+
if (opts?.watchRelaunch)
|
|
3154
|
+
this.armQuitRelaunchWatch(reason, caller, opts.watchMs);
|
|
3155
|
+
}
|
|
3156
|
+
else {
|
|
3157
|
+
this.logger.warn({ backend, quit: quitCmd ?? quitKey, reason, caller, step }, "Backend quit sequence failed to send");
|
|
3158
|
+
}
|
|
3159
|
+
return sent;
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Codex names its own resume command on its exit screen — the wedge
|
|
3163
|
+
* signature. Backend-generic shell, codex-specific match (#1030 ask 4):
|
|
3164
|
+
* other backends' panes simply never match.
|
|
3165
|
+
*/
|
|
3166
|
+
isCodexResumeScreen(pane) {
|
|
3167
|
+
return /codex\s+resume\b/i.test(pane);
|
|
3168
|
+
}
|
|
3169
|
+
clearQuitRelaunchWatch() {
|
|
3170
|
+
if (this.quitWatchTimer) {
|
|
3171
|
+
clearTimeout(this.quitWatchTimer);
|
|
3172
|
+
this.quitWatchTimer = null;
|
|
3173
|
+
}
|
|
3174
|
+
// Invalidates any already-started checkQuitRelaunch whose pane reads are
|
|
3175
|
+
// still pending: it must bail on return, not warn from stale results.
|
|
3176
|
+
this.quitWatchToken = null;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* After a non-stop quit, warn if the backend exited but nothing relaunched
|
|
3180
|
+
* within the bound while this daemon still considers itself active.
|
|
3181
|
+
* Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
|
|
3182
|
+
* generation) already happened — only the wedged state warns.
|
|
3183
|
+
*/
|
|
3184
|
+
armQuitRelaunchWatch(reason, caller, delayMs = QUIT_RELAUNCH_WATCH_MS) {
|
|
3185
|
+
this.clearQuitRelaunchWatch();
|
|
3186
|
+
const generation = this.spawnGeneration;
|
|
3187
|
+
const quitAt = Date.now();
|
|
3188
|
+
const token = {};
|
|
3189
|
+
this.quitWatchToken = token;
|
|
3190
|
+
this.quitWatchTimer = setTimeout(() => {
|
|
3191
|
+
this.quitWatchTimer = null;
|
|
3192
|
+
void this.checkQuitRelaunch({ reason, caller, generation, quitAt, token }).catch(() => { });
|
|
3193
|
+
}, delayMs);
|
|
3194
|
+
}
|
|
3195
|
+
async checkQuitRelaunch(opts) {
|
|
3196
|
+
if (this.pauseWakeState !== "active")
|
|
3197
|
+
return;
|
|
3198
|
+
if (opts.generation !== this.spawnGeneration)
|
|
3199
|
+
return;
|
|
3200
|
+
if (!this.tmux)
|
|
3201
|
+
return;
|
|
3202
|
+
// TOCTOU guard: the token is captured at arm time, so a beginSpawn/stop
|
|
3203
|
+
// that lands while the pane reads below are pending invalidates this
|
|
3204
|
+
// check. Re-validate after every await before using the results.
|
|
3205
|
+
const token = opts.token ?? this.quitWatchToken;
|
|
3206
|
+
let pane;
|
|
3207
|
+
let alive = true;
|
|
3208
|
+
try {
|
|
3209
|
+
const status = await this.tmux.getPaneStatus();
|
|
3210
|
+
alive = !!status?.alive;
|
|
3211
|
+
pane = await this.tmux.capturePane().catch(() => undefined);
|
|
3212
|
+
}
|
|
3213
|
+
catch {
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
if (this.pauseWakeState !== "active")
|
|
3217
|
+
return;
|
|
3218
|
+
if (opts.generation !== this.spawnGeneration)
|
|
3219
|
+
return;
|
|
3220
|
+
if (token !== this.quitWatchToken)
|
|
3221
|
+
return;
|
|
3222
|
+
const resumeScreen = !!pane && this.isCodexResumeScreen(pane);
|
|
3223
|
+
if (alive && !resumeScreen)
|
|
3224
|
+
return;
|
|
3225
|
+
this.logger.warn({ backend: this.backend?.binaryName, reason: opts.reason, caller: opts.caller, quitAt: opts.quitAt, paneAlive: alive, resumeScreen }, "codex-exited-without-relaunch: backend quit but no relaunch followed while this daemon is still active");
|
|
3034
3226
|
}
|
|
3035
3227
|
/**
|
|
3036
3228
|
* Kiro drops `/quit` while a turn is running. Stop that turn first, then use
|
|
@@ -3054,6 +3246,7 @@ export class Daemon extends EventEmitter {
|
|
|
3054
3246
|
}
|
|
3055
3247
|
async stop() {
|
|
3056
3248
|
this.logger.info("Stopping daemon instance");
|
|
3249
|
+
this.clearQuitRelaunchWatch();
|
|
3057
3250
|
this.fenceDeliveryWritesForStop();
|
|
3058
3251
|
this.turnReplyGuard.reset();
|
|
3059
3252
|
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
@@ -3080,7 +3273,7 @@ export class Daemon extends EventEmitter {
|
|
|
3080
3273
|
let killed = false;
|
|
3081
3274
|
const windowId = this.tmux.getWindowId();
|
|
3082
3275
|
const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
|
|
3083
|
-
const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
|
|
3276
|
+
const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
|
|
3084
3277
|
const quitGraceMs = this.backend?.binaryName === "kiro-cli"
|
|
3085
3278
|
? KIRO_STOP_QUIT_GRACE_MS : 3_000;
|
|
3086
3279
|
const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
|
|
@@ -3166,13 +3359,16 @@ export class Daemon extends EventEmitter {
|
|
|
3166
3359
|
/**
|
|
3167
3360
|
* Answer a FleetManager state query. Most callers only need the cached state,
|
|
3168
3361
|
* but lifecycle decisions such as post-reply Cancel retirement need an
|
|
3169
|
-
* authoritative pane observation
|
|
3170
|
-
* "working" even though the CLI has since
|
|
3171
|
-
* control-mode output record.
|
|
3362
|
+
* authoritative pane observation, as do delivery idle gates: after startup
|
|
3363
|
+
* the last transition can stay "working" even though the CLI has since
|
|
3364
|
+
* settled without emitting another control-mode output record.
|
|
3172
3365
|
*/
|
|
3173
3366
|
async respondToInstanceStateQuery(msg, socket) {
|
|
3174
3367
|
try {
|
|
3175
|
-
if (msg.
|
|
3368
|
+
if (msg.deliveryIdle === true) {
|
|
3369
|
+
await this.captureAndEvaluateInstanceState("delivery_idle_gate");
|
|
3370
|
+
}
|
|
3371
|
+
else if (msg.refresh === true) {
|
|
3176
3372
|
await this.captureAndEvaluateInstanceState("state_query");
|
|
3177
3373
|
}
|
|
3178
3374
|
const snapshot = this.getInstanceStateSnapshot();
|
|
@@ -3196,12 +3392,35 @@ export class Daemon extends EventEmitter {
|
|
|
3196
3392
|
* failures: pause() alone no-ops while the CLI is busy/stuck, which is the
|
|
3197
3393
|
* usual state when the error surfaces. Cleared by a successful pause or wake.
|
|
3198
3394
|
*/
|
|
3199
|
-
requestPauseWhenIdle() {
|
|
3395
|
+
requestPauseWhenIdle(opts = {}) {
|
|
3200
3396
|
if (this.pauseWakeState === "paused")
|
|
3201
3397
|
return;
|
|
3398
|
+
const reconfirm = opts.reconfirmAuth === true;
|
|
3399
|
+
// An auth request wins over a pending non-auth one: the marker must not
|
|
3400
|
+
// later read as auto-wakeable when a login failure is among the causes.
|
|
3401
|
+
const reason = opts.reason ?? "auth";
|
|
3402
|
+
this.pendingPauseReason = this.pausePending && this.pendingPauseReason === "auth" ? "auth" : reason;
|
|
3403
|
+
// A pause already pending unconditionally stays unconditional.
|
|
3404
|
+
this.pauseReconfirmAuth = this.pausePending ? this.pauseReconfirmAuth && reconfirm : reconfirm;
|
|
3202
3405
|
this.pausePending = true;
|
|
3203
|
-
|
|
3204
|
-
|
|
3406
|
+
// A conditional request is the only pause path it has (#1044): decide on
|
|
3407
|
+
// the current pane now instead of at the next state sweep, so a pane that
|
|
3408
|
+
// is already idle is not left open to a delivery in the meantime.
|
|
3409
|
+
if (reconfirm)
|
|
3410
|
+
void this.captureAndEvaluateInstanceState("deferred_pause");
|
|
3411
|
+
}
|
|
3412
|
+
/** The backend's own auth-error pattern, within the pane's last rows. */
|
|
3413
|
+
authErrorAtPaneBottom(pane) {
|
|
3414
|
+
const rows = pane.replace(/\r/g, "").split("\n").filter(r => r.trim() !== "");
|
|
3415
|
+
const bottom = rows.slice(-Daemon.AUTH_RECONFIRM_ROWS).join("\n");
|
|
3416
|
+
return (this.backend?.getErrorPatterns?.() ?? [])
|
|
3417
|
+
.filter(ep => ep.type === "auth_error")
|
|
3418
|
+
.some(ep => new RegExp(ep.pattern.source, ep.pattern.flags.replace("g", "")).test(bottom));
|
|
3419
|
+
}
|
|
3420
|
+
static AUTH_RECONFIRM_ROWS = 15;
|
|
3421
|
+
/** Why the deferred pause was requested; what its marker records (Phase 2a). */
|
|
3422
|
+
pendingPauseReason = "auth";
|
|
3423
|
+
async pause(reason = "operator") {
|
|
3205
3424
|
if (this.pauseWakeState === "paused")
|
|
3206
3425
|
return;
|
|
3207
3426
|
if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
|
|
@@ -3229,7 +3448,7 @@ export class Daemon extends EventEmitter {
|
|
|
3229
3448
|
const transition = (async () => {
|
|
3230
3449
|
try {
|
|
3231
3450
|
this.saveSessionId();
|
|
3232
|
-
await this.sendQuitSequence(pauseReason);
|
|
3451
|
+
await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
|
|
3233
3452
|
let exited = false;
|
|
3234
3453
|
for (let i = 0; i < 15; i++) {
|
|
3235
3454
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -3254,7 +3473,7 @@ export class Daemon extends EventEmitter {
|
|
|
3254
3473
|
}
|
|
3255
3474
|
this.pauseWakeState = "paused";
|
|
3256
3475
|
this.autoPauseController.markPaused();
|
|
3257
|
-
writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
|
|
3476
|
+
writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
|
|
3258
3477
|
this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
|
|
3259
3478
|
this.ipcServer?.broadcast({
|
|
3260
3479
|
type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
|
|
@@ -3283,6 +3502,7 @@ export class Daemon extends EventEmitter {
|
|
|
3283
3502
|
// An explicit wake (e.g. after the user re-logs in) cancels a deferred
|
|
3284
3503
|
// auth pause — otherwise the instance would pause again the moment it idles.
|
|
3285
3504
|
this.pausePending = false;
|
|
3505
|
+
this.pauseReconfirmAuth = false;
|
|
3286
3506
|
this.authFailureUnresolved = false;
|
|
3287
3507
|
this.loginScreenReported = false;
|
|
3288
3508
|
this.fatalStartupReported = false;
|
|
@@ -3410,8 +3630,20 @@ export class Daemon extends EventEmitter {
|
|
|
3410
3630
|
// live, kept its warm slot, and re-raised a hang alert on every scan.
|
|
3411
3631
|
if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
|
|
3412
3632
|
this.pausePending = false;
|
|
3633
|
+
const reconfirm = this.pauseReconfirmAuth;
|
|
3634
|
+
this.pauseReconfirmAuth = false;
|
|
3635
|
+
// An unreadable pane keeps the pause: when in doubt, stop feeding a CLI
|
|
3636
|
+
// that may be unable to answer (42512a46).
|
|
3637
|
+
if (reconfirm && pane !== undefined && !this.authErrorAtPaneBottom(pane)) {
|
|
3638
|
+
this.authFailureUnresolved = false;
|
|
3639
|
+
this.logger.warn({ state: snapshot.state }, "Deferred auth pause dropped: the auth-error text is no longer at the bottom of the pane at the end of the turn, "
|
|
3640
|
+
+ "and this backend has no auth check to confirm it (likely conversation text)");
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3413
3643
|
this.pauseAllowStuck = snapshot.state === "stuck";
|
|
3414
|
-
|
|
3644
|
+
const reason = this.pendingPauseReason;
|
|
3645
|
+
this.pendingPauseReason = "auth";
|
|
3646
|
+
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason });
|
|
3415
3647
|
return;
|
|
3416
3648
|
}
|
|
3417
3649
|
// A restart deferred by a dead MCP server: the turn it must not interrupt is
|
|
@@ -3420,9 +3652,14 @@ export class Daemon extends EventEmitter {
|
|
|
3420
3652
|
this.fireMcpRestartRequest("idle_edge");
|
|
3421
3653
|
return;
|
|
3422
3654
|
}
|
|
3423
|
-
|
|
3655
|
+
// A work lease (Phase 2a) holds only this idle-timeout pause: a target the
|
|
3656
|
+
// fleet just woke for queued work, or one with a submission in flight, must
|
|
3657
|
+
// not be put back to sleep before that work reaches it. The auth-deferred
|
|
3658
|
+
// pause above and operator pauses never consult it.
|
|
3659
|
+
if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)
|
|
3660
|
+
&& !this.workLeaseActive()) {
|
|
3424
3661
|
this.pauseRequested = true;
|
|
3425
|
-
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
|
|
3662
|
+
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason: "idle" });
|
|
3426
3663
|
}
|
|
3427
3664
|
}
|
|
3428
3665
|
/**
|
|
@@ -3841,14 +4078,39 @@ export class Daemon extends EventEmitter {
|
|
|
3841
4078
|
}
|
|
3842
4079
|
this.statePollInFlight = true;
|
|
3843
4080
|
const captureStartedAt = Date.now();
|
|
4081
|
+
const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
4082
|
+
const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
4083
|
+
&& !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
|
|
3844
4084
|
try {
|
|
3845
4085
|
const pane = await this.tmux.capturePane();
|
|
4086
|
+
// An old capture must not retire a new launch's transient guard either.
|
|
4087
|
+
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4088
|
+
this.resetFooterFallback();
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
// Delivery's unknown-footer proof also awaits the TTY mode. Validate
|
|
4092
|
+
// output and launch freshness AFTER both awaits, before accepting it.
|
|
4093
|
+
const deliveryCandidate = reason === "delivery_idle_gate"
|
|
4094
|
+
? await this.probeDeliveryIdleFallback(pane) : null;
|
|
4095
|
+
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4096
|
+
this.resetFooterFallback();
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
3846
4099
|
this.updateInputBlockedState(pane);
|
|
4100
|
+
// A monitor/lifecycle capture can see a busy moment between fleet polls.
|
|
4101
|
+
// Even a stale capture of changed content disproves continuous stability;
|
|
4102
|
+
// invalidate it before the output-freshness check can return early.
|
|
4103
|
+
if (currentDeliveryCapture() && this.footerFallbackPaneKey !== null
|
|
4104
|
+
&& pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "") !== this.footerFallbackPaneKey) {
|
|
4105
|
+
this.resetFooterFallback();
|
|
4106
|
+
}
|
|
3847
4107
|
// Output received while capture-pane was in flight makes this snapshot
|
|
3848
4108
|
// stale. Its output handler has already armed a new debounce.
|
|
3849
4109
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
3850
4110
|
|| (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
|
|
3851
4111
|
if (outputMovedDuringCapture) {
|
|
4112
|
+
if (reason === "delivery_idle_gate")
|
|
4113
|
+
this.resetFooterFallback();
|
|
3852
4114
|
if (reason === "output_probe") {
|
|
3853
4115
|
// A second burst overtook the probe. Do not bless its stale capture as
|
|
3854
4116
|
// idle: continuous output is the safer working signal, while the next
|
|
@@ -3859,6 +4121,9 @@ export class Daemon extends EventEmitter {
|
|
|
3859
4121
|
}
|
|
3860
4122
|
return;
|
|
3861
4123
|
}
|
|
4124
|
+
// Advance the shared 10s timer only for a fresh capture of this launch.
|
|
4125
|
+
const deliveryFallback = deliveryCandidate === true
|
|
4126
|
+
? this.deliveryInputReadyPane(pane) : deliveryCandidate;
|
|
3862
4127
|
const observedChangeAt = expectedOutputAt || captureStartedAt;
|
|
3863
4128
|
// One observation per capture. This used to call observe() twice with the
|
|
3864
4129
|
// same pane: the second call always saw an unchanged pane, so the
|
|
@@ -3874,7 +4139,17 @@ export class Daemon extends EventEmitter {
|
|
|
3874
4139
|
// observe twice, which silently disabled the "content moved" branch.
|
|
3875
4140
|
const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
|
|
3876
4141
|
let snapshot;
|
|
3877
|
-
if (
|
|
4142
|
+
if (deliveryFallback !== null) {
|
|
4143
|
+
// A missing footer cannot fall through to a broad ready match. All
|
|
4144
|
+
// of the same #1032 conditions must hold continuously for 10 seconds.
|
|
4145
|
+
snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
|
|
4146
|
+
settled: true,
|
|
4147
|
+
changeAt: observedChangeAt,
|
|
4148
|
+
forceReady: deliveryFallback,
|
|
4149
|
+
forceBusy: !deliveryFallback || paneActivity !== null,
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
else if (structuredPeriodicIdle && reason === "output_probe") {
|
|
3878
4153
|
const idlePane = structuredPeriodicIdle.call(this.backend, pane);
|
|
3879
4154
|
if (idlePane) {
|
|
3880
4155
|
if (this.instanceState === "idle") {
|
|
@@ -4015,6 +4290,8 @@ export class Daemon extends EventEmitter {
|
|
|
4015
4290
|
}
|
|
4016
4291
|
}
|
|
4017
4292
|
catch (err) {
|
|
4293
|
+
if (reason === "delivery_idle_gate")
|
|
4294
|
+
this.resetFooterFallback();
|
|
4018
4295
|
this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
|
|
4019
4296
|
}
|
|
4020
4297
|
finally {
|
|
@@ -5045,10 +5322,13 @@ export class Daemon extends EventEmitter {
|
|
|
5045
5322
|
// The pane text comes back with the verdict: a transient that is still
|
|
5046
5323
|
// repainting is still working, and that is the only thing separating a
|
|
5047
5324
|
// slow resume from a wedged one.
|
|
5325
|
+
if (transient)
|
|
5326
|
+
this.resetFooterFallback();
|
|
5048
5327
|
return transient ? { state: "active", transient, pane } : { state: "clear" };
|
|
5049
5328
|
}
|
|
5050
5329
|
catch (err) {
|
|
5051
5330
|
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
5331
|
+
this.resetFooterFallback();
|
|
5052
5332
|
return { state: "unknown" };
|
|
5053
5333
|
}
|
|
5054
5334
|
}
|
|
@@ -5250,6 +5530,35 @@ export class Daemon extends EventEmitter {
|
|
|
5250
5530
|
return !!this.backend?.isDeliveryInputReadyPane
|
|
5251
5531
|
&& this.inputTransientGuardGeneration === this.spawnGeneration;
|
|
5252
5532
|
}
|
|
5533
|
+
/**
|
|
5534
|
+
* null preserves native readiness; false is an ineligible unknown layout.
|
|
5535
|
+
* true means the caller may advance the timer after checking freshness.
|
|
5536
|
+
* Share #1032's candidate/timer with the later paste gate, rather than
|
|
5537
|
+
* reporting idle after 10s only to make that gate wait another 10s (#1035).
|
|
5538
|
+
*/
|
|
5539
|
+
async probeDeliveryIdleFallback(pane) {
|
|
5540
|
+
const inputReady = this.backend?.isDeliveryInputReadyPane;
|
|
5541
|
+
const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
|
|
5542
|
+
if (!inputReady || !stableUnknown || inputReady.call(this.backend, pane)) {
|
|
5543
|
+
this.resetFooterFallback();
|
|
5544
|
+
return null;
|
|
5545
|
+
}
|
|
5546
|
+
const busy = this.backend?.getBusyPattern?.();
|
|
5547
|
+
if (busy)
|
|
5548
|
+
busy.lastIndex = 0;
|
|
5549
|
+
if (!stableUnknown.call(this.backend, pane) || busy?.test(pane)
|
|
5550
|
+
|| this.backend?.getPaneActivity?.(pane)
|
|
5551
|
+
|| this.deliveryBlockingDialogs().some(dialog => Daemon.dialogMatches(dialog, pane))
|
|
5552
|
+
|| this.inputTransientInPane(pane)) {
|
|
5553
|
+
this.resetFooterFallback();
|
|
5554
|
+
return false;
|
|
5555
|
+
}
|
|
5556
|
+
if (await this.tmux?.getPaneInputMode?.() !== "raw") {
|
|
5557
|
+
this.resetFooterFallback();
|
|
5558
|
+
return false;
|
|
5559
|
+
}
|
|
5560
|
+
return true;
|
|
5561
|
+
}
|
|
5253
5562
|
/** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
|
|
5254
5563
|
async hasPositiveDeliveryInput() {
|
|
5255
5564
|
if (!this.needsStartupInputProof())
|
|
@@ -5259,13 +5568,77 @@ export class Daemon extends EventEmitter {
|
|
|
5259
5568
|
return !check;
|
|
5260
5569
|
try {
|
|
5261
5570
|
const mode = await this.tmux.getPaneInputMode?.();
|
|
5262
|
-
if (mode !== "raw")
|
|
5571
|
+
if (mode !== "raw") {
|
|
5572
|
+
this.resetFooterFallback();
|
|
5263
5573
|
return false;
|
|
5264
|
-
|
|
5574
|
+
}
|
|
5575
|
+
return this.deliveryInputReadyPane(await this.tmux.capturePane());
|
|
5265
5576
|
}
|
|
5266
5577
|
catch {
|
|
5578
|
+
this.resetFooterFallback();
|
|
5579
|
+
return false;
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
/** Pane text of the current footer-fallback candidate, and since when it has been unchanged. */
|
|
5583
|
+
footerFallbackPaneKey = null;
|
|
5584
|
+
footerFallbackStableSince = 0;
|
|
5585
|
+
/** The launch the candidate was seen in: a new spawn or launch attempt starts over. */
|
|
5586
|
+
footerFallbackEpoch = null;
|
|
5587
|
+
/**
|
|
5588
|
+
* Forget the footer-fallback candidate. Called whenever the pane is seen
|
|
5589
|
+
* outside the candidate state (busy, a dialog, a transient, unreadable) and
|
|
5590
|
+
* on every spawn: "stable for UNKNOWN_LAYOUT_STABLE_MS" must mean this screen,
|
|
5591
|
+
* continuously, not time accumulated across an interruption.
|
|
5592
|
+
*/
|
|
5593
|
+
resetFooterFallback() {
|
|
5594
|
+
this.footerFallbackPaneKey = null;
|
|
5595
|
+
this.footerFallbackEpoch = null;
|
|
5596
|
+
}
|
|
5597
|
+
footerFallbackWarnedGeneration = null;
|
|
5598
|
+
/**
|
|
5599
|
+
* The input-row proof used before a paste (#931/#947), with one fallback
|
|
5600
|
+
* (#1031). Codex sometimes paints an idle composer without its Context
|
|
5601
|
+
* status item even though tui.status_line asks for it: after four fleet
|
|
5602
|
+
* restarts, sol's resumed session showed only `⚠ 2 warnings · f2 to view`.
|
|
5603
|
+
* The proof needs a recognised footer, so the first delivery after each
|
|
5604
|
+
* restart waited 30 minutes, failed, and waited again, for seven hours.
|
|
5605
|
+
*
|
|
5606
|
+
* When the footer is the only thing missing, the #978 structural evidence
|
|
5607
|
+
* stands in for it, all of it required: the backend's own stable-unknown
|
|
5608
|
+
* idle check (an empty live composer; no busy row, esc-to-interrupt row,
|
|
5609
|
+
* queued ↳ input or known picker), no input transient such as the resume
|
|
5610
|
+
* load, and the same screen for UNKNOWN_LAYOUT_STABLE_MS. The raw-tty
|
|
5611
|
+
* requirement stays with the caller.
|
|
5612
|
+
*/
|
|
5613
|
+
deliveryInputReadyPane(pane) {
|
|
5614
|
+
const check = this.backend?.isDeliveryInputReadyPane;
|
|
5615
|
+
if (!check)
|
|
5616
|
+
return true;
|
|
5617
|
+
if (check.call(this.backend, pane)) {
|
|
5618
|
+
this.footerFallbackPaneKey = null;
|
|
5619
|
+
return true;
|
|
5620
|
+
}
|
|
5621
|
+
const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
|
|
5622
|
+
if (!stableUnknown?.call(this.backend, pane) || this.inputTransientInPane(pane)) {
|
|
5623
|
+
this.footerFallbackPaneKey = null;
|
|
5624
|
+
return false;
|
|
5625
|
+
}
|
|
5626
|
+
const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
|
|
5627
|
+
const epoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
5628
|
+
const now = Date.now();
|
|
5629
|
+
if (key !== this.footerFallbackPaneKey || epoch !== this.footerFallbackEpoch) {
|
|
5630
|
+
this.footerFallbackPaneKey = key;
|
|
5631
|
+
this.footerFallbackEpoch = epoch;
|
|
5632
|
+
this.footerFallbackStableSince = now;
|
|
5267
5633
|
return false;
|
|
5268
5634
|
}
|
|
5635
|
+
if (now - this.footerFallbackStableSince < UNKNOWN_LAYOUT_STABLE_MS)
|
|
5636
|
+
return false;
|
|
5637
|
+
if (this.footerFallbackWarnedGeneration !== this.spawnGeneration) {
|
|
5638
|
+
this.footerFallbackWarnedGeneration = this.spawnGeneration;
|
|
5639
|
+
this.logger.warn({ backend: this.backend?.binaryName, stableForMs: now - this.footerFallbackStableSince }, "Idle footer not recognised — delivering on the #978 fallback (stable empty composer, no busy/queued/dialog, raw tty, no transient)");
|
|
5640
|
+
}
|
|
5641
|
+
return true;
|
|
5269
5642
|
}
|
|
5270
5643
|
/**
|
|
5271
5644
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
@@ -5275,16 +5648,26 @@ export class Daemon extends EventEmitter {
|
|
|
5275
5648
|
* pane proves nothing. The silence gate comes first and costs no capture.
|
|
5276
5649
|
*/
|
|
5277
5650
|
async paneReadinessForDelivery(windowId) {
|
|
5278
|
-
|
|
5651
|
+
// Each early return below is the pane seen outside the footer-fallback
|
|
5652
|
+
// candidate state, and never reaches deliveryInputReadyPane: reset here.
|
|
5653
|
+
if (!this.isPaneIdleForDelivery(windowId)) {
|
|
5654
|
+
this.resetFooterFallback();
|
|
5279
5655
|
return "busy";
|
|
5656
|
+
}
|
|
5280
5657
|
const probe = await this.probeBlockingDialog();
|
|
5281
|
-
if (probe.state !== "clear")
|
|
5658
|
+
if (probe.state !== "clear") {
|
|
5659
|
+
this.resetFooterFallback();
|
|
5282
5660
|
return probe.state;
|
|
5661
|
+
}
|
|
5283
5662
|
const transient = await this.probeInputTransient();
|
|
5284
|
-
if (transient.state === "active")
|
|
5663
|
+
if (transient.state === "active") {
|
|
5664
|
+
this.resetFooterFallback();
|
|
5285
5665
|
return "transient";
|
|
5286
|
-
|
|
5666
|
+
}
|
|
5667
|
+
if (transient.state === "unknown") {
|
|
5668
|
+
this.resetFooterFallback();
|
|
5287
5669
|
return "unknown";
|
|
5670
|
+
}
|
|
5288
5671
|
if (this.needsStartupInputProof()) {
|
|
5289
5672
|
return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
|
|
5290
5673
|
}
|
|
@@ -5461,7 +5844,7 @@ export class Daemon extends EventEmitter {
|
|
|
5461
5844
|
return "busy";
|
|
5462
5845
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
5463
5846
|
return "busy";
|
|
5464
|
-
if (
|
|
5847
|
+
if (!this.deliveryInputReadyPane(pane))
|
|
5465
5848
|
return "busy";
|
|
5466
5849
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
5467
5850
|
return "stranded";
|
|
@@ -6501,6 +6884,18 @@ export class Daemon extends EventEmitter {
|
|
|
6501
6884
|
}
|
|
6502
6885
|
return undefined;
|
|
6503
6886
|
}
|
|
6887
|
+
/** The fleet's answers to forwarded tool calls: settle the pending request they name. */
|
|
6888
|
+
routeFleetResponse(msg) {
|
|
6889
|
+
const type = msg.type;
|
|
6890
|
+
if (!type || !FLEET_RESPONSE_TYPES.has(type) || !msg.fleetRequestId)
|
|
6891
|
+
return;
|
|
6892
|
+
const key = String(msg.fleetRequestId);
|
|
6893
|
+
const handler = this.pendingIpcRequests.get(key);
|
|
6894
|
+
if (!handler)
|
|
6895
|
+
return;
|
|
6896
|
+
this.pendingIpcRequests.delete(key);
|
|
6897
|
+
handler(msg);
|
|
6898
|
+
}
|
|
6504
6899
|
/**
|
|
6505
6900
|
* Handle a tool call from the MCP server (forwarded by Claude).
|
|
6506
6901
|
* Routes to the channel adapter via MessageBus.
|
|
@@ -6549,9 +6944,12 @@ export class Daemon extends EventEmitter {
|
|
|
6549
6944
|
this.handleReleaseRepo(args, respond);
|
|
6550
6945
|
return;
|
|
6551
6946
|
}
|
|
6552
|
-
if (tool === "set_display_name" || tool === "set_description") {
|
|
6553
|
-
const type = tool === "set_display_name" ? "fleet_set_display_name"
|
|
6554
|
-
|
|
6947
|
+
if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis") {
|
|
6948
|
+
const type = tool === "set_display_name" ? "fleet_set_display_name"
|
|
6949
|
+
: tool === "set_description" ? "fleet_set_description"
|
|
6950
|
+
: tool === "list_emojis" ? "fleet_list_emojis"
|
|
6951
|
+
: tool === "preview_emojis" ? "fleet_preview_emojis" : "fleet_set_persona_emoji";
|
|
6952
|
+
const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
|
|
6555
6953
|
const timeout = setTimeout(() => {
|
|
6556
6954
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
6557
6955
|
respond(null, `${tool} timed out`);
|
|
@@ -6966,6 +7364,8 @@ export class Daemon extends EventEmitter {
|
|
|
6966
7364
|
* into exactly the window this exists to close.
|
|
6967
7365
|
*/
|
|
6968
7366
|
beginSpawn() {
|
|
7367
|
+
// A relaunch settles any outstanding quit watch: the CLI came back.
|
|
7368
|
+
this.clearQuitRelaunchWatch();
|
|
6969
7369
|
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
6970
7370
|
// v1 deliberately does not persist obligations across restarts: missing one
|
|
6971
7371
|
// warning is safer than treating the replacement's startup idle as the old
|
|
@@ -7716,6 +8116,29 @@ export class Daemon extends EventEmitter {
|
|
|
7716
8116
|
this.firstDeliveryDelay.recordReady();
|
|
7717
8117
|
return ready;
|
|
7718
8118
|
}
|
|
8119
|
+
/**
|
|
8120
|
+
* Report the CLI's logged-out startup screen as an auth incident. Returns true
|
|
8121
|
+
* when this call reported it (the scan is then over: only re-login ends the
|
|
8122
|
+
* state); false when the screen is absent or was already reported this spawn.
|
|
8123
|
+
* `loginScreenActive`, when the backend flow has one, replaces the bare
|
|
8124
|
+
* pattern test so a transcript that merely mentions the screen cannot trip it.
|
|
8125
|
+
*/
|
|
8126
|
+
reportLoginScreen(pane) {
|
|
8127
|
+
const flow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
8128
|
+
const onScreen = flow?.loginScreenActive ? flow.loginScreenActive(pane) : flow?.loginScreenPattern?.test(pane) === true;
|
|
8129
|
+
if (!onScreen || this.loginScreenReported)
|
|
8130
|
+
return false;
|
|
8131
|
+
this.loginScreenReported = true;
|
|
8132
|
+
this.authFailureUnresolved = true;
|
|
8133
|
+
this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
|
|
8134
|
+
this.emit("pty_error", {
|
|
8135
|
+
name: this.name,
|
|
8136
|
+
type: "auth_error",
|
|
8137
|
+
action: "pause",
|
|
8138
|
+
message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
|
|
8139
|
+
});
|
|
8140
|
+
return true;
|
|
8141
|
+
}
|
|
7719
8142
|
/**
|
|
7720
8143
|
* Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
|
|
7721
8144
|
* the backend knows, and return true once the CLI shows a ready prompt with
|
|
@@ -7891,6 +8314,11 @@ export class Daemon extends EventEmitter {
|
|
|
7891
8314
|
}
|
|
7892
8315
|
if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
|
|
7893
8316
|
this.trackDialogParked(null);
|
|
8317
|
+
// A sign-in screen that carries the ready pattern's own glyph (Claude's
|
|
8318
|
+
// `❯ 1. …` login menu) must be recognised BEFORE the ready check, or the
|
|
8319
|
+
// ready check swallows it and the incident is never reported (#1074).
|
|
8320
|
+
if (LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenBeforeReady && this.reportLoginScreen(pane))
|
|
8321
|
+
return true;
|
|
7894
8322
|
// CLI is ready (pattern defined by each backend). Require it on two
|
|
7895
8323
|
// consecutive polls: the first ready frame is also the moment a late
|
|
7896
8324
|
// dialog is about to be painted over it.
|
|
@@ -7921,19 +8349,8 @@ export class Daemon extends EventEmitter {
|
|
|
7921
8349
|
// an auth error (the lifecycle double-checks with the token-free probe)
|
|
7922
8350
|
// instead of letting it decay into crash/hang/MCP-died noise, and stop
|
|
7923
8351
|
// the retry loop — only re-login (see /login) ends this state.
|
|
7924
|
-
|
|
7925
|
-
if (loginScreen?.test(pane) && !this.loginScreenReported) {
|
|
7926
|
-
this.loginScreenReported = true;
|
|
7927
|
-
this.authFailureUnresolved = true;
|
|
7928
|
-
this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
|
|
7929
|
-
this.emit("pty_error", {
|
|
7930
|
-
name: this.name,
|
|
7931
|
-
type: "auth_error",
|
|
7932
|
-
action: "pause",
|
|
7933
|
-
message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
|
|
7934
|
-
});
|
|
8352
|
+
if (this.reportLoginScreen(pane))
|
|
7935
8353
|
return true;
|
|
7936
|
-
}
|
|
7937
8354
|
// Fatal: command not found (must match full phrase to avoid false positives
|
|
7938
8355
|
// like Kiro's "agent X not found, using default")
|
|
7939
8356
|
if (/command not found|: not found$/m.test(pane))
|