@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.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/dist/agent-cli.js +6 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/auth-api.js +2 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/claude-code.d.ts +39 -2
- package/dist/backend/claude-code.js +115 -31
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +33 -3
- package/dist/backend/credential-profile.js +107 -5
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/cache-service.d.ts +2 -0
- package/dist/cache-service.js +2 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -1
- package/dist/channel/adapters/discord.js +42 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +18 -3
- package/dist/channel/adapters/telegram.js +66 -10
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.js +5 -4
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +27 -4
- package/dist/config-validator.js +10 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +108 -0
- package/dist/daemon.js +432 -32
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +29 -7
- package/dist/delivery-outbox.js +134 -16
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -0
- package/dist/fleet-manager.d.ts +81 -4
- package/dist/fleet-manager.js +452 -85
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +25 -6
- package/dist/instance-lifecycle.js +73 -26
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +48 -18
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/outbound-handlers.d.ts +63 -0
- package/dist/outbound-handlers.js +121 -46
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -2
- package/dist/outbound-schemas.js +3 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +8 -5
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.js +10 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +2 -2
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/topic-commands.js +9 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +25 -0
- package/dist/ui/chat-store.js +1 -1
- package/dist/ui/chat-strings.js +12 -12
- package/dist/ui/chat-thread.js +48 -11
- package/dist/ui/panel-chat.js +20 -9
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +51 -22
- package/dist/ui/panel-settings.js +18 -9
- package/dist/ui/preview-panel.js +48 -14
- package/dist/ui/preview.js +5 -1
- package/dist/ui/settings-strings.js +6 -2
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +35 -13
- package/dist/ui/shared/app-prefs.js +249 -0
- package/dist/ui/shared/app-shell.js +17 -4
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app.css +41 -7
- package/dist/ui/shared/app.js +6 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +131 -38
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +13 -4
- package/dist/ui/shared/view-strings.js +26 -2
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/view-api.js +5 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +13 -1
- package/dist/web-api.js +218 -18
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +3 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-mentions.d.ts +44 -0
- package/dist/web-mentions.js +75 -0
- package/dist/web-mentions.js.map +1 -0
- package/dist/web-prefs.d.ts +63 -0
- package/dist/web-prefs.js +263 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -4,6 +4,7 @@ import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
|
4
4
|
import { SettingsControlServer } from "./settings-control.js";
|
|
5
5
|
import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
|
+
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
7
8
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
8
9
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
9
10
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
@@ -75,7 +76,7 @@ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, che
|
|
|
75
76
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
76
77
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
77
78
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
78
|
-
import { DeliveryOutbox } from "./delivery-outbox.js";
|
|
79
|
+
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
79
80
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
80
81
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
81
82
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
@@ -129,6 +130,8 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
129
130
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
130
131
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
131
132
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
133
|
+
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
134
|
+
import { WebPrefsStore } from "./web-prefs.js";
|
|
132
135
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
133
136
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
134
137
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
@@ -150,7 +153,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
|
150
153
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
151
154
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
152
155
|
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
153
|
-
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
156
|
+
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
154
157
|
import { runBeforeDeadline } from "./deadline.js";
|
|
155
158
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
156
159
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -178,7 +181,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
|
178
181
|
import { buildOrgChart } from "./web-org.js";
|
|
179
182
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
180
183
|
import { runWebCommand } from "./web-commands.js";
|
|
181
|
-
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
184
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
182
185
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
183
186
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
184
187
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
@@ -198,7 +201,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
198
201
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
199
202
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
200
203
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
201
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
204
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
202
205
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
203
206
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
204
207
|
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
@@ -427,6 +430,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
427
430
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
428
431
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
429
432
|
*/
|
|
433
|
+
/** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
|
|
434
|
+
export const RUNTIME_SWITCH_RESULT_MS = 12_000;
|
|
430
435
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
431
436
|
/**
|
|
432
437
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -749,8 +754,8 @@ export class FleetManager {
|
|
|
749
754
|
memoryLogAt = null;
|
|
750
755
|
memoryDebugAt = null;
|
|
751
756
|
memoryLogLevel = null;
|
|
752
|
-
|
|
753
|
-
|
|
757
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
758
|
+
memoryIndicatorShown = null;
|
|
754
759
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
755
760
|
mirrorBuffer = [];
|
|
756
761
|
mirrorTimer = null;
|
|
@@ -762,6 +767,17 @@ export class FleetManager {
|
|
|
762
767
|
webChatStore = null;
|
|
763
768
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
764
769
|
webFiles = new WebFileLedger();
|
|
770
|
+
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
771
|
+
seenUsers = new SeenUsers();
|
|
772
|
+
webPrefsStore;
|
|
773
|
+
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
774
|
+
get webPrefs() {
|
|
775
|
+
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
776
|
+
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
777
|
+
exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
|
|
778
|
+
|| (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
765
781
|
/**
|
|
766
782
|
* Read from disk on every access rather than cached at startup: `agend
|
|
767
783
|
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
@@ -1219,8 +1235,13 @@ export class FleetManager {
|
|
|
1219
1235
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
1220
1236
|
const row = outbox.get(event.deliveryId);
|
|
1221
1237
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
1222
|
-
|
|
1223
|
-
|
|
1238
|
+
// #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
|
|
1239
|
+
const silent = isSilentScheduleDelivery(row);
|
|
1240
|
+
const notice = silent
|
|
1241
|
+
? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
|
|
1242
|
+
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1243
|
+
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1244
|
+
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1224
1245
|
this.notifyFleetError(notice);
|
|
1225
1246
|
}
|
|
1226
1247
|
}
|
|
@@ -1315,7 +1336,9 @@ export class FleetManager {
|
|
|
1315
1336
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1316
1337
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1317
1338
|
try {
|
|
1318
|
-
|
|
1339
|
+
const now = new Date();
|
|
1340
|
+
this.expireReplyObligations(now);
|
|
1341
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1319
1342
|
}
|
|
1320
1343
|
catch (err) {
|
|
1321
1344
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1333,6 +1356,7 @@ export class FleetManager {
|
|
|
1333
1356
|
return;
|
|
1334
1357
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1335
1358
|
try {
|
|
1359
|
+
this.expireReplyObligations(now);
|
|
1336
1360
|
for (const name of this.daemons.keys()) {
|
|
1337
1361
|
if (stateOf(name) === "idle")
|
|
1338
1362
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1344,6 +1368,15 @@ export class FleetManager {
|
|
|
1344
1368
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1345
1369
|
}
|
|
1346
1370
|
}
|
|
1371
|
+
expireReplyObligations(now) {
|
|
1372
|
+
const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
|
|
1373
|
+
const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
|
|
1374
|
+
? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
|
|
1375
|
+
for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
|
|
1376
|
+
this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
|
|
1377
|
+
correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1347
1380
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1348
1381
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1349
1382
|
return;
|
|
@@ -1583,6 +1616,14 @@ export class FleetManager {
|
|
|
1583
1616
|
return Math.max(1, Math.min(20, explicit));
|
|
1584
1617
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1585
1618
|
}
|
|
1619
|
+
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
1620
|
+
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
1621
|
+
hostMemoryIndicator() {
|
|
1622
|
+
if (!this.memoryPressure)
|
|
1623
|
+
return null;
|
|
1624
|
+
const level = this.memoryPressure.snapshot().level;
|
|
1625
|
+
return level === "elevated" || level === "critical" ? level : null;
|
|
1626
|
+
}
|
|
1586
1627
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1587
1628
|
reportMemoryPressure(snapshot) {
|
|
1588
1629
|
if (this.shuttingDown)
|
|
@@ -1593,6 +1634,12 @@ export class FleetManager {
|
|
|
1593
1634
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1594
1635
|
}
|
|
1595
1636
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1637
|
+
// #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
|
|
1638
|
+
const indicator = this.hostMemoryIndicator();
|
|
1639
|
+
if (indicator !== this.memoryIndicatorShown) {
|
|
1640
|
+
this.memoryIndicatorShown = indicator;
|
|
1641
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1642
|
+
}
|
|
1596
1643
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1597
1644
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1598
1645
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1613,20 +1660,6 @@ export class FleetManager {
|
|
|
1613
1660
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1614
1661
|
}
|
|
1615
1662
|
this.memoryLogLevel = snapshot.level;
|
|
1616
|
-
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1617
|
-
return;
|
|
1618
|
-
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1619
|
-
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1620
|
-
return;
|
|
1621
|
-
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1622
|
-
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1623
|
-
const text = this.memoryPressure.platform === "darwin"
|
|
1624
|
-
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1625
|
-
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1626
|
-
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1627
|
-
this.memoryNoticeAt = now;
|
|
1628
|
-
this.memoryNoticeLevel = snapshot.level;
|
|
1629
|
-
}
|
|
1630
1663
|
}
|
|
1631
1664
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1632
1665
|
bindStormWindowEvents() {
|
|
@@ -3020,13 +3053,38 @@ export class FleetManager {
|
|
|
3020
3053
|
}
|
|
3021
3054
|
return posted;
|
|
3022
3055
|
}
|
|
3023
|
-
|
|
3056
|
+
/**
|
|
3057
|
+
* #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
|
|
3058
|
+
* inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
|
|
3059
|
+
* id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
|
|
3060
|
+
*/
|
|
3061
|
+
startWebChatProgress(instanceName) {
|
|
3062
|
+
if (this.shuttingDown)
|
|
3063
|
+
return Promise.resolve();
|
|
3064
|
+
return this.sendCancelButton(instanceName);
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
|
|
3068
|
+
* keeps its place in the order, but the lane's copy rules do not apply to it: its 5 s budget starts when its turn
|
|
3069
|
+
* starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
|
|
3070
|
+
* behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
|
|
3071
|
+
* any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
|
|
3072
|
+
* however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
|
|
3073
|
+
* deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
|
|
3074
|
+
*/
|
|
3075
|
+
reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
|
|
3076
|
+
const copy = opts.copy !== false;
|
|
3024
3077
|
const epoch = this.getDeliveryEpoch(instanceName);
|
|
3025
|
-
const
|
|
3078
|
+
const queued = this.webChannelEchoTails.get(instanceName);
|
|
3079
|
+
// The last open non-copy ahead of this copy: its budget starts when that one finishes.
|
|
3080
|
+
const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
|
|
3081
|
+
let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
|
|
3026
3082
|
let decide;
|
|
3027
3083
|
const admission = new Promise(resolve => { decide = resolve; });
|
|
3028
|
-
const queue =
|
|
3084
|
+
const queue = queued
|
|
3029
3085
|
?? { tail: Promise.resolve(), pending: new Set() };
|
|
3086
|
+
const seq = queue.nextSeq ?? 0;
|
|
3087
|
+
queue.nextSeq = seq + 1;
|
|
3030
3088
|
const previous = queue.tail;
|
|
3031
3089
|
let resolveDone;
|
|
3032
3090
|
const tail = new Promise(resolve => { resolveDone = resolve; });
|
|
@@ -3037,6 +3095,8 @@ export class FleetManager {
|
|
|
3037
3095
|
if (done)
|
|
3038
3096
|
return;
|
|
3039
3097
|
done = true;
|
|
3098
|
+
if (!copy)
|
|
3099
|
+
queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
|
|
3040
3100
|
clearTimeout(timer);
|
|
3041
3101
|
queue.pending.delete(entry);
|
|
3042
3102
|
if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
|
|
@@ -3044,7 +3104,7 @@ export class FleetManager {
|
|
|
3044
3104
|
}
|
|
3045
3105
|
resolveDone();
|
|
3046
3106
|
};
|
|
3047
|
-
const entry = { started: false, drop: () => {
|
|
3107
|
+
const entry = { seq, started: false, copy, drop: () => {
|
|
3048
3108
|
this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
|
|
3049
3109
|
finish();
|
|
3050
3110
|
} };
|
|
@@ -3054,14 +3114,16 @@ export class FleetManager {
|
|
|
3054
3114
|
expired = true;
|
|
3055
3115
|
this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
|
|
3056
3116
|
finish();
|
|
3057
|
-
// Do not post copies that were queued behind an ambiguous platform send.
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3117
|
+
// Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
|
|
3118
|
+
// at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
|
|
3119
|
+
if (copy)
|
|
3120
|
+
for (const pending of queue.pending)
|
|
3121
|
+
if (pending.seq > seq && !pending.started && pending.copy)
|
|
3122
|
+
pending.drop();
|
|
3061
3123
|
};
|
|
3062
3124
|
const checkDeadline = () => {
|
|
3063
|
-
if (done)
|
|
3064
|
-
return;
|
|
3125
|
+
if (done || deadlineAt === Number.POSITIVE_INFINITY)
|
|
3126
|
+
return; // a non-copy's budget has not started yet
|
|
3065
3127
|
const remaining = deadlineAt - performance.now();
|
|
3066
3128
|
if (remaining <= 0)
|
|
3067
3129
|
expire();
|
|
@@ -3070,8 +3132,19 @@ export class FleetManager {
|
|
|
3070
3132
|
};
|
|
3071
3133
|
queue.pending.add(entry);
|
|
3072
3134
|
queue.tail = tail;
|
|
3135
|
+
if (!copy) {
|
|
3136
|
+
queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
|
|
3137
|
+
queue.lastNonCopyDone = tail;
|
|
3138
|
+
}
|
|
3073
3139
|
this.webChannelEchoTails.set(instanceName, queue);
|
|
3074
3140
|
checkDeadline();
|
|
3141
|
+
// Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
|
|
3142
|
+
// is set by the time that runs.
|
|
3143
|
+
if (nonCopyAhead)
|
|
3144
|
+
void nonCopyAhead.then(() => { if (!done) {
|
|
3145
|
+
deadlineAt = performance.now() + 5_000;
|
|
3146
|
+
checkDeadline();
|
|
3147
|
+
} });
|
|
3075
3148
|
void Promise.all([previous, admission]).then(([, accepted]) => {
|
|
3076
3149
|
if (done)
|
|
3077
3150
|
return;
|
|
@@ -3080,11 +3153,15 @@ export class FleetManager {
|
|
|
3080
3153
|
return;
|
|
3081
3154
|
}
|
|
3082
3155
|
// A delayed timer callback must not admit an already expired copy.
|
|
3083
|
-
if (performance.now() >= deadlineAt) {
|
|
3156
|
+
if (copy && performance.now() >= deadlineAt) {
|
|
3084
3157
|
expire();
|
|
3085
3158
|
return;
|
|
3086
3159
|
}
|
|
3087
3160
|
entry.started = true;
|
|
3161
|
+
if (!copy) {
|
|
3162
|
+
deadlineAt = performance.now() + 5_000;
|
|
3163
|
+
checkDeadline();
|
|
3164
|
+
}
|
|
3088
3165
|
guard = { epoch, deadlineAt };
|
|
3089
3166
|
this.webChannelEchoGuards.set(instanceName, guard);
|
|
3090
3167
|
let request;
|
|
@@ -3251,6 +3328,9 @@ export class FleetManager {
|
|
|
3251
3328
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3252
3329
|
*/
|
|
3253
3330
|
async dispatchSlash(slash, adapterId, adapter) {
|
|
3331
|
+
return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
|
|
3332
|
+
}
|
|
3333
|
+
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
3254
3334
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3255
3335
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3256
3336
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -4710,6 +4790,16 @@ export class FleetManager {
|
|
|
4710
4790
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4711
4791
|
}
|
|
4712
4792
|
}
|
|
4793
|
+
/**
|
|
4794
|
+
* #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
|
|
4795
|
+
* Returns null when no botUsername is known or the username fails Telegram's format validation.
|
|
4796
|
+
*/
|
|
4797
|
+
tgStartWebUrl(adapterId) {
|
|
4798
|
+
const username = this.worlds.get(adapterId)?.botUsername;
|
|
4799
|
+
if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
|
|
4800
|
+
return null;
|
|
4801
|
+
return `https://t.me/${username}?start=web`;
|
|
4802
|
+
}
|
|
4713
4803
|
async sendLocalDashboard(data, adapterId) {
|
|
4714
4804
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4715
4805
|
await data.respond(t("not_authorized"));
|
|
@@ -4831,10 +4921,12 @@ export class FleetManager {
|
|
|
4831
4921
|
const adapter = owner.binding;
|
|
4832
4922
|
const status = this.getPublicWebStatus();
|
|
4833
4923
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4924
|
+
const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4834
4925
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4835
4926
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4836
4927
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
4837
|
-
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
4928
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
4929
|
+
+ (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
|
|
4838
4930
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4839
4931
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4840
4932
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
@@ -4977,7 +5069,11 @@ export class FleetManager {
|
|
|
4977
5069
|
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4978
5070
|
await progress;
|
|
4979
5071
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4980
|
-
|
|
5072
|
+
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5073
|
+
const failUrlButtons = (!ok && owner.binding.type === "telegram")
|
|
5074
|
+
? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
|
|
5075
|
+
: undefined;
|
|
5076
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
4981
5077
|
return true;
|
|
4982
5078
|
}
|
|
4983
5079
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5322,7 +5418,11 @@ export class FleetManager {
|
|
|
5322
5418
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5323
5419
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5324
5420
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5325
|
-
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config =>
|
|
5421
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
|
|
5422
|
+
const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
|
|
5423
|
+
// #1699 follow-up: each instance's own login — a profile is probed with its own environment.
|
|
5424
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
|
|
5425
|
+
}));
|
|
5326
5426
|
// Start tmux control mode client for idle detection
|
|
5327
5427
|
if (!this.controlClient) {
|
|
5328
5428
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -6510,6 +6610,14 @@ export class FleetManager {
|
|
|
6510
6610
|
});
|
|
6511
6611
|
}
|
|
6512
6612
|
}
|
|
6613
|
+
else if (msg.type === "runtime_switch_result") {
|
|
6614
|
+
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
6615
|
+
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
6616
|
+
if (waiter) {
|
|
6617
|
+
this.runtimeSwitchWaiters.delete(msg.id);
|
|
6618
|
+
waiter(msg);
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6513
6621
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6514
6622
|
this.cacheInstanceExecutionState(name, msg);
|
|
6515
6623
|
if (msg.type === "instance_state_response") {
|
|
@@ -7022,6 +7130,16 @@ export class FleetManager {
|
|
|
7022
7130
|
});
|
|
7023
7131
|
}
|
|
7024
7132
|
async handleInboundMessage(msg) {
|
|
7133
|
+
if (msg.isBotMessage)
|
|
7134
|
+
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7135
|
+
const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapters.get(msg.adapterId ?? "") ?? this.adapter;
|
|
7136
|
+
return withSpawnHoldReply(text => adapter?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7137
|
+
}
|
|
7138
|
+
async handleInboundMessageCore(msg) {
|
|
7139
|
+
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
7140
|
+
// the instance they belong to).
|
|
7141
|
+
if (!msg.isBotMessage && msg.userId && msg.username)
|
|
7142
|
+
this.seenUsers.record(msg.userId, msg.username);
|
|
7025
7143
|
// Platform author identity is the authority. This runs before routing,
|
|
7026
7144
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
7027
7145
|
const fleetBotIds = new Set();
|
|
@@ -7177,6 +7295,16 @@ export class FleetManager {
|
|
|
7177
7295
|
}
|
|
7178
7296
|
// Handle /start command
|
|
7179
7297
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
7298
|
+
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7299
|
+
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7300
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7301
|
+
if (requestedBackend === "web" && isPrivateChat) {
|
|
7302
|
+
if (this.isFleetAdmin(msg.userId, msg.adapterId ?? "")) {
|
|
7303
|
+
await msgAdapter?.sendText(chatId, t("dashboard.start_web_ack"));
|
|
7304
|
+
}
|
|
7305
|
+
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7306
|
+
return;
|
|
7307
|
+
}
|
|
7180
7308
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7181
7309
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7182
7310
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7185,7 +7313,6 @@ export class FleetManager {
|
|
|
7185
7313
|
return;
|
|
7186
7314
|
}
|
|
7187
7315
|
const channelName = msg.username || chatId;
|
|
7188
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7189
7316
|
if (requestedBackend) {
|
|
7190
7317
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7191
7318
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -9350,7 +9477,9 @@ export class FleetManager {
|
|
|
9350
9477
|
else
|
|
9351
9478
|
await this.lifecycle.remove(name, authorization);
|
|
9352
9479
|
// A later same-name registration owns its files and caches.
|
|
9353
|
-
|
|
9480
|
+
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
9481
|
+
// re-registration) — #1677 review.
|
|
9482
|
+
if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
|
|
9354
9483
|
return;
|
|
9355
9484
|
execution?.assert();
|
|
9356
9485
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
@@ -9362,6 +9491,13 @@ export class FleetManager {
|
|
|
9362
9491
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
9363
9492
|
this.webChatHistory.forget(name);
|
|
9364
9493
|
this.webFiles.forget(name);
|
|
9494
|
+
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
9495
|
+
try {
|
|
9496
|
+
this.webPrefs.forgetInstance(name);
|
|
9497
|
+
}
|
|
9498
|
+
catch (err) {
|
|
9499
|
+
this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
|
|
9500
|
+
}
|
|
9365
9501
|
// Clean up statusline watcher + instance directory
|
|
9366
9502
|
this.statuslineWatcher.unwatch(name);
|
|
9367
9503
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -9663,6 +9799,45 @@ export class FleetManager {
|
|
|
9663
9799
|
return null;
|
|
9664
9800
|
}
|
|
9665
9801
|
}
|
|
9802
|
+
/**
|
|
9803
|
+
* #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
|
|
9804
|
+
* the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
|
|
9805
|
+
* profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
|
|
9806
|
+
* (members, users, roles, channels).
|
|
9807
|
+
*/
|
|
9808
|
+
webMentionNames(query) {
|
|
9809
|
+
const bots = (q) => {
|
|
9810
|
+
const u = Object.create(null);
|
|
9811
|
+
if (!q.u.length)
|
|
9812
|
+
return { u };
|
|
9813
|
+
let own = new Map();
|
|
9814
|
+
try {
|
|
9815
|
+
own = this.ownBots();
|
|
9816
|
+
}
|
|
9817
|
+
catch { /* none known */ }
|
|
9818
|
+
const profiles = profileIdentities(this.dataDir);
|
|
9819
|
+
const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
9820
|
+
for (const [instance, connection] of own) {
|
|
9821
|
+
const id = this.adapters.get(connection)?.getBotUserId?.();
|
|
9822
|
+
if (!id || !q.u.includes(id))
|
|
9823
|
+
continue;
|
|
9824
|
+
const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9825
|
+
u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
|
|
9826
|
+
}
|
|
9827
|
+
return { u };
|
|
9828
|
+
};
|
|
9829
|
+
const seen = (q) => {
|
|
9830
|
+
const u = Object.create(null);
|
|
9831
|
+
for (const id of q.u) {
|
|
9832
|
+
const name = this.seenUsers.get(id);
|
|
9833
|
+
if (name)
|
|
9834
|
+
u[id] = name;
|
|
9835
|
+
}
|
|
9836
|
+
return { u };
|
|
9837
|
+
};
|
|
9838
|
+
const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
|
|
9839
|
+
return resolveMentions(query, [bots, seen, ...caches]);
|
|
9840
|
+
}
|
|
9666
9841
|
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
9667
9842
|
async webEmojiList() {
|
|
9668
9843
|
const adopted = [];
|
|
@@ -11106,6 +11281,48 @@ export class FleetManager {
|
|
|
11106
11281
|
});
|
|
11107
11282
|
}
|
|
11108
11283
|
cacheService = null;
|
|
11284
|
+
/**
|
|
11285
|
+
* #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
|
|
11286
|
+
* ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
|
|
11287
|
+
* (also for a backend that has no profiles, where the option is ignored).
|
|
11288
|
+
*/
|
|
11289
|
+
credentialProfileOf(name, backend) {
|
|
11290
|
+
if (!credentialHomeSpec(backend))
|
|
11291
|
+
return null;
|
|
11292
|
+
const cfg = this.fleetConfig?.instances?.[name];
|
|
11293
|
+
if (cfg)
|
|
11294
|
+
return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
|
|
11295
|
+
const launched = this.daemons.get(name)?.getConfigSnapshot?.();
|
|
11296
|
+
if (launched)
|
|
11297
|
+
return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
|
|
11298
|
+
if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
|
|
11299
|
+
return null;
|
|
11300
|
+
return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
|
|
11301
|
+
}
|
|
11302
|
+
/**
|
|
11303
|
+
* #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
|
|
11304
|
+
* (CLAUDE_CONFIG_DIR or ~/.claude).
|
|
11305
|
+
*/
|
|
11306
|
+
claudeConfigDirOf(name) {
|
|
11307
|
+
const profile = this.credentialProfileOf(name, "claude-code");
|
|
11308
|
+
return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
|
|
11309
|
+
}
|
|
11310
|
+
/** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
|
|
11311
|
+
cacheInstances() {
|
|
11312
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11313
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11314
|
+
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
11315
|
+
}));
|
|
11316
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
11317
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11318
|
+
name: ch.instanceName,
|
|
11319
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11320
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11321
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11322
|
+
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
11323
|
+
}));
|
|
11324
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11325
|
+
}
|
|
11109
11326
|
/**
|
|
11110
11327
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
11111
11328
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -11114,20 +11331,7 @@ export class FleetManager {
|
|
|
11114
11331
|
cacheReport(window) {
|
|
11115
11332
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
11116
11333
|
this.cacheService ??= new CacheService({
|
|
11117
|
-
|
|
11118
|
-
instances: () => {
|
|
11119
|
-
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11120
|
-
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11121
|
-
}));
|
|
11122
|
-
const taken = new Set(fleet.map(i => i.name));
|
|
11123
|
-
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11124
|
-
name: ch.instanceName,
|
|
11125
|
-
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11126
|
-
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11127
|
-
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11128
|
-
}));
|
|
11129
|
-
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11130
|
-
},
|
|
11334
|
+
instances: () => this.cacheInstances(),
|
|
11131
11335
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
11132
11336
|
claudeKey: claudeProjectKey,
|
|
11133
11337
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11314,7 +11518,9 @@ export class FleetManager {
|
|
|
11314
11518
|
}
|
|
11315
11519
|
async retireNonceButtons(pending, messageId, text,
|
|
11316
11520
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11317
|
-
editClicked
|
|
11521
|
+
editClicked,
|
|
11522
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
11523
|
+
urlButtons) {
|
|
11318
11524
|
this.webPromptGone(pending, text);
|
|
11319
11525
|
try {
|
|
11320
11526
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11324,7 +11530,12 @@ export class FleetManager {
|
|
|
11324
11530
|
}
|
|
11325
11531
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11326
11532
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11327
|
-
|
|
11533
|
+
if (urlButtons) {
|
|
11534
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
|
|
11535
|
+
}
|
|
11536
|
+
else {
|
|
11537
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
11538
|
+
}
|
|
11328
11539
|
}
|
|
11329
11540
|
catch (err) {
|
|
11330
11541
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -11938,9 +12149,12 @@ export class FleetManager {
|
|
|
11938
12149
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
11939
12150
|
* no button — the alert's own wording already tells them what to run.
|
|
11940
12151
|
*/
|
|
11941
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
12152
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
11942
12153
|
if (!LOGIN_FLOWS[backend])
|
|
11943
12154
|
return;
|
|
12155
|
+
if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
|
|
12156
|
+
return; // that profile signs in on the host
|
|
12157
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
11944
12158
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
11945
12159
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
11946
12160
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -11958,9 +12172,10 @@ export class FleetManager {
|
|
|
11958
12172
|
adapterId,
|
|
11959
12173
|
chatId,
|
|
11960
12174
|
threadId,
|
|
11961
|
-
message: t("login.offer",
|
|
11962
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
12175
|
+
message: t("login.offer", shown),
|
|
12176
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
11963
12177
|
expiredText: t("buttons.stale"),
|
|
12178
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
11964
12179
|
});
|
|
11965
12180
|
}
|
|
11966
12181
|
/** Consume a General assist button exactly once. */
|
|
@@ -12773,7 +12988,7 @@ export class FleetManager {
|
|
|
12773
12988
|
fleetConfig: () => this.fleetConfig,
|
|
12774
12989
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12775
12990
|
eventLog: () => this.eventLog,
|
|
12776
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
12991
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12777
12992
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12778
12993
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12779
12994
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12782,11 +12997,12 @@ export class FleetManager {
|
|
|
12782
12997
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12783
12998
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12784
12999
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12785
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13000
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12786
13001
|
await this.postNonceButtonPromptOrThrow({
|
|
12787
13002
|
prefix, alertType: "login", instanceName,
|
|
12788
13003
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12789
13004
|
message, choices, expiredText,
|
|
13005
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12790
13006
|
});
|
|
12791
13007
|
},
|
|
12792
13008
|
});
|
|
@@ -13000,10 +13216,20 @@ export class FleetManager {
|
|
|
13000
13216
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
13001
13217
|
if (isRemovedBackend(wanted))
|
|
13002
13218
|
return removedBackendMessage(wanted);
|
|
13219
|
+
// #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
|
|
13220
|
+
// whose profiles sign in on the host, is refused here, not after an install it did not need.
|
|
13221
|
+
if (opts.profile !== undefined) {
|
|
13222
|
+
const admitted = admitProfileLogin(wanted, opts.profile);
|
|
13223
|
+
if ("refusal" in admitted)
|
|
13224
|
+
return admitted.refusal;
|
|
13225
|
+
opts = { ...opts, profile: admitted.profile };
|
|
13226
|
+
}
|
|
13003
13227
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
13004
13228
|
const flow = LOGIN_FLOWS[wanted];
|
|
13005
13229
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
13006
|
-
return
|
|
13230
|
+
return opts.profile !== undefined
|
|
13231
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
13232
|
+
: this.startInstallSession(wanted, chat);
|
|
13007
13233
|
}
|
|
13008
13234
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
13009
13235
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -13092,7 +13318,9 @@ export class FleetManager {
|
|
|
13092
13318
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
13093
13319
|
* message can say so instead of implying failure.
|
|
13094
13320
|
*/
|
|
13095
|
-
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS
|
|
13321
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
|
|
13322
|
+
/** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
|
|
13323
|
+
profile = null) {
|
|
13096
13324
|
const woken = [];
|
|
13097
13325
|
const restarted = [];
|
|
13098
13326
|
const pending = [];
|
|
@@ -13100,6 +13328,9 @@ export class FleetManager {
|
|
|
13100
13328
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
13101
13329
|
if (this.backendNameOf(name) !== backend)
|
|
13102
13330
|
continue;
|
|
13331
|
+
// Only the instances on the login that changed: another profile's (or the default's) credentials did not.
|
|
13332
|
+
if (this.credentialProfileOf(name, backend) !== profile)
|
|
13333
|
+
continue;
|
|
13103
13334
|
const status = this.getInstanceStatus(name);
|
|
13104
13335
|
if (status !== "paused" && status !== "running")
|
|
13105
13336
|
continue;
|
|
@@ -13137,14 +13368,16 @@ export class FleetManager {
|
|
|
13137
13368
|
if (claimed === "consumed")
|
|
13138
13369
|
return true;
|
|
13139
13370
|
const { entry, action: backend } = claimed;
|
|
13140
|
-
|
|
13141
|
-
|
|
13371
|
+
const profile = entry.loginProfile;
|
|
13372
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
|
|
13373
|
+
const chat = {
|
|
13142
13374
|
adapter: entry.adapter,
|
|
13143
13375
|
adapterId: entry.adapterId,
|
|
13144
13376
|
chatId: entry.chatId,
|
|
13145
13377
|
threadId: entry.threadId,
|
|
13146
13378
|
userId: data.userId,
|
|
13147
|
-
}
|
|
13379
|
+
};
|
|
13380
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13148
13381
|
if (text)
|
|
13149
13382
|
await this.postPromptOutcome(entry, text);
|
|
13150
13383
|
return true;
|
|
@@ -13158,11 +13391,13 @@ export class FleetManager {
|
|
|
13158
13391
|
return true;
|
|
13159
13392
|
const { entry, action } = claimed;
|
|
13160
13393
|
const backend = entry.instanceName;
|
|
13394
|
+
const profile = entry.loginProfile;
|
|
13395
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
13161
13396
|
if (action === "cancel") {
|
|
13162
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
13397
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
13163
13398
|
return true;
|
|
13164
13399
|
}
|
|
13165
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend",
|
|
13400
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
|
|
13166
13401
|
const text = await this.startLoginSession(backend, {
|
|
13167
13402
|
adapter: entry.adapter,
|
|
13168
13403
|
adapterId: entry.adapterId,
|
|
@@ -13174,6 +13409,7 @@ export class FleetManager {
|
|
|
13174
13409
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
13175
13410
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
13176
13411
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
13412
|
+
...(profile ? { profile } : {}),
|
|
13177
13413
|
});
|
|
13178
13414
|
if (text)
|
|
13179
13415
|
await this.postPromptOutcome(entry, text);
|
|
@@ -13201,7 +13437,7 @@ export class FleetManager {
|
|
|
13201
13437
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
13202
13438
|
installHandoff = null;
|
|
13203
13439
|
/** Start a CLI install session. Caller enforces admin. */
|
|
13204
|
-
async startInstallSession(backendArg, chat) {
|
|
13440
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
13205
13441
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
13206
13442
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
13207
13443
|
if (!info)
|
|
@@ -13285,7 +13521,8 @@ export class FleetManager {
|
|
|
13285
13521
|
// without a click.
|
|
13286
13522
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13287
13523
|
// not visible, startLoginSession would route straight back to an install.
|
|
13288
|
-
|
|
13524
|
+
// A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
|
|
13525
|
+
const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
13289
13526
|
if (next) {
|
|
13290
13527
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13291
13528
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13393,8 +13630,16 @@ export class FleetManager {
|
|
|
13393
13630
|
return;
|
|
13394
13631
|
}
|
|
13395
13632
|
const backend = String(data.options?.backend ?? "").trim();
|
|
13633
|
+
// #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
|
|
13634
|
+
// and one without a backend is refused rather than becoming the default login's picker.
|
|
13635
|
+
const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
|
|
13636
|
+
const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
|
|
13637
|
+
if (profileSupplied && !backend) {
|
|
13638
|
+
await data.respond(t("login.profile_needs_backend"));
|
|
13639
|
+
return;
|
|
13640
|
+
}
|
|
13396
13641
|
if (backend) {
|
|
13397
|
-
const text = await this.startLoginSession(backend, chat);
|
|
13642
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13398
13643
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13399
13644
|
return;
|
|
13400
13645
|
}
|
|
@@ -14251,6 +14496,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14251
14496
|
});
|
|
14252
14497
|
}
|
|
14253
14498
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
14499
|
+
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
14500
|
+
runtimeSwitchWaiters = new Map();
|
|
14501
|
+
/**
|
|
14502
|
+
* #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
|
|
14503
|
+
* when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
|
|
14504
|
+
* "not confirmed".
|
|
14505
|
+
*/
|
|
14506
|
+
runRuntimeModelSwitch(instanceName, model) {
|
|
14507
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14508
|
+
if (!ipc)
|
|
14509
|
+
return Promise.resolve({ outcome: "not_sent" });
|
|
14510
|
+
const id = `switch-${randomUUID()}`;
|
|
14511
|
+
return new Promise(resolve => {
|
|
14512
|
+
const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
|
|
14513
|
+
timer.unref?.();
|
|
14514
|
+
this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
14515
|
+
const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
|
|
14516
|
+
if (!sent) {
|
|
14517
|
+
clearTimeout(timer);
|
|
14518
|
+
this.runtimeSwitchWaiters.delete(id);
|
|
14519
|
+
resolve({ outcome: "not_sent" });
|
|
14520
|
+
return;
|
|
14521
|
+
}
|
|
14522
|
+
this.logger.info({ instanceName, model }, "Runtime model switch sent");
|
|
14523
|
+
});
|
|
14524
|
+
}
|
|
14525
|
+
/**
|
|
14526
|
+
* #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
|
|
14527
|
+
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
14528
|
+
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
14529
|
+
*/
|
|
14530
|
+
claudeModelKnown(instanceName, model) {
|
|
14531
|
+
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
14532
|
+
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
14533
|
+
const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
|
|
14534
|
+
const lower = model.trim().toLowerCase();
|
|
14535
|
+
const bare = lower.replace(/\[1m\]$/, "");
|
|
14536
|
+
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
14537
|
+
|| api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
|
|
14538
|
+
|| (typeof configured === "string" && configured.toLowerCase() === lower)
|
|
14539
|
+
|| (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
|
|
14540
|
+
return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
|
|
14541
|
+
}
|
|
14254
14542
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14255
14543
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14256
14544
|
if (!ipc) {
|
|
@@ -14850,7 +15138,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14850
15138
|
try {
|
|
14851
15139
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14852
15140
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14853
|
-
? await
|
|
15141
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15142
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15143
|
+
if (progressMsgId)
|
|
15144
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15145
|
+
else
|
|
15146
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15147
|
+
}
|
|
15148
|
+
else
|
|
15149
|
+
await pending.respond(text);
|
|
15150
|
+
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
14854
15151
|
}
|
|
14855
15152
|
catch (err) {
|
|
14856
15153
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -15175,7 +15472,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15175
15472
|
try {
|
|
15176
15473
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
15177
15474
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
15178
|
-
? await
|
|
15475
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15476
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15477
|
+
if (progressMsgId)
|
|
15478
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15479
|
+
else
|
|
15480
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15481
|
+
}
|
|
15482
|
+
else
|
|
15483
|
+
await pending.respond(text);
|
|
15484
|
+
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
15179
15485
|
}
|
|
15180
15486
|
catch (err) {
|
|
15181
15487
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15309,21 +15615,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15309
15615
|
}
|
|
15310
15616
|
catch { /* default restart */ }
|
|
15311
15617
|
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
15618
|
+
// #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
|
|
15619
|
+
// nothing is typed or saved for an unknown one.
|
|
15620
|
+
if (backendName === "claude-code") {
|
|
15621
|
+
const check = this.claudeModelKnown(instanceName, model);
|
|
15622
|
+
if (!check.known)
|
|
15623
|
+
return t("model.unknown_name", model, check.names.join(", "));
|
|
15624
|
+
}
|
|
15312
15625
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15313
15626
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15314
15627
|
}
|
|
15628
|
+
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
15629
|
+
if (strategy === "runtime" && backendName === "claude-code") {
|
|
15630
|
+
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
15631
|
+
if (result.outcome === "mismatch")
|
|
15632
|
+
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
15633
|
+
if (result.outcome === "not_sent")
|
|
15634
|
+
return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
|
|
15635
|
+
const persistedNow = this.persistInstanceModel(instanceName, model);
|
|
15636
|
+
if (!persistedNow)
|
|
15637
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15638
|
+
const head = result.outcome === "switched"
|
|
15639
|
+
? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
|
|
15640
|
+
: t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
|
|
15641
|
+
return `${warn}${head}${this.effortSuffix(instanceName)}`;
|
|
15642
|
+
}
|
|
15315
15643
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15316
15644
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
15317
|
-
|
|
15318
|
-
if (this.fleetConfig?.instances[instanceName]) {
|
|
15319
|
-
this.fleetConfig.instances[instanceName].model = model;
|
|
15320
|
-
this.saveFleetConfig();
|
|
15321
|
-
persisted = true;
|
|
15322
|
-
}
|
|
15323
|
-
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
15324
|
-
persisted = true;
|
|
15325
|
-
}
|
|
15326
|
-
if (!persisted)
|
|
15645
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15327
15646
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15328
15647
|
if (strategy === "runtime") {
|
|
15329
15648
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15332,6 +15651,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15332
15651
|
await this.restartSingleInstance(instanceName);
|
|
15333
15652
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15334
15653
|
}
|
|
15654
|
+
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
15655
|
+
persistInstanceModel(instanceName, model) {
|
|
15656
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
15657
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
15658
|
+
this.saveFleetConfig();
|
|
15659
|
+
return true;
|
|
15660
|
+
}
|
|
15661
|
+
return this.classicChannels?.setModelByInstance(instanceName, model) === true;
|
|
15662
|
+
}
|
|
15335
15663
|
/**
|
|
15336
15664
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15337
15665
|
*
|
|
@@ -15442,6 +15770,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15442
15770
|
}
|
|
15443
15771
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15444
15772
|
async handleClassicStartSlash(data, adapterId) {
|
|
15773
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
15774
|
+
}
|
|
15775
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15445
15776
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15446
15777
|
if (!requestedBackend) {
|
|
15447
15778
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15558,7 +15889,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15558
15889
|
// failure. The selected backend is still attempted as requested.
|
|
15559
15890
|
if (warning)
|
|
15560
15891
|
await pending.complete(warning, pending.messageId);
|
|
15561
|
-
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
15892
|
+
const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
|
|
15562
15893
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15563
15894
|
}
|
|
15564
15895
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -15639,6 +15970,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15639
15970
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
15640
15971
|
if (!ch)
|
|
15641
15972
|
return t("classic.no_agent");
|
|
15973
|
+
// #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
|
|
15974
|
+
// instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
|
|
15975
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
|
|
15976
|
+
&& !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
|
|
15977
|
+
try {
|
|
15978
|
+
this.webPrefs.forgetInstance(ch.instanceName);
|
|
15979
|
+
}
|
|
15980
|
+
catch (err) {
|
|
15981
|
+
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
|
|
15982
|
+
}
|
|
15983
|
+
}
|
|
15642
15984
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
15643
15985
|
await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
|
|
15644
15986
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
@@ -18013,14 +18355,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18013
18355
|
return;
|
|
18014
18356
|
}
|
|
18015
18357
|
(async () => {
|
|
18358
|
+
let held = false;
|
|
18016
18359
|
try {
|
|
18017
18360
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
18018
|
-
await
|
|
18361
|
+
await withSpawnHoldReply(async (message) => {
|
|
18362
|
+
held = true;
|
|
18363
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
18364
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
18365
|
+
}, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
18019
18366
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18367
|
+
if (held)
|
|
18368
|
+
return;
|
|
18020
18369
|
res.writeHead(200);
|
|
18021
18370
|
res.end(JSON.stringify({ ok: true }));
|
|
18022
18371
|
}
|
|
18023
18372
|
catch (err) {
|
|
18373
|
+
if (held) {
|
|
18374
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
18375
|
+
return;
|
|
18376
|
+
}
|
|
18024
18377
|
res.writeHead(500);
|
|
18025
18378
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
18026
18379
|
}
|
|
@@ -18035,15 +18388,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18035
18388
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
18036
18389
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
18037
18390
|
(async () => {
|
|
18391
|
+
let held = false;
|
|
18038
18392
|
try {
|
|
18039
|
-
await
|
|
18393
|
+
await withSpawnHoldReply(async (message) => {
|
|
18394
|
+
held = true;
|
|
18395
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
18396
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
18397
|
+
}, () => this.restartSingleInstance(name, { explicit: true }));
|
|
18040
18398
|
this.logger.info({ name }, "Instance restarted");
|
|
18041
18399
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18400
|
+
if (held)
|
|
18401
|
+
return;
|
|
18042
18402
|
res.writeHead(200);
|
|
18043
18403
|
res.end(JSON.stringify({ restarted: name }));
|
|
18044
18404
|
}
|
|
18045
18405
|
catch (err) {
|
|
18046
18406
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
18407
|
+
if (held)
|
|
18408
|
+
return;
|
|
18047
18409
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
18048
18410
|
res.writeHead(status);
|
|
18049
18411
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -18357,6 +18719,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18357
18719
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
18358
18720
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
18359
18721
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
18722
|
+
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
18723
|
+
prefs_rev: this.webPrefs.rev(),
|
|
18724
|
+
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
18725
|
+
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
18726
|
+
host_memory: this.hostMemoryIndicator(),
|
|
18360
18727
|
};
|
|
18361
18728
|
}
|
|
18362
18729
|
}
|