@songsid/agend 2.1.4-beta.9 → 2.1.4
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/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +2 -2
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +12 -8
- package/dist/backend/antigravity.js +189 -42
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +87 -23
- package/dist/backend/claude-code.js +361 -46
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +139 -5
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.js +2 -2
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.js +2 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +52 -2
- package/dist/backend/kiro.js +267 -12
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.js +17 -4
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +122 -3
- package/dist/backend/types.js +5 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-outage.d.ts +61 -0
- package/dist/backend-outage.js +71 -0
- package/dist/backend-outage.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +44 -2
- package/dist/channel/adapters/discord.js +523 -90
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +30 -0
- package/dist/channel/adapters/telegram.js +221 -19
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/agy-mcp-launcher.d.ts +2 -0
- package/dist/channel/agy-mcp-launcher.js +28 -0
- package/dist/channel/agy-mcp-launcher.js.map +1 -0
- package/dist/channel/ipc-bridge.d.ts +2 -1
- package/dist/channel/ipc-bridge.js +33 -4
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +42 -0
- package/dist/channel/markdown-chunk.js +205 -0
- package/dist/channel/markdown-chunk.js.map +1 -0
- package/dist/channel/mcp-server.js +34 -5
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js +64 -8
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/types.d.ts +31 -1
- package/dist/classic-channel-manager.d.ts +65 -0
- package/dist/classic-channel-manager.js +172 -3
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +124 -83
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +13 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +6 -0
- package/dist/cross-instance-envelope.js +30 -0
- package/dist/cross-instance-envelope.js.map +1 -0
- package/dist/daemon.d.ts +491 -14
- package/dist/daemon.js +2121 -276
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +41 -0
- package/dist/doctor.js +267 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fleet-context.d.ts +63 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +402 -11
- package/dist/fleet-manager.js +2594 -422
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-yaml-slim.d.ts +10 -0
- package/dist/fleet-yaml-slim.js +59 -0
- package/dist/fleet-yaml-slim.js.map +1 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +63 -16
- package/dist/general-knowledge/skills/session-management/SKILL.md +171 -8
- package/dist/instance-lifecycle.d.ts +114 -1
- package/dist/instance-lifecycle.js +404 -14
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +42 -4
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +1024 -320
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +91 -0
- package/dist/login-flows.js +170 -0
- package/dist/login-flows.js.map +1 -0
- package/dist/login-manager.d.ts +63 -0
- package/dist/login-manager.js +134 -0
- package/dist/login-manager.js.map +1 -0
- package/dist/network-family.d.ts +18 -0
- package/dist/network-family.js +20 -0
- package/dist/network-family.js.map +1 -0
- package/dist/outbound-handlers.d.ts +16 -1
- package/dist/outbound-handlers.js +174 -40
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +10 -3
- package/dist/outbound-schemas.js +10 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +52 -0
- package/dist/pane-input-residue.js +107 -0
- package/dist/pane-input-residue.js.map +1 -0
- package/dist/process-memory.d.ts +6 -0
- package/dist/process-memory.js +45 -0
- package/dist/process-memory.js.map +1 -1
- package/dist/reply-dedup.d.ts +7 -8
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-progress.d.ts +2 -0
- package/dist/restart-progress.js +3 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +12 -0
- package/dist/scheduler/db.js +59 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-installer.d.ts +11 -0
- package/dist/service-installer.js +84 -18
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +30 -0
- package/dist/spawn-gate.js +79 -0
- package/dist/spawn-gate.js.map +1 -0
- package/dist/steer-capability.d.ts +1 -0
- package/dist/steer-capability.js +6 -0
- package/dist/steer-capability.js.map +1 -0
- package/dist/storm-window.d.ts +83 -0
- package/dist/storm-window.js +251 -0
- package/dist/storm-window.js.map +1 -0
- package/dist/tips.d.ts +44 -0
- package/dist/tips.js +355 -0
- package/dist/tips.js.map +1 -0
- package/dist/tmux-manager.d.ts +16 -0
- package/dist/tmux-manager.js +110 -25
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +31 -7
- package/dist/topic-commands.js +342 -70
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +35 -10
- package/dist/transcript-sources.js +190 -38
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +200 -60
- package/dist/ui/view.html +147 -30
- package/dist/usage/format-rich.d.ts +1 -1
- package/dist/usage/format-rich.js +28 -24
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +7 -0
- package/dist/usage/i18n-keys.js +34 -0
- package/dist/usage/i18n-keys.js.map +1 -0
- package/dist/usage/i18n.d.ts +5 -0
- package/dist/usage/i18n.js +27 -0
- package/dist/usage/i18n.js.map +1 -0
- package/dist/usage/providers.d.ts +21 -0
- package/dist/usage/providers.js +153 -75
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +11 -3
- package/dist/usage/usage-api.js +61 -24
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@ import { join, basename, dirname, resolve, sep as pathSep } from "node:path";
|
|
|
4
4
|
import { access, unlink } from "node:fs/promises";
|
|
5
5
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
6
6
|
import { DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
7
|
-
import { sanitizeInstanceName } from "./topic-commands.js";
|
|
7
|
+
import { readStatuslineModel, sanitizeInstanceName } from "./topic-commands.js";
|
|
8
8
|
import { isModelCompatible } from "./backend/types.js";
|
|
9
9
|
import { safeHandler } from "./safe-async.js";
|
|
10
10
|
import { t } from "./locale.js";
|
|
@@ -12,13 +12,17 @@ import { clearPausedMarker, hasPausedMarker, readPausedAt, writePausedMarker } f
|
|
|
12
12
|
import { reportProviderRateLimit } from "./usage/provider-alerts.js";
|
|
13
13
|
import { isFleetStartCommandLine } from "./fleet-lock.js";
|
|
14
14
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
15
|
+
import { BACKEND_AUTH_CHECKS, checkAuthStatus, LOGIN_FLOWS } from "./login-flows.js";
|
|
16
|
+
import { fetchClaudeUsage, fetchCodexUsage } from "./usage/providers.js";
|
|
15
17
|
export { isFleetStartCommandLine } from "./fleet-lock.js";
|
|
16
18
|
/** Shared CLI metadata used by startup validation and ClassicBot onboarding. */
|
|
17
19
|
export const BACKEND_INSTALLATION_INFO = {
|
|
18
20
|
"claude-code": { binary: "claude", install: "curl -fsSL https://claude.ai/install.sh | bash" },
|
|
19
21
|
"gemini-cli": { binary: "gemini", install: "npm i -g @google/gemini-cli" },
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// The curl installer serves Linux and macOS (verified: 200, text/x-shellscript);
|
|
23
|
+
// the previous `brew install --cask` form only worked on macOS.
|
|
24
|
+
"kiro-cli": { binary: "kiro-cli", install: "curl -fsSL https://cli.kiro.dev/install | bash" },
|
|
25
|
+
codex: { binary: "codex", install: "curl -fsSL https://chatgpt.com/codex/install.sh | sh" },
|
|
22
26
|
opencode: { binary: "opencode", install: "curl -fsSL https://opencode.ai/install | bash" },
|
|
23
27
|
antigravity: { binary: "agy", install: "curl -fsSL https://antigravity.google/cli/install.sh | bash" },
|
|
24
28
|
grok: { binary: "grok", install: "curl -fsSL https://x.ai/cli/install.sh | bash" },
|
|
@@ -77,12 +81,116 @@ export function getUnsafeInstanceDaemonPidReason(pid, dataDir) {
|
|
|
77
81
|
}
|
|
78
82
|
/** Suppress duplicate auth alerts for the same backend within this window. */
|
|
79
83
|
const AUTH_ALERT_COOLDOWN_MS = 5 * 60_000;
|
|
84
|
+
/**
|
|
85
|
+
* Reuse one auth verification per backend for this long. Shared credentials
|
|
86
|
+
* expiring makes EVERY instance of the backend emit auth_error at once — one
|
|
87
|
+
* token-free check answers for all of them.
|
|
88
|
+
*/
|
|
89
|
+
const AUTH_VERIFY_CACHE_MS = 60_000;
|
|
90
|
+
const CODEX_QUOTA_VERIFY_TIMEOUT_MS = 5_000;
|
|
91
|
+
const CLAUDE_QUOTA_VERIFY_TIMEOUT_MS = 5_000;
|
|
92
|
+
/**
|
|
93
|
+
* Convert the live Codex usage row into a conservative quota verdict. A
|
|
94
|
+
* successful response with at least one window below 100% proves that stale
|
|
95
|
+
* terminal text is no longer current only when no other window is exhausted.
|
|
96
|
+
* Missing credentials, API errors, and metric-less responses prove nothing.
|
|
97
|
+
*/
|
|
98
|
+
export function codexQuotaVerdictFromUsage(usage) {
|
|
99
|
+
if (usage.status !== "ok")
|
|
100
|
+
return "unknown";
|
|
101
|
+
const windows = usage.metrics.filter(metric => metric.type === "percent"
|
|
102
|
+
&& typeof metric.used === "number"
|
|
103
|
+
&& Number.isFinite(metric.used)
|
|
104
|
+
&& metric.windowMs != null);
|
|
105
|
+
if (windows.length === 0)
|
|
106
|
+
return "unknown";
|
|
107
|
+
return windows.some(metric => (metric.used ?? 0) >= 100) ? "exhausted" : "available";
|
|
108
|
+
}
|
|
109
|
+
/** Run only the Codex usage provider, bounded independently of its network timeout. */
|
|
110
|
+
export async function verifyCodexQuotaStatus(fetchUsage = fetchCodexUsage, timeoutMs = CODEX_QUOTA_VERIFY_TIMEOUT_MS) {
|
|
111
|
+
let timer;
|
|
112
|
+
try {
|
|
113
|
+
const usage = await Promise.race([
|
|
114
|
+
fetchUsage(),
|
|
115
|
+
new Promise((_, reject) => {
|
|
116
|
+
timer = setTimeout(() => reject(new Error("Codex quota verification timed out")), timeoutMs);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
return codexQuotaVerdictFromUsage(usage);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Convert a LIVE Claude usage API row into a conservative verdict. The
|
|
132
|
+
* provider can degrade to statusline data when the network/API is unavailable;
|
|
133
|
+
* that fallback is intentionally unknown because stale local limits cannot
|
|
134
|
+
* disprove a current API credit-balance error.
|
|
135
|
+
*/
|
|
136
|
+
export function claudeQuotaVerdictFromUsage(usage) {
|
|
137
|
+
if (usage.status !== "ok" || /statusline/i.test(usage.hint ?? ""))
|
|
138
|
+
return "unknown";
|
|
139
|
+
const bounded = usage.metrics.filter(metric => {
|
|
140
|
+
if (typeof metric.used !== "number" || !Number.isFinite(metric.used))
|
|
141
|
+
return false;
|
|
142
|
+
if (metric.type === "percent")
|
|
143
|
+
return true;
|
|
144
|
+
return metric.type === "dollars"
|
|
145
|
+
&& typeof metric.limit === "number"
|
|
146
|
+
&& Number.isFinite(metric.limit)
|
|
147
|
+
&& metric.limit > 0;
|
|
148
|
+
});
|
|
149
|
+
if (bounded.length === 0)
|
|
150
|
+
return "unknown";
|
|
151
|
+
const exhausted = bounded.some(metric => metric.type === "percent"
|
|
152
|
+
? (metric.used ?? 0) >= 100
|
|
153
|
+
: (metric.used ?? 0) >= (metric.limit ?? Number.POSITIVE_INFINITY));
|
|
154
|
+
return exhausted ? "exhausted" : "available";
|
|
155
|
+
}
|
|
156
|
+
/** Run only the Claude usage provider, bounded independently of its network timeout. */
|
|
157
|
+
export async function verifyClaudeQuotaStatus(fetchUsage = fetchClaudeUsage, timeoutMs = CLAUDE_QUOTA_VERIFY_TIMEOUT_MS) {
|
|
158
|
+
let timer;
|
|
159
|
+
try {
|
|
160
|
+
const usage = await Promise.race([
|
|
161
|
+
fetchUsage(),
|
|
162
|
+
new Promise((_, reject) => {
|
|
163
|
+
timer = setTimeout(() => reject(new Error("Claude quota verification timed out")), timeoutMs);
|
|
164
|
+
timer.unref?.();
|
|
165
|
+
}),
|
|
166
|
+
]);
|
|
167
|
+
return claudeQuotaVerdictFromUsage(usage);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return "unknown";
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (timer)
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
80
177
|
export class InstanceLifecycle {
|
|
81
178
|
ctx;
|
|
82
179
|
/** Active daemon processes: instanceName → Daemon */
|
|
83
180
|
daemons = new Map();
|
|
84
181
|
/** backend → last auth-error alert time, so one expiry sends one alert. */
|
|
85
182
|
lastAuthAlertAt = new Map();
|
|
183
|
+
/**
|
|
184
|
+
* backend → cached token-free auth verification (see AUTH_VERIFY_CACHE_MS).
|
|
185
|
+
* The PROMISE is cached, not the result: a shared credential expiring makes
|
|
186
|
+
* every instance of the backend fire in the same tick, and they must join
|
|
187
|
+
* one in-flight check instead of each spawning their own.
|
|
188
|
+
*/
|
|
189
|
+
authVerifyCache = new Map();
|
|
190
|
+
/** Same-tick Codex quota alerts join one live usage probe. */
|
|
191
|
+
codexQuotaVerifyInFlight = null;
|
|
192
|
+
/** Same-tick Claude quota alerts join one live usage probe. */
|
|
193
|
+
claudeQuotaVerifyInFlight = null;
|
|
86
194
|
/**
|
|
87
195
|
* Minimum gap between MCP-revival auto-restarts of one instance. Kept here —
|
|
88
196
|
* not in the daemon — because each restart replaces the daemon object, which
|
|
@@ -108,12 +216,37 @@ export class InstanceLifecycle {
|
|
|
108
216
|
* suppresses the chat message, not the record. A crash outside a planned
|
|
109
217
|
* restart notifies exactly as before.
|
|
110
218
|
*/
|
|
219
|
+
/** @returns whether the notice was actually dispatched (false when suppressed). */
|
|
111
220
|
notifyIncident(name, kind, text) {
|
|
112
221
|
if (this.ctx.isPlannedRestart()) {
|
|
113
222
|
this.ctx.logger.info({ name, kind }, "Incident notification suppressed — planned restart in progress");
|
|
114
|
-
return;
|
|
223
|
+
return false;
|
|
115
224
|
}
|
|
116
|
-
this.ctx.
|
|
225
|
+
if (this.ctx.stormSuppressed?.(kind)) {
|
|
226
|
+
this.ctx.logger.info({ name, kind }, "Incident notification suppressed — included in tmux storm summary");
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
// A production `false` (no adapter / no route) means nobody saw it — it
|
|
230
|
+
// must not count as dispatched, or a later retraction would be an orphan.
|
|
231
|
+
const dispatched = this.ctx.notifyInstanceTopic(name, text);
|
|
232
|
+
return dispatched !== false;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Per-instance MCP incident fence. The mcp_died handler awaits an auth probe
|
|
236
|
+
* (up to 5s) before it notifies; a recovery that lands inside that window
|
|
237
|
+
* bumps the generation so the late death handler sends nothing — otherwise
|
|
238
|
+
* the user would see "retracted" followed by the stale red alarm. The
|
|
239
|
+
* retraction itself is only sent when a death notice really reached the
|
|
240
|
+
* user (not when it was storm- or planned-restart-suppressed).
|
|
241
|
+
*/
|
|
242
|
+
mcpIncidents = new Map();
|
|
243
|
+
mcpIncident(name) {
|
|
244
|
+
let s = this.mcpIncidents.get(name);
|
|
245
|
+
if (!s) {
|
|
246
|
+
s = { gen: 0, deathNoticeSent: false };
|
|
247
|
+
this.mcpIncidents.set(name, s);
|
|
248
|
+
}
|
|
249
|
+
return s;
|
|
117
250
|
}
|
|
118
251
|
/** Backend a running instance uses (config → fleet default). */
|
|
119
252
|
backendOf(name) {
|
|
@@ -121,6 +254,67 @@ export class InstanceLifecycle {
|
|
|
121
254
|
?? this.ctx.fleetConfig?.defaults?.backend
|
|
122
255
|
?? "claude-code";
|
|
123
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Confirm a pane-detected auth error with the backend's token-free status
|
|
259
|
+
* probe before pausing anything: pattern matching over terminal text also
|
|
260
|
+
* fires on an agent DISCUSSING a 401. "valid" means false positive — ignore.
|
|
261
|
+
* "invalid" and "unknown" (timeout, missing binary) both pause: for a
|
|
262
|
+
* suspected expiry, pausing too much is recoverable, delivering into a dead
|
|
263
|
+
* CLI is not. Cached per backend so simultaneous alerts run one check.
|
|
264
|
+
*/
|
|
265
|
+
verifyAuthError(name) {
|
|
266
|
+
return this.verifyBackendAuth(this.backendOf(name));
|
|
267
|
+
}
|
|
268
|
+
verifyCodexQuota() {
|
|
269
|
+
if (this.codexQuotaVerifyInFlight)
|
|
270
|
+
return this.codexQuotaVerifyInFlight;
|
|
271
|
+
const verify = this.ctx.verifyCodexQuota ?? (() => verifyCodexQuotaStatus());
|
|
272
|
+
const promise = verify().catch(() => "unknown");
|
|
273
|
+
this.codexQuotaVerifyInFlight = promise;
|
|
274
|
+
void promise.finally(() => {
|
|
275
|
+
if (this.codexQuotaVerifyInFlight === promise)
|
|
276
|
+
this.codexQuotaVerifyInFlight = null;
|
|
277
|
+
});
|
|
278
|
+
return promise;
|
|
279
|
+
}
|
|
280
|
+
verifyClaudeQuota() {
|
|
281
|
+
if (this.claudeQuotaVerifyInFlight)
|
|
282
|
+
return this.claudeQuotaVerifyInFlight;
|
|
283
|
+
const verify = this.ctx.verifyClaudeQuota ?? (() => verifyClaudeQuotaStatus());
|
|
284
|
+
const promise = verify().catch(() => "unknown");
|
|
285
|
+
this.claudeQuotaVerifyInFlight = promise;
|
|
286
|
+
void promise.finally(() => {
|
|
287
|
+
if (this.claudeQuotaVerifyInFlight === promise)
|
|
288
|
+
this.claudeQuotaVerifyInFlight = null;
|
|
289
|
+
});
|
|
290
|
+
return promise;
|
|
291
|
+
}
|
|
292
|
+
/** Same verification keyed by backend (used by startup pre-flight priming). */
|
|
293
|
+
verifyBackendAuth(backend) {
|
|
294
|
+
const cached = this.authVerifyCache.get(backend);
|
|
295
|
+
if (cached && Date.now() - cached.at < AUTH_VERIFY_CACHE_MS)
|
|
296
|
+
return cached.promise;
|
|
297
|
+
const check = LOGIN_FLOWS[backend]?.authCheck ?? BACKEND_AUTH_CHECKS[backend];
|
|
298
|
+
const promise = check ? checkAuthStatus(check) : Promise.resolve("unknown");
|
|
299
|
+
this.authVerifyCache.set(backend, { at: Date.now(), promise });
|
|
300
|
+
return promise;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Startup pre-flight: warm the per-backend auth verification cache so the
|
|
304
|
+
* detectors that fire seconds later (login-screen scan, MCP-died gate) get an
|
|
305
|
+
* instant answer. Advisory only — a pre-flight result alone never pauses
|
|
306
|
+
* anything, because e.g. codex on a custom provider runs fine while
|
|
307
|
+
* `codex login status` reports logged out (live-verified on this fleet).
|
|
308
|
+
*/
|
|
309
|
+
primeAuthVerification(backends) {
|
|
310
|
+
for (const backend of new Set(backends)) {
|
|
311
|
+
void this.verifyBackendAuth(backend).then(result => {
|
|
312
|
+
if (result === "invalid") {
|
|
313
|
+
this.ctx.logger.warn({ backend }, "Pre-flight auth check failed — marking backend as auth-suspect");
|
|
314
|
+
}
|
|
315
|
+
}).catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
124
318
|
/**
|
|
125
319
|
* One alert per backend per cooldown, naming every affected instance — a CLI's
|
|
126
320
|
* credentials are shared, so N instances failing is ONE problem with ONE fix
|
|
@@ -141,7 +335,30 @@ export class InstanceLifecycle {
|
|
|
141
335
|
const scope = others.length
|
|
142
336
|
? `${affected.length} instances on \`${backend}\`: ${affected.join(", ")}`
|
|
143
337
|
: `\`${name}\` (${backend})`;
|
|
144
|
-
|
|
338
|
+
const remedy = backend === "opencode"
|
|
339
|
+
? "Run `opencode auth login` in a terminal, then wake the affected instance(s)."
|
|
340
|
+
: `Use \`/login ${backend}\` to re-login remotely.`;
|
|
341
|
+
this.notifyIncident(notificationTarget, "auth_error", `🔑 ${message}\n\nAffects ${scope}. Credentials are shared per backend — one re-login restores all of them; affected instances pause until then. ${remedy}`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A fleet-wide backend outage (see backend-outage.ts): record the sighting so
|
|
345
|
+
* startup stops spending `--resume` attempts on it, and notify ONCE per
|
|
346
|
+
* outage at fleet level — kiro prints the line every 10s on every instance,
|
|
347
|
+
* so per-instance incidents would be N × (one message per cooldown).
|
|
348
|
+
*/
|
|
349
|
+
noteBackendOutage(name, message, notificationTarget) {
|
|
350
|
+
const backend = this.backendOf(name);
|
|
351
|
+
const result = this.ctx.backendOutage?.record(backend, name, message);
|
|
352
|
+
this.ctx.eventLog?.insert(name, "backend_unreachable", { backend, message });
|
|
353
|
+
if (result && !result.isNew) {
|
|
354
|
+
this.ctx.logger.info({ name, backend }, "backend outage sighting (already alerted)");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const text = t("inst.backend_unreachable", backend, message);
|
|
358
|
+
if (this.ctx.notifyFleetError)
|
|
359
|
+
this.ctx.notifyFleetError(text);
|
|
360
|
+
else if (notificationTarget)
|
|
361
|
+
this.notifyIncident(notificationTarget, "pty_error", text);
|
|
145
362
|
}
|
|
146
363
|
/**
|
|
147
364
|
* System errors from a ClassicBot belong in the operator's General topic,
|
|
@@ -179,6 +396,10 @@ export class InstanceLifecycle {
|
|
|
179
396
|
this.ctx.logger.info({ name }, "Hang notification suppressed — planned restart in progress");
|
|
180
397
|
return;
|
|
181
398
|
}
|
|
399
|
+
if (this.ctx.stormSuppressed?.("hang")) {
|
|
400
|
+
this.ctx.logger.info({ name }, "Hang notification suppressed — included in tmux storm summary");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
182
403
|
// Check if instance has claimed tasks — nudge it to continue
|
|
183
404
|
const claimedTasks = this.ctx.listClaimedTasks(name);
|
|
184
405
|
if (claimedTasks.length > 0) {
|
|
@@ -207,6 +428,24 @@ export class InstanceLifecycle {
|
|
|
207
428
|
this.notifyIncident(generalName, "crash_respawn", t("inst.crashed_respawned_log", name));
|
|
208
429
|
}
|
|
209
430
|
}, this.ctx.logger, `daemon.crash_respawn[${name}]`));
|
|
431
|
+
daemon.on("startup_backend_unreachable", safeHandler(async (data) => {
|
|
432
|
+
// The daemon's crash-respawn hit the backend outage and paused itself.
|
|
433
|
+
// Stop it cleanly (session kept) and let the fleet's delayed retry bring
|
|
434
|
+
// it back once the backend answers — instead of `crashed` forever.
|
|
435
|
+
this.ctx.eventLog?.insert(name, "startup_backend_unreachable", { backend: data.backend });
|
|
436
|
+
this.ctx.logger.warn({ name, backend: data.backend }, "Respawn blocked by backend outage — stopping the daemon for a delayed startup retry");
|
|
437
|
+
if (this.ctx.handOffToStartupRetry) {
|
|
438
|
+
await this.ctx.handOffToStartupRetry(name, daemon);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// No fleet coordinator: stop only if this daemon is still the registered one.
|
|
442
|
+
if (await this.stopIfCurrent(name, daemon))
|
|
443
|
+
this.ctx.scheduleStartupRetry?.(name, 0);
|
|
444
|
+
}, this.ctx.logger, `daemon.startup_backend_unreachable[${name}]`));
|
|
445
|
+
daemon.on("tmux_server_crash", safeHandler(() => {
|
|
446
|
+
this.ctx.eventLog?.insert(name, "tmux_server_crash", {});
|
|
447
|
+
this.ctx.logger.error({ name }, "tmux server crash joined fleet storm window");
|
|
448
|
+
}, this.ctx.logger, `daemon.tmux_server_crash[${name}]`));
|
|
210
449
|
daemon.on("snapshot_failed", safeHandler(() => {
|
|
211
450
|
this.ctx.eventLog?.insert(name, "snapshot_failed", {});
|
|
212
451
|
this.notifyIncident(name, "snapshot_failed", t("inst.restarted_no_context", name));
|
|
@@ -242,26 +481,82 @@ export class InstanceLifecycle {
|
|
|
242
481
|
this.notifyIncident(name, "crash_loop", t("inst.respawn_paused", name));
|
|
243
482
|
this.ctx.setTopicIcon(name, "red");
|
|
244
483
|
}, this.ctx.logger, `daemon.crash_loop[${name}]`));
|
|
245
|
-
daemon.on("mcp_died", safeHandler((data) => {
|
|
484
|
+
daemon.on("mcp_died", safeHandler(async (data) => {
|
|
485
|
+
const stormAtDetection = this.ctx.stormWindow?.isActive() === true;
|
|
486
|
+
const incident = this.mcpIncident(name);
|
|
487
|
+
const generation = ++incident.gen;
|
|
488
|
+
incident.deathNoticeSent = false;
|
|
246
489
|
this.ctx.eventLog?.insert(name, "mcp_died", { pid: data.pid });
|
|
247
490
|
this.ctx.logger.error({ name, pid: data.pid }, "MCP server died — instance cannot use agend tools");
|
|
248
491
|
this.ctx.webhookEmit("mcp_died", name, { pid: data.pid });
|
|
492
|
+
// Auth outranks MCP: when the CLI lost authentication, "MCP server died"
|
|
493
|
+
// is a symptom and the fix is /login, not a restart. The daemon flags a
|
|
494
|
+
// suspicion it already confirmed (login screen / 401 pattern); otherwise
|
|
495
|
+
// ask the cached token-free probe. Only a CONFIRMED invalid swaps the
|
|
496
|
+
// message — valid or uncertain keeps the accurate MCP report below.
|
|
497
|
+
const verdict = data.authSuspected ? "invalid" : await this.verifyAuthError(name);
|
|
498
|
+
if (incident.gen !== generation) {
|
|
499
|
+
// The server recovered (or a newer death superseded this one) while the
|
|
500
|
+
// probe ran. Anything we would say now is stale — say nothing.
|
|
501
|
+
this.ctx.logger.info({ name, pid: data.pid }, "MCP death report superseded during the auth probe — nothing sent");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (verdict === "invalid") {
|
|
505
|
+
this.notifyAuthErrorOnce(name, "Sign-in expired — the CLI cannot run its MCP server (agend tools are down) until it is re-authenticated.", this.ptyErrorNotificationTarget(name) ?? name);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (stormAtDetection) {
|
|
509
|
+
this.ctx.stormSuppressed?.("mcp_died");
|
|
510
|
+
this.ctx.logger.info({ name, pid: data.pid }, "MCP death notification suppressed — included in tmux storm summary");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
249
513
|
// The CLI owns the MCP server's stdio pipes, so only restarting the CLI can
|
|
250
514
|
// restore its tools. With mcp_auto_restart (default) the daemon requests an
|
|
251
515
|
// idle-gated restart itself — an immediate one would interrupt whatever the
|
|
252
516
|
// agent is doing. With it off, tell the operator what to run, as before.
|
|
253
|
-
this.notifyIncident(name, "mcp_died", `⚠️ \`${name}\` 的 MCP server 已終止 — 這個 instance 目前無法使用 agend 工具(無法 reply / 跨 instance 通訊)。\n`
|
|
517
|
+
incident.deathNoticeSent = this.notifyIncident(name, "mcp_died", `⚠️ \`${name}\` 的 MCP server 已終止 — 這個 instance 目前無法使用 agend 工具(無法 reply / 跨 instance 通訊)。\n`
|
|
254
518
|
+ (data.autoRestart
|
|
255
519
|
? "CLI 本身還在執行;等它閒置後會自動重啟以恢復工具(進行中的工作不會被打斷,session 會保留)。"
|
|
256
520
|
: `CLI 本身還在執行。工具只能由 CLI 自己重新啟動 MCP server,請用 \`restart_instance("${name}")\` 或 \`/restart\` 恢復。`));
|
|
257
521
|
}, this.ctx.logger, `daemon.mcp_died[${name}]`));
|
|
522
|
+
daemon.on("mcp_recovered", safeHandler((data) => {
|
|
523
|
+
// Retract an earlier "MCP died" report: a live server for this instance
|
|
524
|
+
// is serving (seen at the IPC layer — a connection, mcp_ready, or a tool
|
|
525
|
+
// call — never inferred from the LLM). No restart will follow.
|
|
526
|
+
const incident = this.mcpIncident(name);
|
|
527
|
+
incident.gen++; // fences a death handler still waiting on its auth probe
|
|
528
|
+
this.ctx.eventLog?.insert(name, "mcp_recovered", { source: data.source, pid: data.pid });
|
|
529
|
+
this.ctx.logger.info({ name, source: data.source, pid: data.pid }, "MCP server recovered — earlier death report retracted");
|
|
530
|
+
if (!incident.deathNoticeSent) {
|
|
531
|
+
this.ctx.logger.info({ name }, "No MCP death notice reached the user — nothing to retract");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
incident.deathNoticeSent = false;
|
|
535
|
+
this.notifyIncident(name, "mcp_recovered", t("inst.mcp_recovered", name));
|
|
536
|
+
}, this.ctx.logger, `daemon.mcp_recovered[${name}]`));
|
|
258
537
|
daemon.on("mcp_proxy_reply", safeHandler((data) => {
|
|
259
538
|
// The message itself goes out through the daemon's fleet_outbound path;
|
|
260
539
|
// this is the audit trail that it happened (and why the channel saw a ⚠️).
|
|
261
540
|
this.ctx.eventLog?.insert(name, "mcp_proxy_reply", { correlationId: data.correlationId });
|
|
262
541
|
this.ctx.logger.warn({ name, correlationId: data.correlationId }, "MCP dead at turn end with no reply — daemon relayed the pane text to the channel");
|
|
263
542
|
}, this.ctx.logger, `daemon.mcp_proxy_reply[${name}]`));
|
|
543
|
+
daemon.on("malformed_tool_call", safeHandler((data) => {
|
|
544
|
+
this.ctx.eventLog?.insert(name, "malformed_tool_call", {
|
|
545
|
+
correlationId: data.correlationId,
|
|
546
|
+
recovered: data.recovered,
|
|
547
|
+
});
|
|
548
|
+
const notificationTarget = this.ptyErrorNotificationTarget(name);
|
|
549
|
+
if (notificationTarget) {
|
|
550
|
+
this.notifyIncident(notificationTarget, "malformed_tool_call", t(data.recovered ? "inst.malformed_tool_call_recovered" : "inst.malformed_tool_call_unrecoverable", name));
|
|
551
|
+
}
|
|
552
|
+
}, this.ctx.logger, `daemon.malformed_tool_call[${name}]`));
|
|
264
553
|
daemon.on("mcp_restart_requested", safeHandler((data) => {
|
|
554
|
+
if (this.ctx.stormWindow?.isActive()) {
|
|
555
|
+
this.ctx.eventLog?.insert(name, "mcp_auto_restart_suppressed", { trigger: data.trigger, reason: "tmux_storm" });
|
|
556
|
+
this.ctx.stormSuppressed?.("mcp_auto_restart");
|
|
557
|
+
this.ctx.logger.info({ name, trigger: data.trigger }, "MCP revival restart suppressed — tmux storm recovery will replace MCP");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
265
560
|
// The daemon object dies with the restart it asks for, so the loop guard
|
|
266
561
|
// lives here: if the previous auto-restart was under the cooldown, the new
|
|
267
562
|
// MCP server evidently died right back (broken install, OOM pressure) and
|
|
@@ -288,6 +583,24 @@ export class InstanceLifecycle {
|
|
|
288
583
|
// server.
|
|
289
584
|
this.ctx.restartSingleInstance(name).catch(err => this.ctx.logger.error({ err, name }, "MCP auto-restart failed"));
|
|
290
585
|
}, this.ctx.logger, `daemon.mcp_restart_requested[${name}]`));
|
|
586
|
+
daemon.on("dialog_parked", safeHandler((data) => {
|
|
587
|
+
// A CLI dialog the daemon will NOT answer on its own. No assist buttons
|
|
588
|
+
// here on purpose: the General "Confirm" assist sends Enter, and Enter is
|
|
589
|
+
// exactly the destructive default for Claude's resume prompt. The text
|
|
590
|
+
// differs by case: a hold-only variant means we do not know the cursor
|
|
591
|
+
// position, so the human must LOOK and pick the option explicitly — no
|
|
592
|
+
// fixed key sequence; a known dialog whose auto-dismiss did not take gets
|
|
593
|
+
// the keys we would have pressed.
|
|
594
|
+
this.ctx.eventLog?.insert(name, "dialog_parked", { description: data.description, holdOnly: data.holdOnly });
|
|
595
|
+
this.ctx.logger.warn({ name, description: data.description, holdOnly: data.holdOnly }, "Instance is parked on a CLI dialog — not auto-answering");
|
|
596
|
+
if (this.ctx.isPlannedRestart())
|
|
597
|
+
return;
|
|
598
|
+
const text = data.holdOnly
|
|
599
|
+
? t("inst.dialog_parked_hold", name, data.description)
|
|
600
|
+
: t("inst.dialog_parked_stuck", name, data.description);
|
|
601
|
+
this.notifyIncident(name, "dialog_parked", text);
|
|
602
|
+
this.ctx.notifyFleetError?.(t("fleet.dialog_parked", name, data.description));
|
|
603
|
+
}, this.ctx.logger, `daemon.dialog_parked[${name}]`));
|
|
291
604
|
daemon.on("interactive_prompt", safeHandler(async (data) => {
|
|
292
605
|
this.ctx.eventLog?.insert(name, "interactive_prompt", { kind: data.kind });
|
|
293
606
|
this.ctx.logger.warn({ name, kind: data.kind, prompt: data.prompt }, "Instance is waiting for interactive terminal input");
|
|
@@ -297,7 +610,7 @@ export class InstanceLifecycle {
|
|
|
297
610
|
}
|
|
298
611
|
await this.ctx.notifyInteractivePrompt(name, data.kind);
|
|
299
612
|
}, this.ctx.logger, `daemon.interactive_prompt[${name}]`));
|
|
300
|
-
daemon.on("pty_error", safeHandler((data) => {
|
|
613
|
+
daemon.on("pty_error", safeHandler(async (data) => {
|
|
301
614
|
this.ctx.eventLog?.insert(name, "pty_error", { type: data.type, action: data.action });
|
|
302
615
|
this.ctx.logger.warn({ name, errorType: data.type, action: data.action }, `PTY error: ${data.message}`);
|
|
303
616
|
// Antigravity's account-level cap is visible ONLY here: the quota summary
|
|
@@ -307,18 +620,69 @@ export class InstanceLifecycle {
|
|
|
307
620
|
if (data.type === "quota" && this.backendOf(name) === "antigravity") {
|
|
308
621
|
reportProviderRateLimit("antigravity", data.message);
|
|
309
622
|
}
|
|
623
|
+
// Codex keeps old errors in pane scrollback. After a restart, the new
|
|
624
|
+
// daemon has no occurrence baseline and can mistake yesterday's
|
|
625
|
+
// `You've hit your usage limit` for a current failure, pause, wake, then
|
|
626
|
+
// repeat forever. A live, non-LLM usage query distinguishes an
|
|
627
|
+
// available account from that stale text before any notification/pause.
|
|
628
|
+
// Only pause-class quota errors need this gate; low-quota notifications
|
|
629
|
+
// remain immediate. Unknown (timeout/auth/API failure) stays fail-closed.
|
|
630
|
+
if (data.type === "quota" && data.action === "pause" && this.backendOf(name) === "codex") {
|
|
631
|
+
const verdict = await this.verifyCodexQuota();
|
|
632
|
+
if (verdict === "available") {
|
|
633
|
+
this.ctx.logger.debug({ name, backend: "codex" }, "quota pattern ignored — live usage has capacity (stale pane history)");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (verdict === "unknown") {
|
|
637
|
+
this.ctx.logger.warn({ name, backend: "codex" }, "Codex quota verification failed or timed out — pausing conservatively");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Claude's credit-balance message also remains in pane scrollback. Only
|
|
641
|
+
// a fresh usage API row can prove it stale; provider fallback to a local
|
|
642
|
+
// statusline, timeout, or missing credentials remains fail-closed.
|
|
643
|
+
if (data.type === "quota" && data.action === "pause" && this.backendOf(name) === "claude-code") {
|
|
644
|
+
const verdict = await this.verifyClaudeQuota();
|
|
645
|
+
if (verdict === "available") {
|
|
646
|
+
this.ctx.logger.debug({ name, backend: "claude-code" }, "quota pattern ignored — live usage has capacity (stale pane history)");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (verdict === "unknown") {
|
|
650
|
+
this.ctx.logger.warn({ name, backend: "claude-code" }, "Claude quota verification failed, degraded, or timed out — pausing conservatively");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Pattern-matched auth errors get a second opinion from the real CLI
|
|
654
|
+
// before any pause/alert: an agent quoting "401 Unauthorized" in prose
|
|
655
|
+
// must not pause the fleet. A working credential ends the incident here.
|
|
656
|
+
if (data.type === "auth_error") {
|
|
657
|
+
const verdict = await this.verifyAuthError(name);
|
|
658
|
+
if (verdict === "valid") {
|
|
659
|
+
this.ctx.logger.info({ name, backend: this.backendOf(name) }, "auth-error pattern ignored — token-free auth check passed (likely conversation text)");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
310
663
|
const emoji = data.type === "rate_limit" || data.type === "timeout" ? "⏳" : data.type === "auth_error" ? "🔑" : "⚠️";
|
|
664
|
+
const incidentMessage = data.type === "model_error" && this.backendOf(name) === "claude-code"
|
|
665
|
+
? (() => {
|
|
666
|
+
const liveModel = readStatuslineModel(this.ctx.dataDir, name);
|
|
667
|
+
return liveModel
|
|
668
|
+
? t("inst.claude_model_fallback", liveModel)
|
|
669
|
+
: t("inst.claude_model_unavailable");
|
|
670
|
+
})()
|
|
671
|
+
: data.message;
|
|
311
672
|
const notificationTarget = this.ptyErrorNotificationTarget(name);
|
|
312
673
|
// Auth failures are a property of the BACKEND's shared credentials, not of
|
|
313
674
|
// one instance: every instance on that CLI fails at once, and one re-login
|
|
314
675
|
// fixes them all. Notify once per backend (listing who's affected) instead
|
|
315
676
|
// of N near-identical alerts, and suppress repeats fleet-wide.
|
|
316
|
-
if (data.type === "
|
|
677
|
+
if (data.type === "network" && data.fleetWide) {
|
|
678
|
+
this.noteBackendOutage(name, data.message, notificationTarget);
|
|
679
|
+
}
|
|
680
|
+
else if (data.type === "auth_error") {
|
|
317
681
|
if (notificationTarget)
|
|
318
682
|
this.notifyAuthErrorOnce(name, data.message, notificationTarget);
|
|
319
683
|
}
|
|
320
684
|
else if (notificationTarget) {
|
|
321
|
-
this.notifyIncident(notificationTarget, "pty_error", t("inst.notification", emoji, name,
|
|
685
|
+
this.notifyIncident(notificationTarget, "pty_error", t("inst.notification", emoji, name, incidentMessage, data.action));
|
|
322
686
|
}
|
|
323
687
|
this.ctx.webhookEmit("pty_error", name, { type: data.type, action: data.action, message: data.message });
|
|
324
688
|
// The CLI interrupted itself on this error, so any pending Cancel button is
|
|
@@ -386,12 +750,12 @@ export class InstanceLifecycle {
|
|
|
386
750
|
kind: "fleet-topic",
|
|
387
751
|
backend: backendName,
|
|
388
752
|
model: config.model ?? "default",
|
|
389
|
-
});
|
|
753
|
+
}, this.ctx.spawnGate, this.ctx.stormWindow, this.ctx.backendOutage);
|
|
390
754
|
// Catch errors from daemon internals (e.g. IPC server) to prevent crashing the fleet process
|
|
391
755
|
daemon.on("error", (err) => {
|
|
392
756
|
this.ctx.logger.error({ err, name }, "Daemon emitted error — instance isolated");
|
|
393
757
|
});
|
|
394
|
-
await daemon.
|
|
758
|
+
await InstanceLifecycle.startOrDispose(daemon, name, this.ctx.logger);
|
|
395
759
|
this.daemons.set(name, daemon);
|
|
396
760
|
daemon.on("auto_pause_requested", safeHandler(async () => {
|
|
397
761
|
await this.pause(name);
|
|
@@ -485,12 +849,38 @@ export class InstanceLifecycle {
|
|
|
485
849
|
}
|
|
486
850
|
this.ctx.startStatuslineWatcher(name);
|
|
487
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Ownership boundary for a daemon that is not yet registered: if start()
|
|
854
|
+
* rejects, nothing else will ever dispose it, so do it here before
|
|
855
|
+
* rethrowing. The abort is best-effort — the start error is the one to
|
|
856
|
+
* surface.
|
|
857
|
+
*/
|
|
858
|
+
static async startOrDispose(daemon, name, logger) {
|
|
859
|
+
try {
|
|
860
|
+
await daemon.start();
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
await daemon.abortStartup().catch(abortErr => logger.warn({ err: abortErr, name }, "Failed to dispose a daemon whose start() rejected"));
|
|
864
|
+
throw err;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/** Stop the registered daemon only if it is still `daemon` (a concurrent restart may have replaced it). */
|
|
868
|
+
async stopIfCurrent(name, daemon) {
|
|
869
|
+
if (this.daemons.get(name) !== daemon)
|
|
870
|
+
return false;
|
|
871
|
+
await this.stop(name);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
488
874
|
async stop(name) {
|
|
489
875
|
this.ctx.setTopicIcon(name, "remove");
|
|
490
876
|
const daemon = this.daemons.get(name);
|
|
491
877
|
if (daemon) {
|
|
492
878
|
await daemon.stop();
|
|
493
|
-
|
|
879
|
+
// Identity-safe: while we awaited, a concurrent restart may have
|
|
880
|
+
// registered a FRESH daemon under this name — deleting by name alone
|
|
881
|
+
// would drop it from supervision while its CLI keeps running.
|
|
882
|
+
if (this.daemons.get(name) === daemon)
|
|
883
|
+
this.daemons.delete(name);
|
|
494
884
|
}
|
|
495
885
|
else {
|
|
496
886
|
const instanceDir = this.ctx.getInstanceDir(name);
|