@songsid/agend 2.2.0-beta.3 → 2.2.0-beta.5
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/backend/claude-code.d.ts +46 -2
- package/dist/backend/claude-code.js +125 -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 +13 -0
- package/dist/channel/adapters/discord.js +42 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -1
- package/dist/channel/adapters/telegram.js +27 -2
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +22 -1
- package/dist/cli.js +35 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +7 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +96 -0
- package/dist/daemon.js +339 -16
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +18 -5
- package/dist/delivery-outbox.js +121 -14
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -0
- package/dist/fleet-manager.d.ts +89 -3
- package/dist/fleet-manager.js +541 -86
- 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 +61 -0
- package/dist/outbound-handlers.js +100 -35
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-preview.d.ts +100 -0
- package/dist/public-preview.js +233 -0
- package/dist/public-preview.js.map +1 -0
- package/dist/public-web-gateway.js +6 -4
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +20 -0
- package/dist/public-web-link.js +26 -1
- package/dist/public-web-link.js.map +1 -1
- package/dist/restart-progress.d.ts +29 -0
- package/dist/restart-progress.js +23 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.js +10 -4
- package/dist/settings-api.js.map +1 -1
- 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/topic-commands.js +9 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +5 -1
- package/dist/tunnel/purpose-lane.js +14 -7
- package/dist/tunnel/purpose-lane.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 +8 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +25 -0
- package/dist/ui/chat-strings.js +14 -2
- package/dist/ui/chat-thread.js +28 -10
- package/dist/ui/panel-chat.js +26 -11
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +199 -57
- package/dist/ui/panel-needs.js +4 -3
- package/dist/ui/panel-settings.js +4 -2
- package/dist/ui/preview-panel.js +48 -14
- package/dist/ui/preview-status.js +28 -0
- package/dist/ui/preview.js +40 -3
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +4 -0
- package/dist/ui/shared/app-prefs.js +1 -0
- package/dist/ui/shared/app-shell.js +146 -2
- package/dist/ui/shared/app-store.js +3 -1
- package/dist/ui/shared/app.css +47 -20
- package/dist/ui/shared/instance-nav.js +79 -23
- package/dist/ui/shared/view-strings.js +10 -0
- 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/update-install.d.ts +8 -0
- package/dist/update-install.js +9 -3
- package/dist/update-install.js.map +1 -1
- package/dist/update-marker.d.ts +40 -0
- package/dist/update-marker.js +49 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +34 -5
- package/dist/usage/providers.js +160 -27
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.d.ts +6 -2
- package/dist/usage/statusline-usage.js +10 -4
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/web-api.d.ts +20 -0
- package/dist/web-api.js +216 -30
- 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.js +2 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +14 -1
- package/dist/web-preview.js +35 -13
- package/dist/web-preview.js.map +1 -1
- package/dist/web-schedules.d.ts +12 -0
- package/dist/web-schedules.js +34 -0
- package/dist/web-schedules.js.map +1 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -4,11 +4,14 @@ 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";
|
|
10
11
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
11
12
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
13
|
+
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
14
|
+
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
12
15
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
13
16
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
14
17
|
import { withinBudget } from "./monotonic-budget.js";
|
|
@@ -27,7 +30,7 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
27
30
|
import { fileURLToPath } from "node:url";
|
|
28
31
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
29
32
|
import { fleetLabel } from "./fleet-label.js";
|
|
30
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
33
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
31
34
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
32
35
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
33
36
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -75,7 +78,7 @@ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, che
|
|
|
75
78
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
76
79
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
77
80
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
78
|
-
import { DeliveryOutbox, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
81
|
+
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
79
82
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
80
83
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
81
84
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
@@ -129,6 +132,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
129
132
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
130
133
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
131
134
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
135
|
+
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
132
136
|
import { WebPrefsStore } from "./web-prefs.js";
|
|
133
137
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
134
138
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
@@ -151,7 +155,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
|
151
155
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
152
156
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
153
157
|
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
154
|
-
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
158
|
+
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
155
159
|
import { runBeforeDeadline } from "./deadline.js";
|
|
156
160
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
157
161
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -179,7 +183,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
|
179
183
|
import { buildOrgChart } from "./web-org.js";
|
|
180
184
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
181
185
|
import { runWebCommand } from "./web-commands.js";
|
|
182
|
-
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
186
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
183
187
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
184
188
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
185
189
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
@@ -189,7 +193,7 @@ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.j
|
|
|
189
193
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
190
194
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
191
195
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
192
|
-
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
196
|
+
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
193
197
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
194
198
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
195
199
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -199,10 +203,10 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
199
203
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
200
204
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
201
205
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
202
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
206
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
203
207
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
204
208
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
205
|
-
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
209
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
206
210
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
207
211
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
208
212
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -428,6 +432,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
428
432
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
429
433
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
430
434
|
*/
|
|
435
|
+
/** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
|
|
436
|
+
export const RUNTIME_SWITCH_RESULT_MS = 12_000;
|
|
431
437
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
432
438
|
/**
|
|
433
439
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -731,6 +737,13 @@ export class FleetManager {
|
|
|
731
737
|
updateProgressEditRunning = false;
|
|
732
738
|
lastUpdateProgressText = null;
|
|
733
739
|
updateCompletionTipText = null;
|
|
740
|
+
/**
|
|
741
|
+
* #1660: an adopted update whose retire outcome is still owed. The
|
|
742
|
+
* completion summary was (or will be) posted; this watch appends the
|
|
743
|
+
* cleanup outcome to the same receipt instead of letting stage edits
|
|
744
|
+
* clobber it — and refuses a receipt that no longer matches (checklist 3/5).
|
|
745
|
+
*/
|
|
746
|
+
updateCleanupWatch = null;
|
|
734
747
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
735
748
|
fullRestartLauncher = launchFullRestartHelper;
|
|
736
749
|
eventLogPruneTimer = null;
|
|
@@ -750,8 +763,8 @@ export class FleetManager {
|
|
|
750
763
|
memoryLogAt = null;
|
|
751
764
|
memoryDebugAt = null;
|
|
752
765
|
memoryLogLevel = null;
|
|
753
|
-
|
|
754
|
-
|
|
766
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
767
|
+
memoryIndicatorShown = null;
|
|
755
768
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
756
769
|
mirrorBuffer = [];
|
|
757
770
|
mirrorTimer = null;
|
|
@@ -763,6 +776,8 @@ export class FleetManager {
|
|
|
763
776
|
webChatStore = null;
|
|
764
777
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
765
778
|
webFiles = new WebFileLedger();
|
|
779
|
+
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
780
|
+
seenUsers = new SeenUsers();
|
|
766
781
|
webPrefsStore;
|
|
767
782
|
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
768
783
|
get webPrefs() {
|
|
@@ -1330,7 +1345,9 @@ export class FleetManager {
|
|
|
1330
1345
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1331
1346
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1332
1347
|
try {
|
|
1333
|
-
|
|
1348
|
+
const now = new Date();
|
|
1349
|
+
this.expireReplyObligations(now);
|
|
1350
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1334
1351
|
}
|
|
1335
1352
|
catch (err) {
|
|
1336
1353
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1348,6 +1365,7 @@ export class FleetManager {
|
|
|
1348
1365
|
return;
|
|
1349
1366
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1350
1367
|
try {
|
|
1368
|
+
this.expireReplyObligations(now);
|
|
1351
1369
|
for (const name of this.daemons.keys()) {
|
|
1352
1370
|
if (stateOf(name) === "idle")
|
|
1353
1371
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1359,6 +1377,15 @@ export class FleetManager {
|
|
|
1359
1377
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1360
1378
|
}
|
|
1361
1379
|
}
|
|
1380
|
+
expireReplyObligations(now) {
|
|
1381
|
+
const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
|
|
1382
|
+
const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
|
|
1383
|
+
? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
|
|
1384
|
+
for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
|
|
1385
|
+
this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
|
|
1386
|
+
correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1362
1389
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1363
1390
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1364
1391
|
return;
|
|
@@ -1598,6 +1625,14 @@ export class FleetManager {
|
|
|
1598
1625
|
return Math.max(1, Math.min(20, explicit));
|
|
1599
1626
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1600
1627
|
}
|
|
1628
|
+
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
1629
|
+
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
1630
|
+
hostMemoryIndicator() {
|
|
1631
|
+
if (!this.memoryPressure)
|
|
1632
|
+
return null;
|
|
1633
|
+
const level = this.memoryPressure.snapshot().level;
|
|
1634
|
+
return level === "elevated" || level === "critical" ? level : null;
|
|
1635
|
+
}
|
|
1601
1636
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1602
1637
|
reportMemoryPressure(snapshot) {
|
|
1603
1638
|
if (this.shuttingDown)
|
|
@@ -1608,6 +1643,12 @@ export class FleetManager {
|
|
|
1608
1643
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1609
1644
|
}
|
|
1610
1645
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1646
|
+
// #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
|
|
1647
|
+
const indicator = this.hostMemoryIndicator();
|
|
1648
|
+
if (indicator !== this.memoryIndicatorShown) {
|
|
1649
|
+
this.memoryIndicatorShown = indicator;
|
|
1650
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1651
|
+
}
|
|
1611
1652
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1612
1653
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1613
1654
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1628,20 +1669,6 @@ export class FleetManager {
|
|
|
1628
1669
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1629
1670
|
}
|
|
1630
1671
|
this.memoryLogLevel = snapshot.level;
|
|
1631
|
-
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1632
|
-
return;
|
|
1633
|
-
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1634
|
-
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1635
|
-
return;
|
|
1636
|
-
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1637
|
-
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1638
|
-
const text = this.memoryPressure.platform === "darwin"
|
|
1639
|
-
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1640
|
-
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1641
|
-
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1642
|
-
this.memoryNoticeAt = now;
|
|
1643
|
-
this.memoryNoticeLevel = snapshot.level;
|
|
1644
|
-
}
|
|
1645
1672
|
}
|
|
1646
1673
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1647
1674
|
bindStormWindowEvents() {
|
|
@@ -1772,14 +1799,17 @@ export class FleetManager {
|
|
|
1772
1799
|
// the next tunnel and says so, it does not block the fleet.
|
|
1773
1800
|
this.announceToolPermissionsChange();
|
|
1774
1801
|
this.tightenInstanceDirectories();
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1802
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
|
|
1803
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
|
|
1804
|
+
void reapStaleTunnel(leaseDir)
|
|
1805
|
+
.then(outcome => {
|
|
1806
|
+
if (outcome.kind === "manual")
|
|
1807
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
1808
|
+
else if (outcome.kind === "reaped")
|
|
1809
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
1810
|
+
})
|
|
1811
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
1812
|
+
}
|
|
1783
1813
|
// An existing installation has never written the setup marker — it predates
|
|
1784
1814
|
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
1785
1815
|
// exists. A fleet that just came up on a config with agents in it is proof
|
|
@@ -1802,7 +1832,13 @@ export class FleetManager {
|
|
|
1802
1832
|
// We are the post-update fleet: the update is over by definition. Clearing
|
|
1803
1833
|
// it here (rather than in the update command, which exits before the new
|
|
1804
1834
|
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1805
|
-
|
|
1835
|
+
// #1660 exception: an update still owing its retire outcome keeps its
|
|
1836
|
+
// receipt until the cleanup watch resolves it — deleting it here is what
|
|
1837
|
+
// lost the warning. Terminal stages are still cleared unconditionally.
|
|
1838
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1839
|
+
if (!(leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")) {
|
|
1840
|
+
clearUpdateMarker(this.dataDir);
|
|
1841
|
+
}
|
|
1806
1842
|
if (this.reloadPending)
|
|
1807
1843
|
this.scheduleReconcile();
|
|
1808
1844
|
void this.sweepOrphanedCancelButtons();
|
|
@@ -3310,6 +3346,9 @@ export class FleetManager {
|
|
|
3310
3346
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3311
3347
|
*/
|
|
3312
3348
|
async dispatchSlash(slash, adapterId, adapter) {
|
|
3349
|
+
return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
|
|
3350
|
+
}
|
|
3351
|
+
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
3313
3352
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3314
3353
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3315
3354
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -4684,7 +4723,13 @@ export class FleetManager {
|
|
|
4684
4723
|
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4685
4724
|
}
|
|
4686
4725
|
tunnelPurposeLane = null;
|
|
4726
|
+
previewTunnel = null;
|
|
4687
4727
|
publicWebLink = null;
|
|
4728
|
+
/** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
|
|
4729
|
+
getPreviewTunnel() {
|
|
4730
|
+
return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
|
|
4731
|
+
log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
|
|
4732
|
+
}
|
|
4688
4733
|
getTunnelLane() {
|
|
4689
4734
|
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4690
4735
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
@@ -4737,6 +4782,13 @@ export class FleetManager {
|
|
|
4737
4782
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4738
4783
|
},
|
|
4739
4784
|
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
4785
|
+
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
4786
|
+
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
4787
|
+
return null;
|
|
4788
|
+
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
4789
|
+
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
4790
|
+
log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
|
|
4791
|
+
},
|
|
4740
4792
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4741
4793
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4742
4794
|
const world = this.worlds.get(owner.adapterId);
|
|
@@ -4769,6 +4821,16 @@ export class FleetManager {
|
|
|
4769
4821
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4770
4822
|
}
|
|
4771
4823
|
}
|
|
4824
|
+
/**
|
|
4825
|
+
* #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
|
|
4826
|
+
* Returns null when no botUsername is known or the username fails Telegram's format validation.
|
|
4827
|
+
*/
|
|
4828
|
+
tgStartWebUrl(adapterId) {
|
|
4829
|
+
const username = this.worlds.get(adapterId)?.botUsername;
|
|
4830
|
+
if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
|
|
4831
|
+
return null;
|
|
4832
|
+
return `https://t.me/${username}?start=web`;
|
|
4833
|
+
}
|
|
4772
4834
|
async sendLocalDashboard(data, adapterId) {
|
|
4773
4835
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4774
4836
|
await data.respond(t("not_authorized"));
|
|
@@ -4890,10 +4952,12 @@ export class FleetManager {
|
|
|
4890
4952
|
const adapter = owner.binding;
|
|
4891
4953
|
const status = this.getPublicWebStatus();
|
|
4892
4954
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4955
|
+
const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4893
4956
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4894
4957
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4895
4958
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
4896
|
-
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
4959
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
4960
|
+
+ (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
|
|
4897
4961
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4898
4962
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4899
4963
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
@@ -5036,7 +5100,11 @@ export class FleetManager {
|
|
|
5036
5100
|
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
5037
5101
|
await progress;
|
|
5038
5102
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
5039
|
-
|
|
5103
|
+
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5104
|
+
const failUrlButtons = (!ok && owner.binding.type === "telegram")
|
|
5105
|
+
? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
|
|
5106
|
+
: undefined;
|
|
5107
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
5040
5108
|
return true;
|
|
5041
5109
|
}
|
|
5042
5110
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5278,6 +5346,53 @@ export class FleetManager {
|
|
|
5278
5346
|
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
5279
5347
|
}
|
|
5280
5348
|
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
5349
|
+
/**
|
|
5350
|
+
* #1660: arm the post-completion cleanup watch and make sure its monitor
|
|
5351
|
+
* actually runs. The new fleet adopts the receipt at boot but nothing else
|
|
5352
|
+
* starts this timer on that path (Prism a) — without this, the owed
|
|
5353
|
+
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5354
|
+
*/
|
|
5355
|
+
armCleanupWatch(receipt, summary) {
|
|
5356
|
+
this.updateCleanupWatch = { receipt, summary };
|
|
5357
|
+
if (!this.updateProgressTimer)
|
|
5358
|
+
this.startUpdateProgressMonitor();
|
|
5359
|
+
}
|
|
5360
|
+
/**
|
|
5361
|
+
* #1660: append a recorded cleanup outcome to the adopted update message,
|
|
5362
|
+
* then release the receipt — but only if this receipt still owns the
|
|
5363
|
+
* marker. The await yields, so a newer receipt (e.g. a full-restart that
|
|
5364
|
+
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5365
|
+
*/
|
|
5366
|
+
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5367
|
+
const text = formatRestartProgressCompletion("update", {
|
|
5368
|
+
...watch.summary,
|
|
5369
|
+
warningText: warning,
|
|
5370
|
+
warningCommand: cleanupCommand,
|
|
5371
|
+
}, watch.receipt.startedAt);
|
|
5372
|
+
const stopWatching = () => {
|
|
5373
|
+
this.updateCleanupWatch = null;
|
|
5374
|
+
if (this.updateProgressTimer)
|
|
5375
|
+
clearInterval(this.updateProgressTimer);
|
|
5376
|
+
this.updateProgressTimer = null;
|
|
5377
|
+
};
|
|
5378
|
+
if (text !== this.lastUpdateProgressText) {
|
|
5379
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
5380
|
+
if (!adapter)
|
|
5381
|
+
return; // retry on a later tick
|
|
5382
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
5383
|
+
this.lastUpdateProgressText = text;
|
|
5384
|
+
}
|
|
5385
|
+
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
5386
|
+
if (verdict.action === "drop") {
|
|
5387
|
+
// Someone else owns the marker now — leave it entirely alone.
|
|
5388
|
+
this.updateCleanupWatch = null;
|
|
5389
|
+
return;
|
|
5390
|
+
}
|
|
5391
|
+
if (verdict.action === "wait")
|
|
5392
|
+
return; // not terminal; keep watching
|
|
5393
|
+
clearUpdateMarker(this.dataDir);
|
|
5394
|
+
stopWatching();
|
|
5395
|
+
}
|
|
5281
5396
|
startUpdateProgressMonitor(initialAdapter) {
|
|
5282
5397
|
if (this.updateProgressTimer)
|
|
5283
5398
|
clearInterval(this.updateProgressTimer);
|
|
@@ -5291,6 +5406,48 @@ export class FleetManager {
|
|
|
5291
5406
|
this.updateProgressTimer = null;
|
|
5292
5407
|
return;
|
|
5293
5408
|
}
|
|
5409
|
+
// #1660: the completion summary was already posted; only watch for
|
|
5410
|
+
// the retire outcome owed to this exact receipt. Never stage-edit
|
|
5411
|
+
// here (that would clobber the posted completion), and never touch a
|
|
5412
|
+
// receipt that no longer matches (checklist 3/5).
|
|
5413
|
+
const watch = this.updateCleanupWatch;
|
|
5414
|
+
if (watch) {
|
|
5415
|
+
const stopWatching = () => {
|
|
5416
|
+
this.updateCleanupWatch = null;
|
|
5417
|
+
if (this.updateProgressTimer)
|
|
5418
|
+
clearInterval(this.updateProgressTimer);
|
|
5419
|
+
this.updateProgressTimer = null;
|
|
5420
|
+
};
|
|
5421
|
+
const verdict = cleanupWatchVerdict(marker, watch.receipt);
|
|
5422
|
+
if (verdict.action === "drop") {
|
|
5423
|
+
// Receipt gone or replaced (e.g. a later full-restart adopted the
|
|
5424
|
+
// marker): not ours — leave it to its own lifecycle, drop the watch.
|
|
5425
|
+
this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
|
|
5426
|
+
this.updateCleanupWatch = null;
|
|
5427
|
+
}
|
|
5428
|
+
else if (verdict.action === "release") {
|
|
5429
|
+
// Done silent, or the updater died past the max age: release it.
|
|
5430
|
+
clearUpdateMarker(this.dataDir);
|
|
5431
|
+
stopWatching();
|
|
5432
|
+
return;
|
|
5433
|
+
}
|
|
5434
|
+
else if (verdict.action === "wait") {
|
|
5435
|
+
return;
|
|
5436
|
+
}
|
|
5437
|
+
else {
|
|
5438
|
+
this.updateProgressEditRunning = true;
|
|
5439
|
+
try {
|
|
5440
|
+
await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
|
|
5441
|
+
}
|
|
5442
|
+
catch (err) {
|
|
5443
|
+
this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
|
|
5444
|
+
}
|
|
5445
|
+
finally {
|
|
5446
|
+
this.updateProgressEditRunning = false;
|
|
5447
|
+
}
|
|
5448
|
+
return;
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5294
5451
|
const target = marker.progress.target;
|
|
5295
5452
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5296
5453
|
if (!adapter)
|
|
@@ -5381,7 +5538,11 @@ export class FleetManager {
|
|
|
5381
5538
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5382
5539
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5383
5540
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5384
|
-
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config =>
|
|
5541
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
|
|
5542
|
+
const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
|
|
5543
|
+
// #1699 follow-up: each instance's own login — a profile is probed with its own environment.
|
|
5544
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
|
|
5545
|
+
}));
|
|
5385
5546
|
// Start tmux control mode client for idle detection
|
|
5386
5547
|
if (!this.controlClient) {
|
|
5387
5548
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -5786,7 +5947,13 @@ export class FleetManager {
|
|
|
5786
5947
|
const _require = createRequire(import.meta.url);
|
|
5787
5948
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5788
5949
|
await progressStart;
|
|
5789
|
-
|
|
5950
|
+
// #1660: when the adopted update still owes its retire outcome, the
|
|
5951
|
+
// completion posts without the warning and this same summary object is
|
|
5952
|
+
// kept for the watch to append it to the same receipt later. Waiting
|
|
5953
|
+
// for cleanup before READY would deadlock with the updater (it retires
|
|
5954
|
+
// only after the restart returns), so READY and cleanup notification
|
|
5955
|
+
// have separate lifecycles (review checklist 3/5).
|
|
5956
|
+
const completionSummary = {
|
|
5790
5957
|
running: started,
|
|
5791
5958
|
total,
|
|
5792
5959
|
version: agendVersion,
|
|
@@ -5798,7 +5965,14 @@ export class FleetManager {
|
|
|
5798
5965
|
return tip ? this.formatTip(tip) : undefined;
|
|
5799
5966
|
})()
|
|
5800
5967
|
: undefined,
|
|
5801
|
-
}
|
|
5968
|
+
};
|
|
5969
|
+
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
5970
|
+
if (pendingUpdateProgress && pendingProgressOperation === "update"
|
|
5971
|
+
&& pendingUpdateProgress.progress.cleanup === "pending") {
|
|
5972
|
+
const receipt = updateReceiptOf(pendingUpdateProgress);
|
|
5973
|
+
if (receipt)
|
|
5974
|
+
this.armCleanupWatch(receipt, completionSummary);
|
|
5975
|
+
}
|
|
5802
5976
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5803
5977
|
let text;
|
|
5804
5978
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5820,6 +5994,9 @@ export class FleetManager {
|
|
|
5820
5994
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5821
5995
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5822
5996
|
}
|
|
5997
|
+
// #1660: a pending cleanup warning cannot be present yet (the updater
|
|
5998
|
+
// retires only after the restart returns); the watch appends it to
|
|
5999
|
+
// the adopted message when the outcome lands. Nothing to add here.
|
|
5823
6000
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
5824
6001
|
}
|
|
5825
6002
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -6569,6 +6746,14 @@ export class FleetManager {
|
|
|
6569
6746
|
});
|
|
6570
6747
|
}
|
|
6571
6748
|
}
|
|
6749
|
+
else if (msg.type === "runtime_switch_result") {
|
|
6750
|
+
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
6751
|
+
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
6752
|
+
if (waiter) {
|
|
6753
|
+
this.runtimeSwitchWaiters.delete(msg.id);
|
|
6754
|
+
waiter(msg);
|
|
6755
|
+
}
|
|
6756
|
+
}
|
|
6572
6757
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6573
6758
|
this.cacheInstanceExecutionState(name, msg);
|
|
6574
6759
|
if (msg.type === "instance_state_response") {
|
|
@@ -7081,6 +7266,16 @@ export class FleetManager {
|
|
|
7081
7266
|
});
|
|
7082
7267
|
}
|
|
7083
7268
|
async handleInboundMessage(msg) {
|
|
7269
|
+
if (msg.isBotMessage)
|
|
7270
|
+
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7271
|
+
const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapters.get(msg.adapterId ?? "") ?? this.adapter;
|
|
7272
|
+
return withSpawnHoldReply(text => adapter?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7273
|
+
}
|
|
7274
|
+
async handleInboundMessageCore(msg) {
|
|
7275
|
+
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
7276
|
+
// the instance they belong to).
|
|
7277
|
+
if (!msg.isBotMessage && msg.userId && msg.username)
|
|
7278
|
+
this.seenUsers.record(msg.userId, msg.username);
|
|
7084
7279
|
// Platform author identity is the authority. This runs before routing,
|
|
7085
7280
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
7086
7281
|
const fleetBotIds = new Set();
|
|
@@ -7236,6 +7431,16 @@ export class FleetManager {
|
|
|
7236
7431
|
}
|
|
7237
7432
|
// Handle /start command
|
|
7238
7433
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
7434
|
+
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7435
|
+
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7436
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7437
|
+
if (requestedBackend === "web" && isPrivateChat) {
|
|
7438
|
+
if (this.isFleetAdmin(msg.userId, msg.adapterId ?? "")) {
|
|
7439
|
+
await msgAdapter?.sendText(chatId, t("dashboard.start_web_ack"));
|
|
7440
|
+
}
|
|
7441
|
+
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7442
|
+
return;
|
|
7443
|
+
}
|
|
7239
7444
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7240
7445
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7241
7446
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7244,7 +7449,6 @@ export class FleetManager {
|
|
|
7244
7449
|
return;
|
|
7245
7450
|
}
|
|
7246
7451
|
const channelName = msg.username || chatId;
|
|
7247
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7248
7452
|
if (requestedBackend) {
|
|
7249
7453
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7250
7454
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -9731,6 +9935,45 @@ export class FleetManager {
|
|
|
9731
9935
|
return null;
|
|
9732
9936
|
}
|
|
9733
9937
|
}
|
|
9938
|
+
/**
|
|
9939
|
+
* #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
|
|
9940
|
+
* the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
|
|
9941
|
+
* profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
|
|
9942
|
+
* (members, users, roles, channels).
|
|
9943
|
+
*/
|
|
9944
|
+
webMentionNames(query) {
|
|
9945
|
+
const bots = (q) => {
|
|
9946
|
+
const u = Object.create(null);
|
|
9947
|
+
if (!q.u.length)
|
|
9948
|
+
return { u };
|
|
9949
|
+
let own = new Map();
|
|
9950
|
+
try {
|
|
9951
|
+
own = this.ownBots();
|
|
9952
|
+
}
|
|
9953
|
+
catch { /* none known */ }
|
|
9954
|
+
const profiles = profileIdentities(this.dataDir);
|
|
9955
|
+
const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
9956
|
+
for (const [instance, connection] of own) {
|
|
9957
|
+
const id = this.adapters.get(connection)?.getBotUserId?.();
|
|
9958
|
+
if (!id || !q.u.includes(id))
|
|
9959
|
+
continue;
|
|
9960
|
+
const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9961
|
+
u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
|
|
9962
|
+
}
|
|
9963
|
+
return { u };
|
|
9964
|
+
};
|
|
9965
|
+
const seen = (q) => {
|
|
9966
|
+
const u = Object.create(null);
|
|
9967
|
+
for (const id of q.u) {
|
|
9968
|
+
const name = this.seenUsers.get(id);
|
|
9969
|
+
if (name)
|
|
9970
|
+
u[id] = name;
|
|
9971
|
+
}
|
|
9972
|
+
return { u };
|
|
9973
|
+
};
|
|
9974
|
+
const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
|
|
9975
|
+
return resolveMentions(query, [bots, seen, ...caches]);
|
|
9976
|
+
}
|
|
9734
9977
|
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
9735
9978
|
async webEmojiList() {
|
|
9736
9979
|
const adopted = [];
|
|
@@ -11174,6 +11417,48 @@ export class FleetManager {
|
|
|
11174
11417
|
});
|
|
11175
11418
|
}
|
|
11176
11419
|
cacheService = null;
|
|
11420
|
+
/**
|
|
11421
|
+
* #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
|
|
11422
|
+
* ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
|
|
11423
|
+
* (also for a backend that has no profiles, where the option is ignored).
|
|
11424
|
+
*/
|
|
11425
|
+
credentialProfileOf(name, backend) {
|
|
11426
|
+
if (!credentialHomeSpec(backend))
|
|
11427
|
+
return null;
|
|
11428
|
+
const cfg = this.fleetConfig?.instances?.[name];
|
|
11429
|
+
if (cfg)
|
|
11430
|
+
return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
|
|
11431
|
+
const launched = this.daemons.get(name)?.getConfigSnapshot?.();
|
|
11432
|
+
if (launched)
|
|
11433
|
+
return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
|
|
11434
|
+
if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
|
|
11435
|
+
return null;
|
|
11436
|
+
return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
|
|
11437
|
+
}
|
|
11438
|
+
/**
|
|
11439
|
+
* #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
|
|
11440
|
+
* (CLAUDE_CONFIG_DIR or ~/.claude).
|
|
11441
|
+
*/
|
|
11442
|
+
claudeConfigDirOf(name) {
|
|
11443
|
+
const profile = this.credentialProfileOf(name, "claude-code");
|
|
11444
|
+
return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
|
|
11445
|
+
}
|
|
11446
|
+
/** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
|
|
11447
|
+
cacheInstances() {
|
|
11448
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11449
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11450
|
+
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
11451
|
+
}));
|
|
11452
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
11453
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11454
|
+
name: ch.instanceName,
|
|
11455
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11456
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11457
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11458
|
+
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
11459
|
+
}));
|
|
11460
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11461
|
+
}
|
|
11177
11462
|
/**
|
|
11178
11463
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
11179
11464
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -11182,20 +11467,7 @@ export class FleetManager {
|
|
|
11182
11467
|
cacheReport(window) {
|
|
11183
11468
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
11184
11469
|
this.cacheService ??= new CacheService({
|
|
11185
|
-
|
|
11186
|
-
instances: () => {
|
|
11187
|
-
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11188
|
-
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11189
|
-
}));
|
|
11190
|
-
const taken = new Set(fleet.map(i => i.name));
|
|
11191
|
-
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11192
|
-
name: ch.instanceName,
|
|
11193
|
-
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11194
|
-
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11195
|
-
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11196
|
-
}));
|
|
11197
|
-
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11198
|
-
},
|
|
11470
|
+
instances: () => this.cacheInstances(),
|
|
11199
11471
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
11200
11472
|
claudeKey: claudeProjectKey,
|
|
11201
11473
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11382,7 +11654,9 @@ export class FleetManager {
|
|
|
11382
11654
|
}
|
|
11383
11655
|
async retireNonceButtons(pending, messageId, text,
|
|
11384
11656
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11385
|
-
editClicked
|
|
11657
|
+
editClicked,
|
|
11658
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
11659
|
+
urlButtons) {
|
|
11386
11660
|
this.webPromptGone(pending, text);
|
|
11387
11661
|
try {
|
|
11388
11662
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11392,7 +11666,12 @@ export class FleetManager {
|
|
|
11392
11666
|
}
|
|
11393
11667
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11394
11668
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11395
|
-
|
|
11669
|
+
if (urlButtons) {
|
|
11670
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
|
|
11671
|
+
}
|
|
11672
|
+
else {
|
|
11673
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
11674
|
+
}
|
|
11396
11675
|
}
|
|
11397
11676
|
catch (err) {
|
|
11398
11677
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -12006,9 +12285,12 @@ export class FleetManager {
|
|
|
12006
12285
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
12007
12286
|
* no button — the alert's own wording already tells them what to run.
|
|
12008
12287
|
*/
|
|
12009
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
12288
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
12010
12289
|
if (!LOGIN_FLOWS[backend])
|
|
12011
12290
|
return;
|
|
12291
|
+
if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
|
|
12292
|
+
return; // that profile signs in on the host
|
|
12293
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
12012
12294
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
12013
12295
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
12014
12296
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -12026,9 +12308,10 @@ export class FleetManager {
|
|
|
12026
12308
|
adapterId,
|
|
12027
12309
|
chatId,
|
|
12028
12310
|
threadId,
|
|
12029
|
-
message: t("login.offer",
|
|
12030
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
12311
|
+
message: t("login.offer", shown),
|
|
12312
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
12031
12313
|
expiredText: t("buttons.stale"),
|
|
12314
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12032
12315
|
});
|
|
12033
12316
|
}
|
|
12034
12317
|
/** Consume a General assist button exactly once. */
|
|
@@ -12841,7 +13124,7 @@ export class FleetManager {
|
|
|
12841
13124
|
fleetConfig: () => this.fleetConfig,
|
|
12842
13125
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12843
13126
|
eventLog: () => this.eventLog,
|
|
12844
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
13127
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12845
13128
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12846
13129
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12847
13130
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12850,11 +13133,12 @@ export class FleetManager {
|
|
|
12850
13133
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12851
13134
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12852
13135
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12853
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13136
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12854
13137
|
await this.postNonceButtonPromptOrThrow({
|
|
12855
13138
|
prefix, alertType: "login", instanceName,
|
|
12856
13139
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12857
13140
|
message, choices, expiredText,
|
|
13141
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12858
13142
|
});
|
|
12859
13143
|
},
|
|
12860
13144
|
});
|
|
@@ -13068,10 +13352,20 @@ export class FleetManager {
|
|
|
13068
13352
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
13069
13353
|
if (isRemovedBackend(wanted))
|
|
13070
13354
|
return removedBackendMessage(wanted);
|
|
13355
|
+
// #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
|
|
13356
|
+
// whose profiles sign in on the host, is refused here, not after an install it did not need.
|
|
13357
|
+
if (opts.profile !== undefined) {
|
|
13358
|
+
const admitted = admitProfileLogin(wanted, opts.profile);
|
|
13359
|
+
if ("refusal" in admitted)
|
|
13360
|
+
return admitted.refusal;
|
|
13361
|
+
opts = { ...opts, profile: admitted.profile };
|
|
13362
|
+
}
|
|
13071
13363
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
13072
13364
|
const flow = LOGIN_FLOWS[wanted];
|
|
13073
13365
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
13074
|
-
return
|
|
13366
|
+
return opts.profile !== undefined
|
|
13367
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
13368
|
+
: this.startInstallSession(wanted, chat);
|
|
13075
13369
|
}
|
|
13076
13370
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
13077
13371
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -13160,7 +13454,9 @@ export class FleetManager {
|
|
|
13160
13454
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
13161
13455
|
* message can say so instead of implying failure.
|
|
13162
13456
|
*/
|
|
13163
|
-
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS
|
|
13457
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
|
|
13458
|
+
/** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
|
|
13459
|
+
profile = null) {
|
|
13164
13460
|
const woken = [];
|
|
13165
13461
|
const restarted = [];
|
|
13166
13462
|
const pending = [];
|
|
@@ -13168,6 +13464,9 @@ export class FleetManager {
|
|
|
13168
13464
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
13169
13465
|
if (this.backendNameOf(name) !== backend)
|
|
13170
13466
|
continue;
|
|
13467
|
+
// Only the instances on the login that changed: another profile's (or the default's) credentials did not.
|
|
13468
|
+
if (this.credentialProfileOf(name, backend) !== profile)
|
|
13469
|
+
continue;
|
|
13171
13470
|
const status = this.getInstanceStatus(name);
|
|
13172
13471
|
if (status !== "paused" && status !== "running")
|
|
13173
13472
|
continue;
|
|
@@ -13205,14 +13504,16 @@ export class FleetManager {
|
|
|
13205
13504
|
if (claimed === "consumed")
|
|
13206
13505
|
return true;
|
|
13207
13506
|
const { entry, action: backend } = claimed;
|
|
13208
|
-
|
|
13209
|
-
|
|
13507
|
+
const profile = entry.loginProfile;
|
|
13508
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
|
|
13509
|
+
const chat = {
|
|
13210
13510
|
adapter: entry.adapter,
|
|
13211
13511
|
adapterId: entry.adapterId,
|
|
13212
13512
|
chatId: entry.chatId,
|
|
13213
13513
|
threadId: entry.threadId,
|
|
13214
13514
|
userId: data.userId,
|
|
13215
|
-
}
|
|
13515
|
+
};
|
|
13516
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13216
13517
|
if (text)
|
|
13217
13518
|
await this.postPromptOutcome(entry, text);
|
|
13218
13519
|
return true;
|
|
@@ -13226,11 +13527,13 @@ export class FleetManager {
|
|
|
13226
13527
|
return true;
|
|
13227
13528
|
const { entry, action } = claimed;
|
|
13228
13529
|
const backend = entry.instanceName;
|
|
13530
|
+
const profile = entry.loginProfile;
|
|
13531
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
13229
13532
|
if (action === "cancel") {
|
|
13230
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
13533
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
13231
13534
|
return true;
|
|
13232
13535
|
}
|
|
13233
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend",
|
|
13536
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
|
|
13234
13537
|
const text = await this.startLoginSession(backend, {
|
|
13235
13538
|
adapter: entry.adapter,
|
|
13236
13539
|
adapterId: entry.adapterId,
|
|
@@ -13242,6 +13545,7 @@ export class FleetManager {
|
|
|
13242
13545
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
13243
13546
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
13244
13547
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
13548
|
+
...(profile ? { profile } : {}),
|
|
13245
13549
|
});
|
|
13246
13550
|
if (text)
|
|
13247
13551
|
await this.postPromptOutcome(entry, text);
|
|
@@ -13269,7 +13573,7 @@ export class FleetManager {
|
|
|
13269
13573
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
13270
13574
|
installHandoff = null;
|
|
13271
13575
|
/** Start a CLI install session. Caller enforces admin. */
|
|
13272
|
-
async startInstallSession(backendArg, chat) {
|
|
13576
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
13273
13577
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
13274
13578
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
13275
13579
|
if (!info)
|
|
@@ -13353,7 +13657,8 @@ export class FleetManager {
|
|
|
13353
13657
|
// without a click.
|
|
13354
13658
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13355
13659
|
// not visible, startLoginSession would route straight back to an install.
|
|
13356
|
-
|
|
13660
|
+
// A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
|
|
13661
|
+
const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
13357
13662
|
if (next) {
|
|
13358
13663
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13359
13664
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13461,8 +13766,16 @@ export class FleetManager {
|
|
|
13461
13766
|
return;
|
|
13462
13767
|
}
|
|
13463
13768
|
const backend = String(data.options?.backend ?? "").trim();
|
|
13769
|
+
// #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
|
|
13770
|
+
// and one without a backend is refused rather than becoming the default login's picker.
|
|
13771
|
+
const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
|
|
13772
|
+
const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
|
|
13773
|
+
if (profileSupplied && !backend) {
|
|
13774
|
+
await data.respond(t("login.profile_needs_backend"));
|
|
13775
|
+
return;
|
|
13776
|
+
}
|
|
13464
13777
|
if (backend) {
|
|
13465
|
-
const text = await this.startLoginSession(backend, chat);
|
|
13778
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13466
13779
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13467
13780
|
return;
|
|
13468
13781
|
}
|
|
@@ -14319,6 +14632,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14319
14632
|
});
|
|
14320
14633
|
}
|
|
14321
14634
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
14635
|
+
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
14636
|
+
runtimeSwitchWaiters = new Map();
|
|
14637
|
+
/**
|
|
14638
|
+
* #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
|
|
14639
|
+
* when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
|
|
14640
|
+
* "not confirmed".
|
|
14641
|
+
*/
|
|
14642
|
+
runRuntimeModelSwitch(instanceName, model) {
|
|
14643
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14644
|
+
if (!ipc)
|
|
14645
|
+
return Promise.resolve({ outcome: "not_sent" });
|
|
14646
|
+
const id = `switch-${randomUUID()}`;
|
|
14647
|
+
return new Promise(resolve => {
|
|
14648
|
+
const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
|
|
14649
|
+
timer.unref?.();
|
|
14650
|
+
this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
14651
|
+
const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
|
|
14652
|
+
if (!sent) {
|
|
14653
|
+
clearTimeout(timer);
|
|
14654
|
+
this.runtimeSwitchWaiters.delete(id);
|
|
14655
|
+
resolve({ outcome: "not_sent" });
|
|
14656
|
+
return;
|
|
14657
|
+
}
|
|
14658
|
+
this.logger.info({ instanceName, model }, "Runtime model switch sent");
|
|
14659
|
+
});
|
|
14660
|
+
}
|
|
14661
|
+
/**
|
|
14662
|
+
* #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
|
|
14663
|
+
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
14664
|
+
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
14665
|
+
*/
|
|
14666
|
+
claudeModelKnown(instanceName, model) {
|
|
14667
|
+
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
14668
|
+
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
14669
|
+
const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
|
|
14670
|
+
const lower = model.trim().toLowerCase();
|
|
14671
|
+
const bare = lower.replace(/\[1m\]$/, "");
|
|
14672
|
+
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
14673
|
+
|| api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
|
|
14674
|
+
|| (typeof configured === "string" && configured.toLowerCase() === lower)
|
|
14675
|
+
|| (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
|
|
14676
|
+
return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
|
|
14677
|
+
}
|
|
14322
14678
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14323
14679
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14324
14680
|
if (!ipc) {
|
|
@@ -14918,7 +15274,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14918
15274
|
try {
|
|
14919
15275
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14920
15276
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14921
|
-
? await
|
|
15277
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15278
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15279
|
+
if (progressMsgId)
|
|
15280
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15281
|
+
else
|
|
15282
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15283
|
+
}
|
|
15284
|
+
else
|
|
15285
|
+
await pending.respond(text);
|
|
15286
|
+
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
14922
15287
|
}
|
|
14923
15288
|
catch (err) {
|
|
14924
15289
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -15243,7 +15608,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15243
15608
|
try {
|
|
15244
15609
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
15245
15610
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
15246
|
-
? await
|
|
15611
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15612
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15613
|
+
if (progressMsgId)
|
|
15614
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15615
|
+
else
|
|
15616
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15617
|
+
}
|
|
15618
|
+
else
|
|
15619
|
+
await pending.respond(text);
|
|
15620
|
+
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
15247
15621
|
}
|
|
15248
15622
|
catch (err) {
|
|
15249
15623
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15377,21 +15751,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15377
15751
|
}
|
|
15378
15752
|
catch { /* default restart */ }
|
|
15379
15753
|
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
15754
|
+
// #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
|
|
15755
|
+
// nothing is typed or saved for an unknown one.
|
|
15756
|
+
if (backendName === "claude-code") {
|
|
15757
|
+
const check = this.claudeModelKnown(instanceName, model);
|
|
15758
|
+
if (!check.known)
|
|
15759
|
+
return t("model.unknown_name", model, check.names.join(", "));
|
|
15760
|
+
}
|
|
15380
15761
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15381
15762
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15382
15763
|
}
|
|
15764
|
+
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
15765
|
+
if (strategy === "runtime" && backendName === "claude-code") {
|
|
15766
|
+
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
15767
|
+
if (result.outcome === "mismatch")
|
|
15768
|
+
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
15769
|
+
if (result.outcome === "not_sent")
|
|
15770
|
+
return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
|
|
15771
|
+
const persistedNow = this.persistInstanceModel(instanceName, model);
|
|
15772
|
+
if (!persistedNow)
|
|
15773
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15774
|
+
const head = result.outcome === "switched"
|
|
15775
|
+
? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
|
|
15776
|
+
: t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
|
|
15777
|
+
return `${warn}${head}${this.effortSuffix(instanceName)}`;
|
|
15778
|
+
}
|
|
15383
15779
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15384
15780
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
15385
|
-
|
|
15386
|
-
if (this.fleetConfig?.instances[instanceName]) {
|
|
15387
|
-
this.fleetConfig.instances[instanceName].model = model;
|
|
15388
|
-
this.saveFleetConfig();
|
|
15389
|
-
persisted = true;
|
|
15390
|
-
}
|
|
15391
|
-
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
15392
|
-
persisted = true;
|
|
15393
|
-
}
|
|
15394
|
-
if (!persisted)
|
|
15781
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15395
15782
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15396
15783
|
if (strategy === "runtime") {
|
|
15397
15784
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15400,6 +15787,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15400
15787
|
await this.restartSingleInstance(instanceName);
|
|
15401
15788
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15402
15789
|
}
|
|
15790
|
+
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
15791
|
+
persistInstanceModel(instanceName, model) {
|
|
15792
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
15793
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
15794
|
+
this.saveFleetConfig();
|
|
15795
|
+
return true;
|
|
15796
|
+
}
|
|
15797
|
+
return this.classicChannels?.setModelByInstance(instanceName, model) === true;
|
|
15798
|
+
}
|
|
15403
15799
|
/**
|
|
15404
15800
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15405
15801
|
*
|
|
@@ -15510,6 +15906,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15510
15906
|
}
|
|
15511
15907
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15512
15908
|
async handleClassicStartSlash(data, adapterId) {
|
|
15909
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
15910
|
+
}
|
|
15911
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15513
15912
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15514
15913
|
if (!requestedBackend) {
|
|
15515
15914
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15626,7 +16025,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15626
16025
|
// failure. The selected backend is still attempted as requested.
|
|
15627
16026
|
if (warning)
|
|
15628
16027
|
await pending.complete(warning, pending.messageId);
|
|
15629
|
-
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
16028
|
+
const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
|
|
15630
16029
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15631
16030
|
}
|
|
15632
16031
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -18092,14 +18491,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18092
18491
|
return;
|
|
18093
18492
|
}
|
|
18094
18493
|
(async () => {
|
|
18494
|
+
let held = false;
|
|
18095
18495
|
try {
|
|
18096
18496
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
18097
|
-
await
|
|
18497
|
+
await withSpawnHoldReply(async (message) => {
|
|
18498
|
+
held = true;
|
|
18499
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
18500
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
18501
|
+
}, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
18098
18502
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18503
|
+
if (held)
|
|
18504
|
+
return;
|
|
18099
18505
|
res.writeHead(200);
|
|
18100
18506
|
res.end(JSON.stringify({ ok: true }));
|
|
18101
18507
|
}
|
|
18102
18508
|
catch (err) {
|
|
18509
|
+
if (held) {
|
|
18510
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
18511
|
+
return;
|
|
18512
|
+
}
|
|
18103
18513
|
res.writeHead(500);
|
|
18104
18514
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
18105
18515
|
}
|
|
@@ -18114,15 +18524,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18114
18524
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
18115
18525
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
18116
18526
|
(async () => {
|
|
18527
|
+
let held = false;
|
|
18117
18528
|
try {
|
|
18118
|
-
await
|
|
18529
|
+
await withSpawnHoldReply(async (message) => {
|
|
18530
|
+
held = true;
|
|
18531
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
18532
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
18533
|
+
}, () => this.restartSingleInstance(name, { explicit: true }));
|
|
18119
18534
|
this.logger.info({ name }, "Instance restarted");
|
|
18120
18535
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18536
|
+
if (held)
|
|
18537
|
+
return;
|
|
18121
18538
|
res.writeHead(200);
|
|
18122
18539
|
res.end(JSON.stringify({ restarted: name }));
|
|
18123
18540
|
}
|
|
18124
18541
|
catch (err) {
|
|
18125
18542
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
18543
|
+
if (held)
|
|
18544
|
+
return;
|
|
18126
18545
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
18127
18546
|
res.writeHead(status);
|
|
18128
18547
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -18291,7 +18710,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18291
18710
|
this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
|
|
18292
18711
|
return;
|
|
18293
18712
|
}
|
|
18294
|
-
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig
|
|
18713
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
18714
|
+
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
18715
|
+
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
18295
18716
|
this.previewListener = listener;
|
|
18296
18717
|
listener.server.on("error", (err) => {
|
|
18297
18718
|
if (this.previewListener !== listener)
|
|
@@ -18340,6 +18761,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18340
18761
|
decided.reason = "Previews are not available on this fleet right now.";
|
|
18341
18762
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
18342
18763
|
}
|
|
18764
|
+
/**
|
|
18765
|
+
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
18766
|
+
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
18767
|
+
*/
|
|
18768
|
+
publicPreviewForUi(exposureId, dashboardOrigin) {
|
|
18769
|
+
const web = this.fleetConfig?.web;
|
|
18770
|
+
const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
|
|
18771
|
+
if (web?.preview === false)
|
|
18772
|
+
return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
|
|
18773
|
+
if (web?.preview_on_public_link === false)
|
|
18774
|
+
return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
|
|
18775
|
+
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
18776
|
+
if (configured) {
|
|
18777
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
18778
|
+
if (!listener)
|
|
18779
|
+
return off("publicLink", "Previews are not available on this fleet right now.");
|
|
18780
|
+
if (!configured.startsWith("https:"))
|
|
18781
|
+
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
18782
|
+
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
18783
|
+
}
|
|
18784
|
+
const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
|
|
18785
|
+
if (!status)
|
|
18786
|
+
return off("publicLink", "Previews are not offered on this public link.");
|
|
18787
|
+
// The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
|
|
18788
|
+
// origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
|
|
18789
|
+
if (status.state === "starting")
|
|
18790
|
+
return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
|
|
18791
|
+
if (status.state === "failed")
|
|
18792
|
+
return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
|
|
18793
|
+
return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
|
|
18794
|
+
}
|
|
18343
18795
|
getUiStatus() {
|
|
18344
18796
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
18345
18797
|
}
|
|
@@ -18438,6 +18890,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18438
18890
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
18439
18891
|
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
18440
18892
|
prefs_rev: this.webPrefs.rev(),
|
|
18893
|
+
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
18894
|
+
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
18895
|
+
host_memory: this.hostMemoryIndicator(),
|
|
18441
18896
|
};
|
|
18442
18897
|
}
|
|
18443
18898
|
}
|