@songsid/agend 2.1.5 → 2.1.6-beta.10
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-endpoint.d.ts +18 -0
- package/dist/agent-endpoint.js +53 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/apply-job.d.ts +114 -0
- package/dist/apply-job.js +214 -0
- package/dist/apply-job.js.map +1 -0
- package/dist/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +127 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +57 -0
- package/dist/backend/codex.js +183 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +159 -0
- package/dist/backend/credential-profile.js +340 -0
- package/dist/backend/credential-profile.js.map +1 -0
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/kiro-auth-store.d.ts +25 -0
- package/dist/backend/kiro-auth-store.js +61 -0
- package/dist/backend/kiro-auth-store.js.map +1 -0
- package/dist/backend/kiro.d.ts +8 -0
- package/dist/backend/kiro.js +31 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +90 -0
- package/dist/backend/muse.js +437 -0
- package/dist/backend/muse.js.map +1 -0
- package/dist/backend/types.d.ts +54 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/access-manager.d.ts +20 -0
- package/dist/channel/access-manager.js +25 -0
- package/dist/channel/access-manager.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -0
- package/dist/channel/adapters/discord.js +72 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +8 -3
- package/dist/channel/markdown-chunk.js +12 -3
- package/dist/channel/markdown-chunk.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -12
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +6 -2
- package/dist/channel/mcp-tools.js +6 -36
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +32 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +45 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +76 -0
- package/dist/daemon.js +489 -103
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +9 -0
- package/dist/event-log.js +21 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-level-config.d.ts +42 -0
- package/dist/fleet-level-config.js +78 -0
- package/dist/fleet-level-config.js.map +1 -0
- package/dist/fleet-lock.d.ts +21 -0
- package/dist/fleet-lock.js +42 -8
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +425 -2
- package/dist/fleet-manager.js +2055 -182
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
- package/dist/instance-config-impact.d.ts +55 -0
- package/dist/instance-config-impact.js +152 -0
- package/dist/instance-config-impact.js.map +1 -0
- package/dist/instance-lifecycle.js +8 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +24 -0
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +42 -25
- package/dist/logger.js.map +1 -1
- package/dist/muse-usage-relay.d.ts +72 -0
- package/dist/muse-usage-relay.js +419 -0
- package/dist/muse-usage-relay.js.map +1 -0
- package/dist/outbound-handlers.d.ts +5 -1
- package/dist/outbound-handlers.js +199 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -5
- package/dist/outbound-schemas.js +3 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +48 -0
- package/dist/provider-probe.js +121 -0
- package/dist/provider-probe.js.map +1 -0
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +164 -0
- package/dist/quickstart-api.js +351 -0
- package/dist/quickstart-api.js.map +1 -0
- package/dist/quickstart.js +23 -50
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +17 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +34 -1
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +4 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/self-restart-limit.d.ts +21 -0
- package/dist/self-restart-limit.js +129 -0
- package/dist/self-restart-limit.js.map +1 -0
- package/dist/settings-api.d.ts +116 -0
- package/dist/settings-api.js +530 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-auth.d.ts +113 -0
- package/dist/setup-auth.js +181 -0
- package/dist/setup-auth.js.map +1 -0
- package/dist/setup-form.d.ts +18 -0
- package/dist/setup-form.js +315 -0
- package/dist/setup-form.js.map +1 -0
- package/dist/setup-host.d.ts +162 -0
- package/dist/setup-host.js +496 -0
- package/dist/setup-host.js.map +1 -0
- package/dist/setup-marker.d.ts +8 -0
- package/dist/setup-marker.js +39 -0
- package/dist/setup-marker.js.map +1 -0
- package/dist/setup-tunnel-consent.d.ts +53 -0
- package/dist/setup-tunnel-consent.js +82 -0
- package/dist/setup-tunnel-consent.js.map +1 -0
- package/dist/setup-wizard.js +4 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/steer-capability.js +4 -1
- package/dist/steer-capability.js.map +1 -1
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/tmux-manager.js +29 -4
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions-notice.d.ts +38 -0
- package/dist/tool-permissions-notice.js +92 -0
- package/dist/tool-permissions-notice.js.map +1 -0
- package/dist/tool-permissions.d.ts +120 -0
- package/dist/tool-permissions.js +277 -0
- package/dist/tool-permissions.js.map +1 -0
- package/dist/topic-commands.js +1 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +12 -1
- package/dist/transcript-sources.js +19 -3
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +71 -0
- package/dist/tunnel/cloudflared.js +447 -0
- package/dist/tunnel/cloudflared.js.map +1 -0
- package/dist/tunnel/lease.d.ts +75 -0
- package/dist/tunnel/lease.js +209 -0
- package/dist/tunnel/lease.js.map +1 -0
- package/dist/tunnel/manager.d.ts +56 -0
- package/dist/tunnel/manager.js +167 -0
- package/dist/tunnel/manager.js.map +1 -0
- package/dist/tunnel/types.d.ts +119 -0
- package/dist/tunnel/types.js +33 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/ui/dashboard.html +4 -3
- package/dist/ui/settings.html +808 -156
- package/dist/ui/view.html +2 -2
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +23 -14
- package/dist/usage/providers.js +220 -58
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +13 -0
- package/dist/usage/usage-api.js +69 -1
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.js +8 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +75 -0
- package/dist/web-auth.js +208 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-terminal.d.ts +30 -1
- package/dist/web-terminal.js +86 -3
- package/dist/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/daemon.js
CHANGED
|
@@ -9,6 +9,9 @@ import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
|
|
|
9
9
|
import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
|
|
10
10
|
import { TranscriptMonitor } from "./transcript-monitor.js";
|
|
11
11
|
import { createTranscriptSource } from "./transcript-sources.js";
|
|
12
|
+
import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
|
|
13
|
+
import { resolveToolSet } from "./tool-permissions.js";
|
|
14
|
+
import { getAgendHome } from "./paths.js";
|
|
12
15
|
import { LOGIN_FLOWS } from "./login-flows.js";
|
|
13
16
|
import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
|
|
14
17
|
import { ContextGuardian } from "./context-guardian.js";
|
|
@@ -26,6 +29,7 @@ import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata,
|
|
|
26
29
|
import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
|
|
27
30
|
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
28
31
|
import { t } from "./locale.js";
|
|
32
|
+
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
29
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
34
|
const __dirname = dirname(__filename);
|
|
31
35
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -40,7 +44,7 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
|
40
44
|
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
|
|
41
45
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
42
46
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
43
|
-
const source = binaryName === "codex" || binaryName === "grok"
|
|
47
|
+
const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
44
48
|
? "AGENTS.md"
|
|
45
49
|
: binaryName === "kiro-cli"
|
|
46
50
|
? `.kiro/steering/agend-${instanceName}.md`
|
|
@@ -83,6 +87,8 @@ export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
|
|
|
83
87
|
export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
84
88
|
/** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
|
|
85
89
|
export const PERIODIC_REDRAW_PROBE_MS = 25;
|
|
90
|
+
/** Do not turn a streaming Codex turn into one capture-pane per burst. */
|
|
91
|
+
export const STRUCTURED_IDLE_PROBE_MS = 500;
|
|
86
92
|
/** A foreground server/port-forward should hand control back or be acknowledged. */
|
|
87
93
|
export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
|
|
88
94
|
const LAST_INBOUND_FILE = "last-inbound-at";
|
|
@@ -432,7 +438,23 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
432
438
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
433
439
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
434
440
|
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
435
|
-
|
|
441
|
+
/**
|
|
442
|
+
* How long a passive startup/resume transient may sit WITHOUT REPAINTING before
|
|
443
|
+
* delivery gives up. It is a stall budget, not a total: a CLI that is still
|
|
444
|
+
* painting its resume is making progress, and #826 is what happens when the two
|
|
445
|
+
* are confused — codex's resume is the only declared transient in the fleet
|
|
446
|
+
* (backend/codex.ts), a woken paused codex routinely takes longer than this to
|
|
447
|
+
* come back, and the flat 30s cap turned that into ❌ plus a discarded message
|
|
448
|
+
* on a pane that was seconds from ready.
|
|
449
|
+
*/
|
|
450
|
+
const INPUT_TRANSIENT_STALL_MS = 30_000;
|
|
451
|
+
/**
|
|
452
|
+
* The absolute ceiling for the same wait, so a transient that repaints forever
|
|
453
|
+
* (a spinner on a wedged resume) still ends in an honest failure. Bounded, as
|
|
454
|
+
* every other delivery wait is — just bounded by "no progress", not by a guess
|
|
455
|
+
* at how long a resume takes.
|
|
456
|
+
*/
|
|
457
|
+
const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
436
458
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
437
459
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
438
460
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
@@ -875,6 +897,11 @@ export class Daemon extends EventEmitter {
|
|
|
875
897
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
876
898
|
dialogParkedKey = null;
|
|
877
899
|
dialogParkedReported = false;
|
|
900
|
+
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
901
|
+
inputBlockedDialogKey = null;
|
|
902
|
+
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
903
|
+
autoResolvedDialogKey = null;
|
|
904
|
+
autoResolvedDialogGeneration = 0;
|
|
878
905
|
backgroundSessionRecoveryAttempted = false;
|
|
879
906
|
/** Whether the last spawn started a fresh session (not resumed). */
|
|
880
907
|
isNewSession = false;
|
|
@@ -889,6 +916,9 @@ export class Daemon extends EventEmitter {
|
|
|
889
916
|
instanceStateMonitorTimer = null;
|
|
890
917
|
instanceStateIdleTimer = null;
|
|
891
918
|
instanceStateOutputProbeTimer = null;
|
|
919
|
+
/** Consecutive structural idle captures for a noisy, periodically-redrawing
|
|
920
|
+
* pane. Two are required before a working -> idle transition. */
|
|
921
|
+
instanceStatePeriodicIdleConfirmations = 0;
|
|
892
922
|
instanceStateStuckTimer = null;
|
|
893
923
|
instanceStateOutputListener = null;
|
|
894
924
|
instanceStateOutputEventName = null;
|
|
@@ -941,6 +971,11 @@ export class Daemon extends EventEmitter {
|
|
|
941
971
|
fatalStartupBlocked = false;
|
|
942
972
|
/** model_error seen mid-turn: notify only if it survives to the idle screen. */
|
|
943
973
|
pendingModelErrorKey = null;
|
|
974
|
+
/** Optional per-instance localhost relay used to observe Muse usage events. */
|
|
975
|
+
museUsageRelay = null;
|
|
976
|
+
/** Set after an in-run relay failure so subsequent Muse respawns go direct. */
|
|
977
|
+
museRelayFallback = false;
|
|
978
|
+
museRelayFallbackInFlight = null;
|
|
944
979
|
/**
|
|
945
980
|
* Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
|
|
946
981
|
* pause — see the pausePending consumption site for why waiting for idle there
|
|
@@ -1323,7 +1358,7 @@ export class Daemon extends EventEmitter {
|
|
|
1323
1358
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
1324
1359
|
// conversation store via a pluggable source. Backends with no known
|
|
1325
1360
|
// source stay inert exactly as before.
|
|
1326
|
-
this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
|
|
1361
|
+
this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
|
|
1327
1362
|
// 5. Wire transcript events
|
|
1328
1363
|
const ackIfPending = () => {
|
|
1329
1364
|
if (!this.pendingAckMessage || !this.adapter)
|
|
@@ -2162,6 +2197,7 @@ export class Daemon extends EventEmitter {
|
|
|
2162
2197
|
if (!alive)
|
|
2163
2198
|
return;
|
|
2164
2199
|
const pane = await this.tmux.capturePane();
|
|
2200
|
+
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2165
2201
|
const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
|
|
2166
2202
|
if (interactivePrompt) {
|
|
2167
2203
|
this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
|
|
@@ -2178,7 +2214,7 @@ export class Daemon extends EventEmitter {
|
|
|
2178
2214
|
const hasPendingWork = this.pendingWork.hasPendingWork();
|
|
2179
2215
|
if (!hasPendingWork)
|
|
2180
2216
|
this.blockingProcessDetector.reset();
|
|
2181
|
-
const blockingProcess = hasPendingWork
|
|
2217
|
+
const blockingProcess = hasPendingWork && !inputBlockedDialog
|
|
2182
2218
|
? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
|
|
2183
2219
|
: null;
|
|
2184
2220
|
if (blockingProcess) {
|
|
@@ -2200,27 +2236,80 @@ export class Daemon extends EventEmitter {
|
|
|
2200
2236
|
blockingSeen = dialog;
|
|
2201
2237
|
if (dialog.holdOnly)
|
|
2202
2238
|
break; // recognised, deliberately not answered; trackDialogParked reports it
|
|
2239
|
+
const autoKey = dialog.autoResolutionKey;
|
|
2240
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2241
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2242
|
+
&& this.autoResolvedDialogKey === autoKey) {
|
|
2243
|
+
// A previous poll sent the safety choice but the CLI has not
|
|
2244
|
+
// repainted yet. Never send another Enter into the same screen.
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2203
2247
|
// These keys go straight into the pane. Sent while a message delivery is
|
|
2204
2248
|
// mid-transaction, an `Escape` wipes the pasted text and an `Enter`
|
|
2205
2249
|
// submits it half-composed — the user sees a message that vanished. Skip
|
|
2206
2250
|
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
2207
2251
|
// dialog will still be on screen next tick.
|
|
2252
|
+
let resolved = false;
|
|
2208
2253
|
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
2254
|
+
// Re-read under the lock: the first capture may have gone stale
|
|
2255
|
+
// while a delivery was finishing. A stale danger menu must never
|
|
2256
|
+
// receive a blind key sequence.
|
|
2257
|
+
const currentPane = await this.tmux.capturePane();
|
|
2258
|
+
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
2259
|
+
this.updateInputBlockedState(currentPane, dialogs);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2263
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2264
|
+
&& this.autoResolvedDialogKey === autoKey)
|
|
2265
|
+
return;
|
|
2266
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2267
|
+
this.autoResolvedDialogGeneration = this.spawnGeneration;
|
|
2268
|
+
this.autoResolvedDialogKey = autoKey;
|
|
2269
|
+
}
|
|
2209
2270
|
this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
|
|
2210
2271
|
const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
|
|
2211
2272
|
for (const key of dialog.keys) {
|
|
2273
|
+
let sent = false;
|
|
2212
2274
|
if (SPECIAL_KEYS.has(key)) {
|
|
2213
|
-
await this.tmux.sendSpecialKey(key);
|
|
2275
|
+
sent = await this.tmux.sendSpecialKey(key);
|
|
2214
2276
|
}
|
|
2215
2277
|
else {
|
|
2216
|
-
await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2278
|
+
sent = await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2279
|
+
}
|
|
2280
|
+
if (!sent) {
|
|
2281
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2282
|
+
this.autoResolvedDialogGeneration = 0;
|
|
2283
|
+
this.autoResolvedDialogKey = null;
|
|
2284
|
+
}
|
|
2285
|
+
return;
|
|
2217
2286
|
}
|
|
2218
2287
|
await new Promise(r => setTimeout(r, 200));
|
|
2219
2288
|
}
|
|
2289
|
+
if (dialog.verifyAfterKeys) {
|
|
2290
|
+
const afterKeysPane = await this.tmux.capturePane();
|
|
2291
|
+
const dialogStillActive = dialog.inputBlocked
|
|
2292
|
+
? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
|
|
2293
|
+
: Daemon.dialogMatches(dialog, afterKeysPane);
|
|
2294
|
+
if (dialogStillActive) {
|
|
2295
|
+
this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
this.updateInputBlockedState(afterKeysPane, dialogs);
|
|
2299
|
+
if (dialog.postDismissNotice) {
|
|
2300
|
+
resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
|
|
2301
|
+
}
|
|
2302
|
+
else {
|
|
2303
|
+
resolved = true;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2220
2306
|
});
|
|
2221
2307
|
if (!dismissed) {
|
|
2222
2308
|
this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
2223
2309
|
}
|
|
2310
|
+
else if (dialog.verifyAfterKeys && !resolved) {
|
|
2311
|
+
this.logger.warn({ dialog: dialog.description }, "Safety dialog choice was sent but follow-up verification or notice submission failed");
|
|
2312
|
+
}
|
|
2224
2313
|
this.trackDialogParked(blockingSeen);
|
|
2225
2314
|
return; // Dialog handled (or deliberately deferred): skip error checks this cycle
|
|
2226
2315
|
}
|
|
@@ -2539,6 +2628,13 @@ export class Daemon extends EventEmitter {
|
|
|
2539
2628
|
this.inputTransientGuardGeneration = null;
|
|
2540
2629
|
this.freezeRuntimeMonitors();
|
|
2541
2630
|
this.pendingIpcRequests.clear();
|
|
2631
|
+
if (this.museUsageRelay) {
|
|
2632
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
2633
|
+
this.museUsageRelay = null;
|
|
2634
|
+
}
|
|
2635
|
+
else if (this.backend?.binaryName === "muse") {
|
|
2636
|
+
clearMuseUsageSnapshot(this.instanceDir);
|
|
2637
|
+
}
|
|
2542
2638
|
if (this.adapter)
|
|
2543
2639
|
await this.adapter.stop();
|
|
2544
2640
|
// Notify MCP servers of graceful shutdown (prevents reconnect attempts)
|
|
@@ -2818,6 +2914,15 @@ export class Daemon extends EventEmitter {
|
|
|
2818
2914
|
}
|
|
2819
2915
|
applyInstanceStateSnapshot(snapshot, pane) {
|
|
2820
2916
|
const previous = this.instanceState;
|
|
2917
|
+
// A live safety menu owns stdin. It may still contain Claude's persistent
|
|
2918
|
+
// `❯` ready marker, but that is not an idle turn and must not clear pending
|
|
2919
|
+
// work, retire Cancel, arm auto-pause, or enter the generic stuck path.
|
|
2920
|
+
// Keep the public execution state unchanged until the menu disappears; the
|
|
2921
|
+
// dedicated input_blocked event carries the reason to observers.
|
|
2922
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
2923
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2821
2926
|
this.instanceState = snapshot.state;
|
|
2822
2927
|
// OpenCode creates its session lazily on the first submitted message.
|
|
2823
2928
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
@@ -3230,13 +3335,13 @@ export class Daemon extends EventEmitter {
|
|
|
3230
3335
|
* visible cell. Probe the pane once per burst: a changed capture is real work;
|
|
3231
3336
|
* an identical ready capture remains idle and emits no state edge.
|
|
3232
3337
|
*/
|
|
3233
|
-
scheduleInstanceStateOutputProbe() {
|
|
3338
|
+
scheduleInstanceStateOutputProbe(delayMs = PERIODIC_REDRAW_PROBE_MS) {
|
|
3234
3339
|
if (this.instanceStateOutputProbeTimer)
|
|
3235
3340
|
return;
|
|
3236
3341
|
this.instanceStateOutputProbeTimer = setTimeout(() => {
|
|
3237
3342
|
this.instanceStateOutputProbeTimer = null;
|
|
3238
3343
|
void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
|
|
3239
|
-
},
|
|
3344
|
+
}, delayMs);
|
|
3240
3345
|
this.instanceStateOutputProbeTimer.unref?.();
|
|
3241
3346
|
}
|
|
3242
3347
|
clearInstanceStateStuckTimer() {
|
|
@@ -3291,6 +3396,7 @@ export class Daemon extends EventEmitter {
|
|
|
3291
3396
|
const captureStartedAt = Date.now();
|
|
3292
3397
|
try {
|
|
3293
3398
|
const pane = await this.tmux.capturePane();
|
|
3399
|
+
this.updateInputBlockedState(pane);
|
|
3294
3400
|
// Output received while capture-pane was in flight makes this snapshot
|
|
3295
3401
|
// stale. Its output handler has already armed a new debounce.
|
|
3296
3402
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
@@ -3319,11 +3425,98 @@ export class Daemon extends EventEmitter {
|
|
|
3319
3425
|
// changed when tmux reported output (observedChangeAt), but idle/stuck are
|
|
3320
3426
|
// decisions about *now*. The old double-observe expressed that by calling
|
|
3321
3427
|
// observe twice, which silently disabled the "content moved" branch.
|
|
3322
|
-
const
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3428
|
+
const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
|
|
3429
|
+
let snapshot;
|
|
3430
|
+
if (structuredPeriodicIdle && reason === "output_probe") {
|
|
3431
|
+
const idlePane = structuredPeriodicIdle.call(this.backend, pane);
|
|
3432
|
+
if (idlePane) {
|
|
3433
|
+
if (this.instanceState === "idle") {
|
|
3434
|
+
// Cosmetic output must not manufacture an idle -> working edge.
|
|
3435
|
+
this.instanceStatePeriodicIdleConfirmations = 2;
|
|
3436
|
+
}
|
|
3437
|
+
else {
|
|
3438
|
+
this.instanceStatePeriodicIdleConfirmations++;
|
|
3439
|
+
}
|
|
3440
|
+
if (this.instanceStatePeriodicIdleConfirmations < 2) {
|
|
3441
|
+
// One positive frame is intentionally not enough to bless a
|
|
3442
|
+
// working pane while the starfield is still repainting.
|
|
3443
|
+
snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
|
|
3444
|
+
}
|
|
3445
|
+
else {
|
|
3446
|
+
snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
|
|
3447
|
+
settled: true,
|
|
3448
|
+
changeAt: observedChangeAt,
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
else {
|
|
3453
|
+
// A live busy marker or an unfamiliar layout outranks the broad Codex
|
|
3454
|
+
// ready pattern during the noisy-redraw path. Unknown is working.
|
|
3455
|
+
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3456
|
+
snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
else if (structuredPeriodicIdle && reason !== "idle_debounce" && !settled) {
|
|
3460
|
+
// Startup, safety, and explicit state probes must not bless a broad
|
|
3461
|
+
// Codex ready match when the structural layout is unknown or visibly
|
|
3462
|
+
// busy. The settled debounce below remains the compatibility path for
|
|
3463
|
+
// drafts and older layouts that simply lack the new footer.
|
|
3464
|
+
//
|
|
3465
|
+
// ONLY while output is still recent. recordOutput sets working
|
|
3466
|
+
// unconditionally, and the safety sweep fires for every daemon on a
|
|
3467
|
+
// timer — so without this guard a Codex that had gone quiet was flipped
|
|
3468
|
+
// to working by the next sweep and could never come back, because a
|
|
3469
|
+
// quiet pane produces no further output to re-evaluate it. That state
|
|
3470
|
+
// holds the Cancel button open, disables auto-pause, and makes the hang
|
|
3471
|
+
// detector report a perfectly idle instance as stuck. A settled capture
|
|
3472
|
+
// has no recent output to defend and falls through to the legacy
|
|
3473
|
+
// ready/busy reading below.
|
|
3474
|
+
//
|
|
3475
|
+
// The `settled` guard alone is not enough for the case this whole
|
|
3476
|
+
// branch exists to serve. While the Astra star field keeps repainting,
|
|
3477
|
+
// output never stops arriving, so the capture is NEVER settled — and a
|
|
3478
|
+
// sweep landing on an already-idle animated pane flipped it to working
|
|
3479
|
+
// once a minute, then back an instant later once two probes re-proved
|
|
3480
|
+
// it. Each flicker is a spurious pair of state edges for the fleet and
|
|
3481
|
+
// resets the idleSince this daemon reports when it asks to auto-pause.
|
|
3482
|
+
//
|
|
3483
|
+
// So the same rule the output_probe branch already applies holds here:
|
|
3484
|
+
// cosmetic output must not manufacture an idle -> working edge. A pane
|
|
3485
|
+
// that is STRUCTURALLY idle and already known idle stays idle.
|
|
3486
|
+
//
|
|
3487
|
+
// It must be re-read as SETTLED to do that. Falling through to the
|
|
3488
|
+
// ordinary reading is not equivalent: that path is given the real
|
|
3489
|
+
// `settled` (false, because the animation is still painting), and an
|
|
3490
|
+
// unsettled capture of a CHANGED pane is working by definition — so a
|
|
3491
|
+
// sweep landing on a frame no probe had captured yet still flipped an
|
|
3492
|
+
// idle pane over. Treating this capture as settled is what lets the
|
|
3493
|
+
// ready/busy reading decide on the frame itself.
|
|
3494
|
+
const idlePane = structuredPeriodicIdle.call(this.backend, pane);
|
|
3495
|
+
if (idlePane && this.instanceState === "idle") {
|
|
3496
|
+
this.instanceStatePeriodicIdleConfirmations = 2;
|
|
3497
|
+
snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
|
|
3498
|
+
settled: true,
|
|
3499
|
+
changeAt: observedChangeAt,
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
else {
|
|
3503
|
+
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3504
|
+
snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
else {
|
|
3508
|
+
// Structural proof is an additional working-stage escape hatch only.
|
|
3509
|
+
// Settled captures retain the legacy ready/busy semantics for drafts,
|
|
3510
|
+
// older Codex layouts, and panes without a Context footer.
|
|
3511
|
+
if (structuredPeriodicIdle && settled) {
|
|
3512
|
+
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3513
|
+
}
|
|
3514
|
+
snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
|
|
3515
|
+
settled,
|
|
3516
|
+
changeAt: observedChangeAt,
|
|
3517
|
+
forceBusy: paneActivity !== null,
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3327
3520
|
this.applyInstanceStateSnapshot(snapshot, pane);
|
|
3328
3521
|
// Backends without a transcript feed can still say what they are doing, if
|
|
3329
3522
|
// their TUI names it. Free-riding on a capture that already happened: no
|
|
@@ -3332,10 +3525,11 @@ export class Daemon extends EventEmitter {
|
|
|
3332
3525
|
if (this.backend?.getPaneActivity) {
|
|
3333
3526
|
this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
|
|
3334
3527
|
}
|
|
3335
|
-
|
|
3528
|
+
const currentState = this.instanceState;
|
|
3529
|
+
if (currentState === "idle") {
|
|
3336
3530
|
this.clearInstanceStateStuckTimer();
|
|
3337
3531
|
}
|
|
3338
|
-
else if (
|
|
3532
|
+
else if (currentState === "working") {
|
|
3339
3533
|
// If control mode missed the pane change, the safety capture becomes
|
|
3340
3534
|
// the new progress timestamp and re-arms both deadlines.
|
|
3341
3535
|
if (!expectedOutputAt)
|
|
@@ -3357,11 +3551,17 @@ export class Daemon extends EventEmitter {
|
|
|
3357
3551
|
if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
|
|
3358
3552
|
return;
|
|
3359
3553
|
this.instanceStateLastOutputAt = event.at;
|
|
3360
|
-
|
|
3554
|
+
const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
|
|
3555
|
+
if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
|
|
3361
3556
|
// Do not emit idle→working solely because agy repainted an identical
|
|
3362
|
-
// footer.
|
|
3363
|
-
//
|
|
3364
|
-
|
|
3557
|
+
// footer. Codex additionally uses a structural probe while working: two
|
|
3558
|
+
// positive prompt captures can recover from an animation stream that
|
|
3559
|
+
// never leaves a two-second quiet window.
|
|
3560
|
+
if (this.instanceState !== "idle") {
|
|
3561
|
+
this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(event.at));
|
|
3562
|
+
this.scheduleInstanceStateStuckDeadline(event.at);
|
|
3563
|
+
}
|
|
3564
|
+
this.scheduleInstanceStateOutputProbe(canProvePeriodicIdle ? STRUCTURED_IDLE_PROBE_MS : PERIODIC_REDRAW_PROBE_MS);
|
|
3365
3565
|
this.scheduleInstanceStateIdleCapture();
|
|
3366
3566
|
return;
|
|
3367
3567
|
}
|
|
@@ -3397,6 +3597,7 @@ export class Daemon extends EventEmitter {
|
|
|
3397
3597
|
this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
3398
3598
|
this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
|
|
3399
3599
|
this.instanceStateLastOutputAt = 0;
|
|
3600
|
+
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3400
3601
|
this.instanceStateMonitorActive = true;
|
|
3401
3602
|
if (this.controlClient) {
|
|
3402
3603
|
this.instanceStateSafetyListener = () => { void this.captureAndEvaluateInstanceState("safety_sweep"); };
|
|
@@ -3414,6 +3615,7 @@ export class Daemon extends EventEmitter {
|
|
|
3414
3615
|
}
|
|
3415
3616
|
stopInstanceStateMonitor() {
|
|
3416
3617
|
this.instanceStateMonitorActive = false;
|
|
3618
|
+
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3417
3619
|
this.clearInstanceStateIdleTimer();
|
|
3418
3620
|
this.clearInstanceStateOutputProbeTimer();
|
|
3419
3621
|
this.clearInstanceStateStuckTimer();
|
|
@@ -3431,6 +3633,10 @@ export class Daemon extends EventEmitter {
|
|
|
3431
3633
|
this.instanceStateSafetyListener = null;
|
|
3432
3634
|
}
|
|
3433
3635
|
handleStuckTransition(pane, snapshot, readyPattern) {
|
|
3636
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
3637
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing stuck notification while CLI input is blocked by a dialog");
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3434
3640
|
const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
|
|
3435
3641
|
const diagnostic = {
|
|
3436
3642
|
backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
|
|
@@ -3730,6 +3936,28 @@ export class Daemon extends EventEmitter {
|
|
|
3730
3936
|
}
|
|
3731
3937
|
return formatted;
|
|
3732
3938
|
}
|
|
3939
|
+
/**
|
|
3940
|
+
* Whether ONE delivery reached a verdict, carried by that delivery.
|
|
3941
|
+
*
|
|
3942
|
+
* `deliverMessage` returns false for two unrelated things: a delivery that
|
|
3943
|
+
* cannot land (window gone, a pane that never came back, an unanswered
|
|
3944
|
+
* dialog) and a delivery that was never attempted (user cancel, a tmux storm,
|
|
3945
|
+
* shutdown). Only the first is a failure to tell the sender about; reporting
|
|
3946
|
+
* the second told a sending agent its message was lost while the fleet was
|
|
3947
|
+
* merely standing down (#826).
|
|
3948
|
+
*
|
|
3949
|
+
* It is per call and not a field on the daemon because deliveries do NOT all
|
|
3950
|
+
* share one lock: an ordinary delivery is serialised on `pasteLock`, a steer
|
|
3951
|
+
* on `steerLock`, and the two run concurrently by design. A field would let a
|
|
3952
|
+
* steer's verdict decide whether a queued message's sender is told its
|
|
3953
|
+
* delivery failed — a false ❌ carrying the wrong correlation id.
|
|
3954
|
+
*/
|
|
3955
|
+
failDelivery(verdict, status) {
|
|
3956
|
+
verdict.reached = true;
|
|
3957
|
+
if (status)
|
|
3958
|
+
this.emit("message_failed", status); // ❌
|
|
3959
|
+
return false;
|
|
3960
|
+
}
|
|
3733
3961
|
/** Tell the fleet when an already-accepted cross-instance pane write failed. */
|
|
3734
3962
|
reportCrossInstanceDeliveryFailure(meta, error) {
|
|
3735
3963
|
if (!meta.from_instance)
|
|
@@ -3785,10 +4013,14 @@ export class Daemon extends EventEmitter {
|
|
|
3785
4013
|
await this.wake();
|
|
3786
4014
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3787
4015
|
return;
|
|
3788
|
-
|
|
4016
|
+
const verdict = { reached: false };
|
|
4017
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
3789
4018
|
this.markTurnStarted(meta, formatted);
|
|
3790
4019
|
}
|
|
3791
|
-
else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4020
|
+
else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4021
|
+
// Same rule as the queued path: a steer that never got to try is not a
|
|
4022
|
+
// delivery failure. This holder is the steer's own, which is the point
|
|
4023
|
+
// — it runs on steerLock while a queued delivery runs on pasteLock.
|
|
3792
4024
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
3793
4025
|
}
|
|
3794
4026
|
}).catch(err => {
|
|
@@ -3918,10 +4150,16 @@ export class Daemon extends EventEmitter {
|
|
|
3918
4150
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
3919
4151
|
// the previous turn's tool list.
|
|
3920
4152
|
this.resetToolProgress();
|
|
3921
|
-
|
|
4153
|
+
const verdict = { reached: false };
|
|
4154
|
+
if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
3922
4155
|
this.markTurnStarted(meta, formatted);
|
|
3923
4156
|
}
|
|
3924
|
-
else if (meta.from_instance &&
|
|
4157
|
+
else if (meta.from_instance && verdict.reached
|
|
4158
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4159
|
+
// Only a delivery that reached a verdict is reported. A pane that is
|
|
4160
|
+
// not ready yet, a cancel, a storm hold or a shutdown all return
|
|
4161
|
+
// false without one, and telling the sender its message was lost
|
|
4162
|
+
// there would be the false ❌ of #826 in its other form.
|
|
3925
4163
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
3926
4164
|
}
|
|
3927
4165
|
}
|
|
@@ -3953,6 +4191,9 @@ export class Daemon extends EventEmitter {
|
|
|
3953
4191
|
* serial in both cases so separate PTY writes can never overlap.
|
|
3954
4192
|
*/
|
|
3955
4193
|
async deliverMessage(formatted, status, opts) {
|
|
4194
|
+
// The caller passes its own holder when it needs the answer; a system paste
|
|
4195
|
+
// that ignores the outcome gets a throwaway.
|
|
4196
|
+
const verdict = opts?.verdict ?? { reached: false };
|
|
3956
4197
|
const cancelled = () => opts?.deliveryEpoch !== undefined
|
|
3957
4198
|
&& !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
|
|
3958
4199
|
if (cancelled() || this.stormWindow?.isStopped())
|
|
@@ -3976,7 +4217,7 @@ export class Daemon extends EventEmitter {
|
|
|
3976
4217
|
await this.waitForSpawnToSettle();
|
|
3977
4218
|
if (cancelled())
|
|
3978
4219
|
return false;
|
|
3979
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4220
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
3980
4221
|
return false;
|
|
3981
4222
|
let windowId = this.getWindowId();
|
|
3982
4223
|
const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
|
|
@@ -4018,9 +4259,7 @@ export class Daemon extends EventEmitter {
|
|
|
4018
4259
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
4019
4260
|
// would land on top of it) — and instead of holding the queue silently.
|
|
4020
4261
|
this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
|
|
4021
|
-
|
|
4022
|
-
this.emit("message_failed", status); // ❌
|
|
4023
|
-
return false;
|
|
4262
|
+
return this.failDelivery(verdict, status);
|
|
4024
4263
|
}
|
|
4025
4264
|
}
|
|
4026
4265
|
}
|
|
@@ -4041,9 +4280,7 @@ export class Daemon extends EventEmitter {
|
|
|
4041
4280
|
// recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
|
|
4042
4281
|
if (round >= STRANDED_INPUT_MAX_ROUNDS) {
|
|
4043
4282
|
this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
|
|
4044
|
-
|
|
4045
|
-
this.emit("message_failed", status); // ❌
|
|
4046
|
-
return false;
|
|
4283
|
+
return this.failDelivery(verdict, status);
|
|
4047
4284
|
}
|
|
4048
4285
|
const stranded = await this.submitStrandedInputIfAny(windowId);
|
|
4049
4286
|
if (cancelled())
|
|
@@ -4052,18 +4289,14 @@ export class Daemon extends EventEmitter {
|
|
|
4052
4289
|
break;
|
|
4053
4290
|
if (stranded === "failed") {
|
|
4054
4291
|
this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
|
|
4055
|
-
|
|
4056
|
-
this.emit("message_failed", status); // ❌
|
|
4057
|
-
return false;
|
|
4292
|
+
return this.failDelivery(verdict, status);
|
|
4058
4293
|
}
|
|
4059
4294
|
const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
|
|
4060
4295
|
if (cancelled())
|
|
4061
4296
|
return false;
|
|
4062
4297
|
if (!readyAgain) {
|
|
4063
4298
|
this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
|
|
4064
|
-
|
|
4065
|
-
this.emit("message_failed", status); // ❌
|
|
4066
|
-
return false;
|
|
4299
|
+
return this.failDelivery(verdict, status);
|
|
4067
4300
|
}
|
|
4068
4301
|
}
|
|
4069
4302
|
}
|
|
@@ -4075,15 +4308,13 @@ export class Daemon extends EventEmitter {
|
|
|
4075
4308
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
4076
4309
|
if (cancelled())
|
|
4077
4310
|
return false;
|
|
4078
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4311
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4079
4312
|
return false;
|
|
4080
4313
|
// A passive startup phase may paint after the outer readiness probe.
|
|
4081
4314
|
// It clears without input, so waiting under the pane lock cannot starve
|
|
4082
4315
|
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4083
4316
|
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4084
|
-
|
|
4085
|
-
this.emit("message_failed", status); // ❌
|
|
4086
|
-
return false;
|
|
4317
|
+
return this.failDelivery(verdict, status);
|
|
4087
4318
|
}
|
|
4088
4319
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
4089
4320
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
@@ -4094,7 +4325,7 @@ export class Daemon extends EventEmitter {
|
|
|
4094
4325
|
if (probe.state !== "clear")
|
|
4095
4326
|
return "dialog";
|
|
4096
4327
|
}
|
|
4097
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
|
|
4328
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
|
|
4098
4329
|
});
|
|
4099
4330
|
if (outcome !== "dialog")
|
|
4100
4331
|
return outcome;
|
|
@@ -4103,18 +4334,14 @@ export class Daemon extends EventEmitter {
|
|
|
4103
4334
|
// an honest failure rather than a message that never lands.
|
|
4104
4335
|
if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
|
|
4105
4336
|
this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
|
|
4106
|
-
|
|
4107
|
-
this.emit("message_failed", status); // ❌
|
|
4108
|
-
return false;
|
|
4337
|
+
return this.failDelivery(verdict, status);
|
|
4109
4338
|
}
|
|
4110
4339
|
this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
|
|
4111
4340
|
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
|
|
4112
4341
|
if (cancelled())
|
|
4113
4342
|
return false;
|
|
4114
4343
|
if (!clear) {
|
|
4115
|
-
|
|
4116
|
-
this.emit("message_failed", status); // ❌
|
|
4117
|
-
return false;
|
|
4344
|
+
return this.failDelivery(verdict, status);
|
|
4118
4345
|
}
|
|
4119
4346
|
}
|
|
4120
4347
|
}
|
|
@@ -4149,13 +4376,47 @@ export class Daemon extends EventEmitter {
|
|
|
4149
4376
|
return null;
|
|
4150
4377
|
}
|
|
4151
4378
|
/** Capture failure is unknown, never evidence that input is available. */
|
|
4379
|
+
/**
|
|
4380
|
+
* The budget for waiting out a passive transient, measured in progress rather
|
|
4381
|
+
* than in wall clock.
|
|
4382
|
+
*
|
|
4383
|
+
* `observe(pane)` answers "may I keep waiting?". The stall timer restarts
|
|
4384
|
+
* every time the pane text changes, so a resume that is still painting never
|
|
4385
|
+
* runs out; a screen that has not changed for INPUT_TRANSIENT_STALL_MS, or a
|
|
4386
|
+
* transient that outlives the ceiling, does. Both ends stay bounded — the
|
|
4387
|
+
* point of #826 was never that the wait should be unlimited, it was that a
|
|
4388
|
+
* flat 30s could not tell "slow" from "stuck".
|
|
4389
|
+
*/
|
|
4390
|
+
transientProgressBudget(overallDeadline) {
|
|
4391
|
+
const ceiling = Math.min(overallDeadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4392
|
+
let lastPane = null;
|
|
4393
|
+
let stallDeadline = 0;
|
|
4394
|
+
const budget = {
|
|
4395
|
+
stalled: false,
|
|
4396
|
+
observe(pane) {
|
|
4397
|
+
const now = Date.now();
|
|
4398
|
+
if (pane !== lastPane) {
|
|
4399
|
+
lastPane = pane;
|
|
4400
|
+
stallDeadline = now + INPUT_TRANSIENT_STALL_MS;
|
|
4401
|
+
}
|
|
4402
|
+
if (now >= ceiling)
|
|
4403
|
+
return false;
|
|
4404
|
+
budget.stalled = now >= stallDeadline;
|
|
4405
|
+
return !budget.stalled;
|
|
4406
|
+
},
|
|
4407
|
+
};
|
|
4408
|
+
return budget;
|
|
4409
|
+
}
|
|
4152
4410
|
async probeInputTransient() {
|
|
4153
4411
|
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4154
4412
|
return { state: "clear" };
|
|
4155
4413
|
try {
|
|
4156
4414
|
const pane = await this.tmux.capturePane();
|
|
4157
4415
|
const transient = this.inputTransientInPane(pane);
|
|
4158
|
-
|
|
4416
|
+
// The pane text comes back with the verdict: a transient that is still
|
|
4417
|
+
// repainting is still working, and that is the only thing separating a
|
|
4418
|
+
// slow resume from a wedged one.
|
|
4419
|
+
return transient ? { state: "active", transient, pane } : { state: "clear" };
|
|
4159
4420
|
}
|
|
4160
4421
|
catch (err) {
|
|
4161
4422
|
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
@@ -4171,7 +4432,12 @@ export class Daemon extends EventEmitter {
|
|
|
4171
4432
|
const generation = this.inputTransientGuardGeneration;
|
|
4172
4433
|
if (generation === null || generation !== this.spawnGeneration)
|
|
4173
4434
|
return true;
|
|
4174
|
-
|
|
4435
|
+
// Same progress rule as the outer readiness wait, for the same reason: this
|
|
4436
|
+
// runs under the pane write lock, so a flat cap here turned a resume that
|
|
4437
|
+
// repainted one second too late into a discarded message. Holding the lock
|
|
4438
|
+
// while the pane is still painting costs nothing — every other writer is
|
|
4439
|
+
// waiting on the same screen.
|
|
4440
|
+
const budget = this.transientProgressBudget(Date.now() + timeoutMs);
|
|
4175
4441
|
let observedDescription = null;
|
|
4176
4442
|
for (;;) {
|
|
4177
4443
|
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
@@ -4189,12 +4455,18 @@ export class Daemon extends EventEmitter {
|
|
|
4189
4455
|
observedDescription = probe.transient.description;
|
|
4190
4456
|
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4191
4457
|
}
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4458
|
+
// "unknown" (capture-pane failed) has to consume the budget too, or an
|
|
4459
|
+
// unreadable pane would loop forever now that the flat deadline is gone.
|
|
4460
|
+
// It never "changes", so it stalls out on the same rule.
|
|
4461
|
+
if (!budget.observe(probe.state === "active" ? probe.pane : "\u0000unreadable")) {
|
|
4462
|
+
this.logger.error({
|
|
4463
|
+
phase, generation, transient: observedDescription,
|
|
4464
|
+
stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4465
|
+
ceilingMs: timeoutMs,
|
|
4466
|
+
}, "CLI input stayed unavailable — refusing to send Enter");
|
|
4195
4467
|
return false;
|
|
4196
4468
|
}
|
|
4197
|
-
await new Promise(r => setTimeout(r,
|
|
4469
|
+
await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
|
|
4198
4470
|
}
|
|
4199
4471
|
}
|
|
4200
4472
|
static dialogKey(dialog) {
|
|
@@ -4202,8 +4474,33 @@ export class Daemon extends EventEmitter {
|
|
|
4202
4474
|
}
|
|
4203
4475
|
/** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
|
|
4204
4476
|
static dialogMatches(dialog, pane) {
|
|
4477
|
+
dialog.pattern.lastIndex = 0;
|
|
4205
4478
|
return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
|
|
4206
4479
|
}
|
|
4480
|
+
/** Refresh the separate stdin-blocked observation from one pane capture. */
|
|
4481
|
+
updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
|
|
4482
|
+
const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
|
|
4483
|
+
const nextKey = active ? Daemon.dialogKey(active) : null;
|
|
4484
|
+
if (nextKey !== this.inputBlockedDialogKey) {
|
|
4485
|
+
this.inputBlockedDialogKey = nextKey;
|
|
4486
|
+
this.emit("input_blocked", {
|
|
4487
|
+
name: this.name,
|
|
4488
|
+
blocked: active !== null,
|
|
4489
|
+
description: active?.description,
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
if (!active && this.autoResolvedDialogGeneration === this.spawnGeneration) {
|
|
4493
|
+
// The old screen is gone. A later dangerous command in the same spawn
|
|
4494
|
+
// must get its own one-shot answer.
|
|
4495
|
+
this.autoResolvedDialogKey = null;
|
|
4496
|
+
this.autoResolvedDialogGeneration = 0;
|
|
4497
|
+
}
|
|
4498
|
+
return active;
|
|
4499
|
+
}
|
|
4500
|
+
/** Whether a runtime prompt currently owns the pane's stdin. */
|
|
4501
|
+
isInputBlocked() {
|
|
4502
|
+
return this.inputBlockedDialogKey !== null;
|
|
4503
|
+
}
|
|
4207
4504
|
/**
|
|
4208
4505
|
* Tri-state liveness. tmux's own isWindowAlive folds every query failure
|
|
4209
4506
|
* into `false`, so a transient tmux hiccup would read as "the CLI died" —
|
|
@@ -4369,7 +4666,7 @@ export class Daemon extends EventEmitter {
|
|
|
4369
4666
|
const deadline = Date.now() + timeoutMs;
|
|
4370
4667
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4371
4668
|
let unknownStreak = 0;
|
|
4372
|
-
let
|
|
4669
|
+
let transientBudget = null;
|
|
4373
4670
|
for (;;) {
|
|
4374
4671
|
const remaining = deadline - Date.now();
|
|
4375
4672
|
if (remaining <= 0)
|
|
@@ -4382,15 +4679,19 @@ export class Daemon extends EventEmitter {
|
|
|
4382
4679
|
const transient = await this.probeInputTransient();
|
|
4383
4680
|
if (transient.state === "active") {
|
|
4384
4681
|
unknownStreak = 0;
|
|
4385
|
-
|
|
4386
|
-
if (
|
|
4387
|
-
this.logger.error({
|
|
4682
|
+
transientBudget ||= this.transientProgressBudget(deadline);
|
|
4683
|
+
if (!transientBudget.observe(transient.pane)) {
|
|
4684
|
+
this.logger.error({
|
|
4685
|
+
transient: transient.transient.description,
|
|
4686
|
+
stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4687
|
+
ceilingMs: INPUT_TRANSIENT_WAIT_MS,
|
|
4688
|
+
}, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4388
4689
|
return false;
|
|
4389
4690
|
}
|
|
4390
4691
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4391
4692
|
continue;
|
|
4392
4693
|
}
|
|
4393
|
-
|
|
4694
|
+
transientBudget = null;
|
|
4394
4695
|
if (transient.state === "unknown") {
|
|
4395
4696
|
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4396
4697
|
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
@@ -4536,12 +4837,11 @@ export class Daemon extends EventEmitter {
|
|
|
4536
4837
|
* what to fix, and holding would only build a queue that floods the pane the
|
|
4537
4838
|
* moment the flag clears.
|
|
4538
4839
|
*/
|
|
4539
|
-
refuseFatalStartupDelivery(status) {
|
|
4840
|
+
refuseFatalStartupDelivery(verdict, status) {
|
|
4540
4841
|
if (!this.fatalStartupBlocked)
|
|
4541
4842
|
return false;
|
|
4542
4843
|
this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
|
|
4543
|
-
|
|
4544
|
-
this.emit("message_failed", status); // ❌
|
|
4844
|
+
this.failDelivery(verdict, status);
|
|
4545
4845
|
return true;
|
|
4546
4846
|
}
|
|
4547
4847
|
/**
|
|
@@ -4556,7 +4856,7 @@ export class Daemon extends EventEmitter {
|
|
|
4556
4856
|
return true;
|
|
4557
4857
|
if (this.controlClient.isIdle(windowId))
|
|
4558
4858
|
return true;
|
|
4559
|
-
if (this.
|
|
4859
|
+
if (!this.canTrustRedrawIdleState() || this.instanceState !== "idle")
|
|
4560
4860
|
return false;
|
|
4561
4861
|
const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
|
|
4562
4862
|
if (lastOutputAt === undefined || !this.instanceStateMachine)
|
|
@@ -4568,7 +4868,7 @@ export class Daemon extends EventEmitter {
|
|
|
4568
4868
|
waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
|
|
4569
4869
|
if (!this.controlClient)
|
|
4570
4870
|
return Promise.resolve(true);
|
|
4571
|
-
if (this.
|
|
4871
|
+
if (!this.canTrustRedrawIdleState()) {
|
|
4572
4872
|
return this.controlClient.waitUntilIdle(windowId, timeoutMs);
|
|
4573
4873
|
}
|
|
4574
4874
|
if (this.isPaneIdleForDelivery(windowId))
|
|
@@ -4587,6 +4887,20 @@ export class Daemon extends EventEmitter {
|
|
|
4587
4887
|
}, timeoutMs);
|
|
4588
4888
|
});
|
|
4589
4889
|
}
|
|
4890
|
+
/**
|
|
4891
|
+
* Antigravity has always opted into pane-state delivery readiness because its
|
|
4892
|
+
* footer redraws while idle. Codex's structural idle proof is useful only
|
|
4893
|
+
* while the event-driven state monitor is actually running; without that
|
|
4894
|
+
* monitor there is no fresh proof to trust, so preserve the legacy
|
|
4895
|
+
* TmuxControlClient.waitUntilIdle fallback. This matters during isolated
|
|
4896
|
+
* startup/recovery paths as well as in tests: merely having a matcher must
|
|
4897
|
+
* never turn a missing observer into a 30-minute polling wait.
|
|
4898
|
+
*/
|
|
4899
|
+
canTrustRedrawIdleState() {
|
|
4900
|
+
if (this.backend?.hasPeriodicPaneRedraw?.() === true)
|
|
4901
|
+
return true;
|
|
4902
|
+
return this.instanceStateMonitorActive && !!this.backend?.isPeriodicRedrawIdlePane;
|
|
4903
|
+
}
|
|
4590
4904
|
/**
|
|
4591
4905
|
* The write half of a delivery: paste → Enter → confirm, with retries.
|
|
4592
4906
|
*
|
|
@@ -4606,7 +4920,10 @@ export class Daemon extends EventEmitter {
|
|
|
4606
4920
|
}
|
|
4607
4921
|
return sent;
|
|
4608
4922
|
}
|
|
4609
|
-
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId
|
|
4923
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
4924
|
+
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
4925
|
+
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
4926
|
+
verdict = { reached: false }) {
|
|
4610
4927
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
4611
4928
|
let windowId = initialWindowId;
|
|
4612
4929
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
@@ -4625,9 +4942,9 @@ export class Daemon extends EventEmitter {
|
|
|
4625
4942
|
? "pasteBuffer failed — recovering window and backing off"
|
|
4626
4943
|
: "pasteBuffer failed — non-retryable tmux error");
|
|
4627
4944
|
if (!recoverable) {
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
return
|
|
4945
|
+
// A tmux error the window cannot be recovered from: the text will
|
|
4946
|
+
// never reach this pane, so it is a verdict like the others.
|
|
4947
|
+
return this.failDelivery(verdict, status);
|
|
4631
4948
|
}
|
|
4632
4949
|
windowId = (await this.recoverWindow()) ?? windowId;
|
|
4633
4950
|
if (attempt < maxAttempts)
|
|
@@ -4657,9 +4974,7 @@ export class Daemon extends EventEmitter {
|
|
|
4657
4974
|
}
|
|
4658
4975
|
let enterAt = Date.now();
|
|
4659
4976
|
if (!(await this.sendDeliveryEnter("initial-submit"))) {
|
|
4660
|
-
|
|
4661
|
-
this.emit("message_failed", status); // ❌
|
|
4662
|
-
return false;
|
|
4977
|
+
return this.failDelivery(verdict, status);
|
|
4663
4978
|
}
|
|
4664
4979
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
4665
4980
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
@@ -4742,9 +5057,7 @@ export class Daemon extends EventEmitter {
|
|
|
4742
5057
|
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
4743
5058
|
const strandedAt = Date.now();
|
|
4744
5059
|
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
4745
|
-
|
|
4746
|
-
this.emit("message_failed", status); // ❌
|
|
4747
|
-
return false;
|
|
5060
|
+
return this.failDelivery(verdict, status);
|
|
4748
5061
|
}
|
|
4749
5062
|
const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4750
5063
|
if (afterEnter === "submitted") {
|
|
@@ -4759,9 +5072,7 @@ export class Daemon extends EventEmitter {
|
|
|
4759
5072
|
// message nobody submitted. Output is corroboration; text sitting in
|
|
4760
5073
|
// the input row is disqualifying, and disqualifying evidence wins.
|
|
4761
5074
|
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
4762
|
-
|
|
4763
|
-
this.emit("message_failed", status); // ❌
|
|
4764
|
-
return false;
|
|
5075
|
+
return this.failDelivery(verdict, status);
|
|
4765
5076
|
}
|
|
4766
5077
|
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
4767
5078
|
// so pasting it again cannot duplicate anything.
|
|
@@ -4770,16 +5081,12 @@ export class Daemon extends EventEmitter {
|
|
|
4770
5081
|
this.logger.error({
|
|
4771
5082
|
tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
|
|
4772
5083
|
}, "Idle-gated redelivery paste failed after native-queue silent loss");
|
|
4773
|
-
|
|
4774
|
-
this.emit("message_failed", status); // ❌
|
|
4775
|
-
return false;
|
|
5084
|
+
return this.failDelivery(verdict, status);
|
|
4776
5085
|
}
|
|
4777
5086
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
4778
5087
|
const retryAt = Date.now();
|
|
4779
5088
|
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
|
|
4780
|
-
|
|
4781
|
-
this.emit("message_failed", status); // ❌
|
|
4782
|
-
return false;
|
|
5089
|
+
return this.failDelivery(verdict, status);
|
|
4783
5090
|
}
|
|
4784
5091
|
if (windowId && this.controlClient) {
|
|
4785
5092
|
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
@@ -4794,9 +5101,7 @@ export class Daemon extends EventEmitter {
|
|
|
4794
5101
|
return true;
|
|
4795
5102
|
}
|
|
4796
5103
|
this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
|
|
4797
|
-
|
|
4798
|
-
this.emit("message_failed", status); // ❌
|
|
4799
|
-
return false;
|
|
5104
|
+
return this.failDelivery(verdict, status);
|
|
4800
5105
|
}
|
|
4801
5106
|
if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
|
|
4802
5107
|
// F2: output after Enter is necessary but not sufficient — the paste
|
|
@@ -4810,9 +5115,7 @@ export class Daemon extends EventEmitter {
|
|
|
4810
5115
|
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
4811
5116
|
const retryAt = Date.now();
|
|
4812
5117
|
if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
|
|
4813
|
-
|
|
4814
|
-
this.emit("message_failed", status); // ❌
|
|
4815
|
-
return false;
|
|
5118
|
+
return this.failDelivery(verdict, status);
|
|
4816
5119
|
}
|
|
4817
5120
|
submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
|
|
4818
5121
|
}
|
|
@@ -4822,9 +5125,7 @@ export class Daemon extends EventEmitter {
|
|
|
4822
5125
|
}
|
|
4823
5126
|
else {
|
|
4824
5127
|
this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
|
|
4825
|
-
|
|
4826
|
-
this.emit("message_failed", status); // ❌
|
|
4827
|
-
return false;
|
|
5128
|
+
return this.failDelivery(verdict, status);
|
|
4828
5129
|
}
|
|
4829
5130
|
}
|
|
4830
5131
|
else if (windowId && this.controlClient) {
|
|
@@ -4849,9 +5150,7 @@ export class Daemon extends EventEmitter {
|
|
|
4849
5150
|
// forever and the next delivery pasted on top — submitting two messages
|
|
4850
5151
|
// as one. Say so instead.
|
|
4851
5152
|
this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
|
|
4852
|
-
|
|
4853
|
-
this.emit("message_failed", status); // ❌
|
|
4854
|
-
return false;
|
|
5153
|
+
return this.failDelivery(verdict, status);
|
|
4855
5154
|
}
|
|
4856
5155
|
}
|
|
4857
5156
|
else {
|
|
@@ -4864,9 +5163,7 @@ export class Daemon extends EventEmitter {
|
|
|
4864
5163
|
return true;
|
|
4865
5164
|
}
|
|
4866
5165
|
this.logger.error("Message delivery failed after retries — window not ready");
|
|
4867
|
-
|
|
4868
|
-
this.emit("message_failed", status); // ❌
|
|
4869
|
-
return false;
|
|
5166
|
+
return this.failDelivery(verdict, status);
|
|
4870
5167
|
}
|
|
4871
5168
|
/**
|
|
4872
5169
|
* The single place a pasted message is judged submitted, shared by the
|
|
@@ -5327,7 +5624,12 @@ export class Daemon extends EventEmitter {
|
|
|
5327
5624
|
this.ipcServer?.broadcast({
|
|
5328
5625
|
type: typeMap[tool],
|
|
5329
5626
|
payload: args,
|
|
5330
|
-
meta: {
|
|
5627
|
+
meta: {
|
|
5628
|
+
chat_id: this.lastChatId,
|
|
5629
|
+
thread_id: this.lastThreadId,
|
|
5630
|
+
adapter_id: this.lastAdapterId,
|
|
5631
|
+
instance_name: this.name,
|
|
5632
|
+
},
|
|
5331
5633
|
fleetRequestId: fleetReqId,
|
|
5332
5634
|
});
|
|
5333
5635
|
return;
|
|
@@ -5517,10 +5819,17 @@ export class Daemon extends EventEmitter {
|
|
|
5517
5819
|
// profile instead of `full`. Every tool's schema is resent on every API call,
|
|
5518
5820
|
// so full (44 tools) costs a general ~22k chars per turn — the single largest
|
|
5519
5821
|
// slice of its prompt. Explicit config still wins.
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5822
|
+
// One rule for "is this a general", shared with everywhere else that asks.
|
|
5823
|
+
// The daemon used to read `general_topic` alone while `isGeneralInstance`
|
|
5824
|
+
// also accepted the name, so an instance called `general` without the flag
|
|
5825
|
+
// was a worker here and a general everywhere else.
|
|
5826
|
+
const isGeneral = this.config.general_topic === true || this.name === "general";
|
|
5827
|
+
// No third argument for an ordinary instance: the default is `worker` now,
|
|
5828
|
+
// and naming it here would be a second place to change it.
|
|
5829
|
+
const toolSet = isGeneral
|
|
5830
|
+
? resolveToolSet(this.config, this.name, "general")
|
|
5831
|
+
: resolveToolSet(this.config, this.name);
|
|
5832
|
+
mcpEnv.AGEND_TOOL_SET = toolSet;
|
|
5524
5833
|
if (this.config.display_name)
|
|
5525
5834
|
mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
|
|
5526
5835
|
if (this.config.description)
|
|
@@ -5953,6 +6262,10 @@ export class Daemon extends EventEmitter {
|
|
|
5953
6262
|
this.startupAborted = true;
|
|
5954
6263
|
this.freezeRuntimeMonitors();
|
|
5955
6264
|
this.pendingIpcRequests.clear();
|
|
6265
|
+
if (this.museUsageRelay) {
|
|
6266
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6267
|
+
this.museUsageRelay = null;
|
|
6268
|
+
}
|
|
5956
6269
|
try {
|
|
5957
6270
|
await this.killProcessTree();
|
|
5958
6271
|
}
|
|
@@ -6008,6 +6321,32 @@ export class Daemon extends EventEmitter {
|
|
|
6008
6321
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
6009
6322
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
6010
6323
|
}
|
|
6324
|
+
/**
|
|
6325
|
+
* A relay crash must not make the daemon kill a live Muse process. The
|
|
6326
|
+
* supervisor normally reclaims the same port; when that bounded recovery
|
|
6327
|
+
* fails, mark usage unavailable and leave Muse untouched. A later natural
|
|
6328
|
+
* spawn sees museRelayFallback and starts direct, without interrupting the
|
|
6329
|
+
* current conversation or destroying its session.
|
|
6330
|
+
*/
|
|
6331
|
+
switchMuseToDirect() {
|
|
6332
|
+
if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
|
|
6333
|
+
return this.museRelayFallbackInFlight ?? Promise.resolve();
|
|
6334
|
+
}
|
|
6335
|
+
this.museRelayFallbackInFlight = (async () => {
|
|
6336
|
+
this.museRelayFallback = true;
|
|
6337
|
+
this.logger.warn("Muse usage relay could not recover — usage is unavailable until the next natural Muse restart");
|
|
6338
|
+
try {
|
|
6339
|
+
if (this.museUsageRelay) {
|
|
6340
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6341
|
+
this.museUsageRelay = null;
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
catch (err) {
|
|
6345
|
+
this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
|
|
6346
|
+
}
|
|
6347
|
+
})().finally(() => { this.museRelayFallbackInFlight = null; });
|
|
6348
|
+
return this.museRelayFallbackInFlight;
|
|
6349
|
+
}
|
|
6011
6350
|
/**
|
|
6012
6351
|
* Tell the agent its instructions moved, once the CLI is idle.
|
|
6013
6352
|
*
|
|
@@ -6143,7 +6482,30 @@ export class Daemon extends EventEmitter {
|
|
|
6143
6482
|
if (backendConfig.agentMode === "cli" && backendConfig.agentPort) {
|
|
6144
6483
|
envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`;
|
|
6145
6484
|
}
|
|
6146
|
-
|
|
6485
|
+
// Muse's usage relay must be listening before its command is assembled.
|
|
6486
|
+
// Preparation is best-effort: if the loopback port cannot be bound, leave
|
|
6487
|
+
// museBaseUrl unset and let Muse connect directly so usage never blocks a
|
|
6488
|
+
// conversation.
|
|
6489
|
+
let launchConfig = backendConfig;
|
|
6490
|
+
if (this.backend?.binaryName === "muse" && !this.museRelayFallback) {
|
|
6491
|
+
try {
|
|
6492
|
+
this.museUsageRelay ??= new MuseUsageRelay({
|
|
6493
|
+
instanceDir: this.instanceDir,
|
|
6494
|
+
onUnavailable: () => { void this.switchMuseToDirect(); },
|
|
6495
|
+
});
|
|
6496
|
+
const baseUrl = await this.museUsageRelay.start();
|
|
6497
|
+
launchConfig = { ...backendConfig, museBaseUrl: baseUrl };
|
|
6498
|
+
}
|
|
6499
|
+
catch (err) {
|
|
6500
|
+
this.logger.warn({ reason: err instanceof Error ? err.message : "relay unavailable" }, "Muse usage relay unavailable — using direct connection");
|
|
6501
|
+
if (this.museUsageRelay) {
|
|
6502
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6503
|
+
this.museUsageRelay = null;
|
|
6504
|
+
}
|
|
6505
|
+
clearMuseUsageSnapshot(this.instanceDir);
|
|
6506
|
+
}
|
|
6507
|
+
}
|
|
6508
|
+
const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
6147
6509
|
// Ensure tmux session exists (may have been destroyed if all windows died)
|
|
6148
6510
|
await TmuxManager.ensureSession(this.tmuxSessionName);
|
|
6149
6511
|
if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
|
|
@@ -6579,6 +6941,30 @@ export class Daemon extends EventEmitter {
|
|
|
6579
6941
|
}, "Snapshot written");
|
|
6580
6942
|
return snapshot;
|
|
6581
6943
|
}
|
|
6944
|
+
/**
|
|
6945
|
+
* The credential profile's store this instance's CLI writes to, if any.
|
|
6946
|
+
*
|
|
6947
|
+
* The conversation lives in the same file as the login, so an instance on a
|
|
6948
|
+
* profile keeps its transcript there too — reading the shared store would
|
|
6949
|
+
* follow whichever *other* agent happens to be talking, or find nothing.
|
|
6950
|
+
* Built with the same `getAgendHome()` the launch prefix uses, or the two
|
|
6951
|
+
* would disagree about where the profile is.
|
|
6952
|
+
*/
|
|
6953
|
+
credentialProfileStore() {
|
|
6954
|
+
const backendName = this.config.backend ?? "claude-code";
|
|
6955
|
+
let profile;
|
|
6956
|
+
try {
|
|
6957
|
+
profile = resolveCredentialProfile(this.config.backend_options?.[backendName]);
|
|
6958
|
+
}
|
|
6959
|
+
catch {
|
|
6960
|
+
// A malformed name is refused where it is written; a transcript reader
|
|
6961
|
+
// must not take the instance down over it.
|
|
6962
|
+
return undefined;
|
|
6963
|
+
}
|
|
6964
|
+
if (!profile)
|
|
6965
|
+
return undefined;
|
|
6966
|
+
return credentialProfileStoreHome(getAgendHome(), backendName, profile);
|
|
6967
|
+
}
|
|
6582
6968
|
/** Collect ring buffer data for handover to a replacement instance. */
|
|
6583
6969
|
collectHandoverContext() {
|
|
6584
6970
|
const lines = [];
|