@songsid/agend 2.2.0-beta.3 → 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/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 +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/config-validator.js +4 -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 +59 -3
- package/dist/fleet-manager.js +357 -71
- 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 +44 -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-web-gateway.js +4 -3
- 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/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/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-thread.js +26 -8
- package/dist/ui/panel-chat.js +17 -9
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +51 -22
- package/dist/ui/panel-settings.js +3 -1
- package/dist/ui/preview-panel.js +48 -14
- package/dist/ui/preview.js +5 -1
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-store.js +3 -1
- package/dist/ui/shared/app.css +11 -7
- package/dist/ui/shared/instance-nav.js +32 -7
- package/dist/ui/shared/view-strings.js +6 -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/web-api.d.ts +3 -0
- package/dist/web-api.js +115 -17
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +1 -1
- 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/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, isSilentScheduleDelivery } 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,7 @@ 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";
|
|
132
134
|
import { WebPrefsStore } from "./web-prefs.js";
|
|
133
135
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
134
136
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
@@ -151,7 +153,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
|
151
153
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
152
154
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
153
155
|
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
154
|
-
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";
|
|
155
157
|
import { runBeforeDeadline } from "./deadline.js";
|
|
156
158
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
157
159
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -179,7 +181,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
|
179
181
|
import { buildOrgChart } from "./web-org.js";
|
|
180
182
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
181
183
|
import { runWebCommand } from "./web-commands.js";
|
|
182
|
-
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
184
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
183
185
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
184
186
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
185
187
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
@@ -199,7 +201,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
199
201
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
200
202
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
201
203
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
202
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
204
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
203
205
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
204
206
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
205
207
|
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
@@ -428,6 +430,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
428
430
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
429
431
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
430
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;
|
|
431
435
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
432
436
|
/**
|
|
433
437
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -750,8 +754,8 @@ export class FleetManager {
|
|
|
750
754
|
memoryLogAt = null;
|
|
751
755
|
memoryDebugAt = null;
|
|
752
756
|
memoryLogLevel = null;
|
|
753
|
-
|
|
754
|
-
|
|
757
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
758
|
+
memoryIndicatorShown = null;
|
|
755
759
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
756
760
|
mirrorBuffer = [];
|
|
757
761
|
mirrorTimer = null;
|
|
@@ -763,6 +767,8 @@ export class FleetManager {
|
|
|
763
767
|
webChatStore = null;
|
|
764
768
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
765
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();
|
|
766
772
|
webPrefsStore;
|
|
767
773
|
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
768
774
|
get webPrefs() {
|
|
@@ -1330,7 +1336,9 @@ export class FleetManager {
|
|
|
1330
1336
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1331
1337
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1332
1338
|
try {
|
|
1333
|
-
|
|
1339
|
+
const now = new Date();
|
|
1340
|
+
this.expireReplyObligations(now);
|
|
1341
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1334
1342
|
}
|
|
1335
1343
|
catch (err) {
|
|
1336
1344
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1348,6 +1356,7 @@ export class FleetManager {
|
|
|
1348
1356
|
return;
|
|
1349
1357
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1350
1358
|
try {
|
|
1359
|
+
this.expireReplyObligations(now);
|
|
1351
1360
|
for (const name of this.daemons.keys()) {
|
|
1352
1361
|
if (stateOf(name) === "idle")
|
|
1353
1362
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1359,6 +1368,15 @@ export class FleetManager {
|
|
|
1359
1368
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1360
1369
|
}
|
|
1361
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
|
+
}
|
|
1362
1380
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1363
1381
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1364
1382
|
return;
|
|
@@ -1598,6 +1616,14 @@ export class FleetManager {
|
|
|
1598
1616
|
return Math.max(1, Math.min(20, explicit));
|
|
1599
1617
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1600
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
|
+
}
|
|
1601
1627
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1602
1628
|
reportMemoryPressure(snapshot) {
|
|
1603
1629
|
if (this.shuttingDown)
|
|
@@ -1608,6 +1634,12 @@ export class FleetManager {
|
|
|
1608
1634
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1609
1635
|
}
|
|
1610
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
|
+
}
|
|
1611
1643
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1612
1644
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1613
1645
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1628,20 +1660,6 @@ export class FleetManager {
|
|
|
1628
1660
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1629
1661
|
}
|
|
1630
1662
|
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
1663
|
}
|
|
1646
1664
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1647
1665
|
bindStormWindowEvents() {
|
|
@@ -3310,6 +3328,9 @@ export class FleetManager {
|
|
|
3310
3328
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3311
3329
|
*/
|
|
3312
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) {
|
|
3313
3334
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3314
3335
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3315
3336
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -4769,6 +4790,16 @@ export class FleetManager {
|
|
|
4769
4790
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4770
4791
|
}
|
|
4771
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
|
+
}
|
|
4772
4803
|
async sendLocalDashboard(data, adapterId) {
|
|
4773
4804
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4774
4805
|
await data.respond(t("not_authorized"));
|
|
@@ -4890,10 +4921,12 @@ export class FleetManager {
|
|
|
4890
4921
|
const adapter = owner.binding;
|
|
4891
4922
|
const status = this.getPublicWebStatus();
|
|
4892
4923
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4924
|
+
const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4893
4925
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4894
4926
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4895
4927
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
4896
|
-
+ (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) : "");
|
|
4897
4930
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4898
4931
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4899
4932
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
@@ -5036,7 +5069,11 @@ export class FleetManager {
|
|
|
5036
5069
|
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
5037
5070
|
await progress;
|
|
5038
5071
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
5039
|
-
|
|
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);
|
|
5040
5077
|
return true;
|
|
5041
5078
|
}
|
|
5042
5079
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5381,7 +5418,11 @@ export class FleetManager {
|
|
|
5381
5418
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5382
5419
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5383
5420
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5384
|
-
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
|
+
}));
|
|
5385
5426
|
// Start tmux control mode client for idle detection
|
|
5386
5427
|
if (!this.controlClient) {
|
|
5387
5428
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -6569,6 +6610,14 @@ export class FleetManager {
|
|
|
6569
6610
|
});
|
|
6570
6611
|
}
|
|
6571
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
|
+
}
|
|
6572
6621
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6573
6622
|
this.cacheInstanceExecutionState(name, msg);
|
|
6574
6623
|
if (msg.type === "instance_state_response") {
|
|
@@ -7081,6 +7130,16 @@ export class FleetManager {
|
|
|
7081
7130
|
});
|
|
7082
7131
|
}
|
|
7083
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);
|
|
7084
7143
|
// Platform author identity is the authority. This runs before routing,
|
|
7085
7144
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
7086
7145
|
const fleetBotIds = new Set();
|
|
@@ -7236,6 +7295,16 @@ export class FleetManager {
|
|
|
7236
7295
|
}
|
|
7237
7296
|
// Handle /start command
|
|
7238
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
|
+
}
|
|
7239
7308
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7240
7309
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7241
7310
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7244,7 +7313,6 @@ export class FleetManager {
|
|
|
7244
7313
|
return;
|
|
7245
7314
|
}
|
|
7246
7315
|
const channelName = msg.username || chatId;
|
|
7247
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7248
7316
|
if (requestedBackend) {
|
|
7249
7317
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7250
7318
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -9731,6 +9799,45 @@ export class FleetManager {
|
|
|
9731
9799
|
return null;
|
|
9732
9800
|
}
|
|
9733
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
|
+
}
|
|
9734
9841
|
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
9735
9842
|
async webEmojiList() {
|
|
9736
9843
|
const adopted = [];
|
|
@@ -11174,6 +11281,48 @@ export class FleetManager {
|
|
|
11174
11281
|
});
|
|
11175
11282
|
}
|
|
11176
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
|
+
}
|
|
11177
11326
|
/**
|
|
11178
11327
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
11179
11328
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -11182,20 +11331,7 @@ export class FleetManager {
|
|
|
11182
11331
|
cacheReport(window) {
|
|
11183
11332
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
11184
11333
|
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
|
-
},
|
|
11334
|
+
instances: () => this.cacheInstances(),
|
|
11199
11335
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
11200
11336
|
claudeKey: claudeProjectKey,
|
|
11201
11337
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11382,7 +11518,9 @@ export class FleetManager {
|
|
|
11382
11518
|
}
|
|
11383
11519
|
async retireNonceButtons(pending, messageId, text,
|
|
11384
11520
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11385
|
-
editClicked
|
|
11521
|
+
editClicked,
|
|
11522
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
11523
|
+
urlButtons) {
|
|
11386
11524
|
this.webPromptGone(pending, text);
|
|
11387
11525
|
try {
|
|
11388
11526
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11392,7 +11530,12 @@ export class FleetManager {
|
|
|
11392
11530
|
}
|
|
11393
11531
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11394
11532
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11395
|
-
|
|
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
|
+
}
|
|
11396
11539
|
}
|
|
11397
11540
|
catch (err) {
|
|
11398
11541
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -12006,9 +12149,12 @@ export class FleetManager {
|
|
|
12006
12149
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
12007
12150
|
* no button — the alert's own wording already tells them what to run.
|
|
12008
12151
|
*/
|
|
12009
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
12152
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
12010
12153
|
if (!LOGIN_FLOWS[backend])
|
|
12011
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;
|
|
12012
12158
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
12013
12159
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
12014
12160
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -12026,9 +12172,10 @@ export class FleetManager {
|
|
|
12026
12172
|
adapterId,
|
|
12027
12173
|
chatId,
|
|
12028
12174
|
threadId,
|
|
12029
|
-
message: t("login.offer",
|
|
12030
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
12175
|
+
message: t("login.offer", shown),
|
|
12176
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
12031
12177
|
expiredText: t("buttons.stale"),
|
|
12178
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12032
12179
|
});
|
|
12033
12180
|
}
|
|
12034
12181
|
/** Consume a General assist button exactly once. */
|
|
@@ -12841,7 +12988,7 @@ export class FleetManager {
|
|
|
12841
12988
|
fleetConfig: () => this.fleetConfig,
|
|
12842
12989
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12843
12990
|
eventLog: () => this.eventLog,
|
|
12844
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
12991
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12845
12992
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12846
12993
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12847
12994
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12850,11 +12997,12 @@ export class FleetManager {
|
|
|
12850
12997
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12851
12998
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12852
12999
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12853
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13000
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12854
13001
|
await this.postNonceButtonPromptOrThrow({
|
|
12855
13002
|
prefix, alertType: "login", instanceName,
|
|
12856
13003
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12857
13004
|
message, choices, expiredText,
|
|
13005
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12858
13006
|
});
|
|
12859
13007
|
},
|
|
12860
13008
|
});
|
|
@@ -13068,10 +13216,20 @@ export class FleetManager {
|
|
|
13068
13216
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
13069
13217
|
if (isRemovedBackend(wanted))
|
|
13070
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
|
+
}
|
|
13071
13227
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
13072
13228
|
const flow = LOGIN_FLOWS[wanted];
|
|
13073
13229
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
13074
|
-
return
|
|
13230
|
+
return opts.profile !== undefined
|
|
13231
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
13232
|
+
: this.startInstallSession(wanted, chat);
|
|
13075
13233
|
}
|
|
13076
13234
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
13077
13235
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -13160,7 +13318,9 @@ export class FleetManager {
|
|
|
13160
13318
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
13161
13319
|
* message can say so instead of implying failure.
|
|
13162
13320
|
*/
|
|
13163
|
-
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) {
|
|
13164
13324
|
const woken = [];
|
|
13165
13325
|
const restarted = [];
|
|
13166
13326
|
const pending = [];
|
|
@@ -13168,6 +13328,9 @@ export class FleetManager {
|
|
|
13168
13328
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
13169
13329
|
if (this.backendNameOf(name) !== backend)
|
|
13170
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;
|
|
13171
13334
|
const status = this.getInstanceStatus(name);
|
|
13172
13335
|
if (status !== "paused" && status !== "running")
|
|
13173
13336
|
continue;
|
|
@@ -13205,14 +13368,16 @@ export class FleetManager {
|
|
|
13205
13368
|
if (claimed === "consumed")
|
|
13206
13369
|
return true;
|
|
13207
13370
|
const { entry, action: backend } = claimed;
|
|
13208
|
-
|
|
13209
|
-
|
|
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 = {
|
|
13210
13374
|
adapter: entry.adapter,
|
|
13211
13375
|
adapterId: entry.adapterId,
|
|
13212
13376
|
chatId: entry.chatId,
|
|
13213
13377
|
threadId: entry.threadId,
|
|
13214
13378
|
userId: data.userId,
|
|
13215
|
-
}
|
|
13379
|
+
};
|
|
13380
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13216
13381
|
if (text)
|
|
13217
13382
|
await this.postPromptOutcome(entry, text);
|
|
13218
13383
|
return true;
|
|
@@ -13226,11 +13391,13 @@ export class FleetManager {
|
|
|
13226
13391
|
return true;
|
|
13227
13392
|
const { entry, action } = claimed;
|
|
13228
13393
|
const backend = entry.instanceName;
|
|
13394
|
+
const profile = entry.loginProfile;
|
|
13395
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
13229
13396
|
if (action === "cancel") {
|
|
13230
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
13397
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
13231
13398
|
return true;
|
|
13232
13399
|
}
|
|
13233
|
-
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));
|
|
13234
13401
|
const text = await this.startLoginSession(backend, {
|
|
13235
13402
|
adapter: entry.adapter,
|
|
13236
13403
|
adapterId: entry.adapterId,
|
|
@@ -13242,6 +13409,7 @@ export class FleetManager {
|
|
|
13242
13409
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
13243
13410
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
13244
13411
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
13412
|
+
...(profile ? { profile } : {}),
|
|
13245
13413
|
});
|
|
13246
13414
|
if (text)
|
|
13247
13415
|
await this.postPromptOutcome(entry, text);
|
|
@@ -13269,7 +13437,7 @@ export class FleetManager {
|
|
|
13269
13437
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
13270
13438
|
installHandoff = null;
|
|
13271
13439
|
/** Start a CLI install session. Caller enforces admin. */
|
|
13272
|
-
async startInstallSession(backendArg, chat) {
|
|
13440
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
13273
13441
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
13274
13442
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
13275
13443
|
if (!info)
|
|
@@ -13353,7 +13521,8 @@ export class FleetManager {
|
|
|
13353
13521
|
// without a click.
|
|
13354
13522
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13355
13523
|
// not visible, startLoginSession would route straight back to an install.
|
|
13356
|
-
|
|
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)));
|
|
13357
13526
|
if (next) {
|
|
13358
13527
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13359
13528
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13461,8 +13630,16 @@ export class FleetManager {
|
|
|
13461
13630
|
return;
|
|
13462
13631
|
}
|
|
13463
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
|
+
}
|
|
13464
13641
|
if (backend) {
|
|
13465
|
-
const text = await this.startLoginSession(backend, chat);
|
|
13642
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13466
13643
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13467
13644
|
return;
|
|
13468
13645
|
}
|
|
@@ -14319,6 +14496,49 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14319
14496
|
});
|
|
14320
14497
|
}
|
|
14321
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
|
+
}
|
|
14322
14542
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14323
14543
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14324
14544
|
if (!ipc) {
|
|
@@ -14918,7 +15138,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14918
15138
|
try {
|
|
14919
15139
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14920
15140
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14921
|
-
? 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");
|
|
14922
15151
|
}
|
|
14923
15152
|
catch (err) {
|
|
14924
15153
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -15243,7 +15472,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15243
15472
|
try {
|
|
15244
15473
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
15245
15474
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
15246
|
-
? 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");
|
|
15247
15485
|
}
|
|
15248
15486
|
catch (err) {
|
|
15249
15487
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15377,21 +15615,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15377
15615
|
}
|
|
15378
15616
|
catch { /* default restart */ }
|
|
15379
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
|
+
}
|
|
15380
15625
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15381
15626
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15382
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
|
+
}
|
|
15383
15643
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15384
15644
|
// 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)
|
|
15645
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15395
15646
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15396
15647
|
if (strategy === "runtime") {
|
|
15397
15648
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15400,6 +15651,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15400
15651
|
await this.restartSingleInstance(instanceName);
|
|
15401
15652
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15402
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
|
+
}
|
|
15403
15663
|
/**
|
|
15404
15664
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15405
15665
|
*
|
|
@@ -15510,6 +15770,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15510
15770
|
}
|
|
15511
15771
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15512
15772
|
async handleClassicStartSlash(data, adapterId) {
|
|
15773
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
15774
|
+
}
|
|
15775
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15513
15776
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15514
15777
|
if (!requestedBackend) {
|
|
15515
15778
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15626,7 +15889,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15626
15889
|
// failure. The selected backend is still attempted as requested.
|
|
15627
15890
|
if (warning)
|
|
15628
15891
|
await pending.complete(warning, pending.messageId);
|
|
15629
|
-
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));
|
|
15630
15893
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15631
15894
|
}
|
|
15632
15895
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -18092,14 +18355,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18092
18355
|
return;
|
|
18093
18356
|
}
|
|
18094
18357
|
(async () => {
|
|
18358
|
+
let held = false;
|
|
18095
18359
|
try {
|
|
18096
18360
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
18097
|
-
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));
|
|
18098
18366
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18367
|
+
if (held)
|
|
18368
|
+
return;
|
|
18099
18369
|
res.writeHead(200);
|
|
18100
18370
|
res.end(JSON.stringify({ ok: true }));
|
|
18101
18371
|
}
|
|
18102
18372
|
catch (err) {
|
|
18373
|
+
if (held) {
|
|
18374
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
18375
|
+
return;
|
|
18376
|
+
}
|
|
18103
18377
|
res.writeHead(500);
|
|
18104
18378
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
18105
18379
|
}
|
|
@@ -18114,15 +18388,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18114
18388
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
18115
18389
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
18116
18390
|
(async () => {
|
|
18391
|
+
let held = false;
|
|
18117
18392
|
try {
|
|
18118
|
-
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 }));
|
|
18119
18398
|
this.logger.info({ name }, "Instance restarted");
|
|
18120
18399
|
this.emitSseEvent("status", this.getUiStatus());
|
|
18400
|
+
if (held)
|
|
18401
|
+
return;
|
|
18121
18402
|
res.writeHead(200);
|
|
18122
18403
|
res.end(JSON.stringify({ restarted: name }));
|
|
18123
18404
|
}
|
|
18124
18405
|
catch (err) {
|
|
18125
18406
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
18407
|
+
if (held)
|
|
18408
|
+
return;
|
|
18126
18409
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
18127
18410
|
res.writeHead(status);
|
|
18128
18411
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -18438,6 +18721,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18438
18721
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
18439
18722
|
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
18440
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(),
|
|
18441
18727
|
};
|
|
18442
18728
|
}
|
|
18443
18729
|
}
|