@songsid/agend 2.2.0-alpha.9 → 2.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +10 -7
- package/dist/agent-endpoint.js +21 -9
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +47 -0
- package/dist/agent-whats-new.js +141 -0
- package/dist/agent-whats-new.js.map +1 -0
- package/dist/auth-api.d.ts +2 -2
- package/dist/auth-api.js +2 -2
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.d.ts +5 -0
- package/dist/backend/kiro-engine-ledger.js +20 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +6 -0
- package/dist/backend/kiro-identity.js +19 -5
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +14 -2
- package/dist/backend/kiro.js +84 -9
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +4 -2
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +13 -1
- package/dist/channel/adapters/discord.js +34 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +11 -1
- package/dist/channel/adapters/telegram.js +28 -2
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -1
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +11 -0
- package/dist/channel/mcp-tools.js +23 -12
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +7 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +13 -1
- package/dist/cli.js +55 -16
- package/dist/cli.js.map +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +48 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +15 -2
- package/dist/cross-instance-envelope.js +27 -5
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +36 -0
- package/dist/daemon.js +157 -6
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +6 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +48 -0
- package/dist/fleet-manager.js +275 -6
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
- package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
- package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
- package/dist/general-knowledge/steering/core-rules.md +8 -7
- package/dist/instance-avatar.d.ts +36 -0
- package/dist/instance-avatar.js +57 -0
- package/dist/instance-avatar.js.map +1 -0
- package/dist/instance-config-impact.js +5 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +4 -0
- package/dist/instance-lifecycle.js +3 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.js +10 -7
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +36 -26
- package/dist/locale.js.map +1 -1
- package/dist/next-steps.js +1 -1
- package/dist/next-steps.js.map +1 -1
- package/dist/outbound-handlers.js +7 -4
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +8 -0
- package/dist/outbound-schemas.js +15 -5
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +3 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/service-activation.js +4 -3
- package/dist/service-activation.js.map +1 -1
- package/dist/settings-api.js +71 -21
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +70 -1
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +26 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +7 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +1 -1
- package/dist/status-emojis.d.ts +10 -3
- package/dist/status-emojis.js +11 -2
- package/dist/status-emojis.js.map +1 -1
- package/dist/tips.js +4 -4
- package/dist/tips.js.map +1 -1
- package/dist/transcript-monitor.d.ts +8 -0
- package/dist/transcript-monitor.js +26 -2
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +54 -0
- package/dist/transcript-sources.js +188 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +15 -2
- package/dist/transcript-turns.js +15 -2
- package/dist/transcript-turns.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui/chat-emojis.js +33 -0
- package/dist/ui/chat-render.js +12 -0
- package/dist/ui/chat-thread.js +1 -1
- package/dist/ui/panel-chat.js +13 -3
- package/dist/ui/panel-needs.js +5 -2
- package/dist/ui/panel-settings.js +117 -26
- package/dist/ui/settings-dialogs.js +7 -2
- package/dist/ui/settings-model.js +2 -1
- package/dist/ui/settings-strings.js +38 -2
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app.css +29 -1
- package/dist/ui/shared/instance-nav.js +77 -8
- package/dist/ui/shared/signin.js +7 -7
- package/dist/ui/shared/tokens.css +9 -0
- package/dist/ui/shared/view-strings.js +2 -2
- package/dist/ui/signin.html +1 -1
- package/dist/update-install.d.ts +15 -1
- package/dist/update-install.js +6 -0
- package/dist/update-install.js.map +1 -1
- package/dist/update-preflight.d.ts +100 -0
- package/dist/update-preflight.js +264 -0
- package/dist/update-preflight.js.map +1 -0
- package/dist/updater-caller.d.ts +31 -0
- package/dist/updater-caller.js +109 -0
- package/dist/updater-caller.js.map +1 -0
- package/dist/view-api.d.ts +1 -1
- package/dist/view-api.js +33 -8
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.d.ts +11 -0
- package/dist/wake-coordinator.js +38 -4
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-api.d.ts +11 -0
- package/dist/web-api.js +54 -0
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +2 -2
- package/dist/web-auth.js +5 -3
- package/dist/web-auth.js.map +1 -1
- package/dist/web-host-guard.d.ts +3 -11
- package/dist/web-host-guard.js +3 -12
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-images.d.ts +47 -0
- package/dist/web-images.js +169 -0
- package/dist/web-images.js.map +1 -0
- package/dist/web-session.d.ts +36 -1
- package/dist/web-session.js +52 -5
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +21 -0
- package/dist/web-settings-patch.js +74 -0
- package/dist/web-settings-patch.js.map +1 -0
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -51,6 +51,7 @@ import { startEventLoopWatch } from "./event-loop-watch.js";
|
|
|
51
51
|
import { TmuxManager } from "./tmux-manager.js";
|
|
52
52
|
import { AccessManager } from "./channel/access-manager.js";
|
|
53
53
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
54
|
+
import { usesChannelAddress } from "./channel/mcp-tools.js";
|
|
54
55
|
import { formatFleetTime } from "./tz-utils.js";
|
|
55
56
|
import { createAdapter } from "./channel/factory.js";
|
|
56
57
|
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
@@ -132,6 +133,9 @@ import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtons
|
|
|
132
133
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
133
134
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
134
135
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
136
|
+
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
137
|
+
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
138
|
+
import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
|
|
135
139
|
import { envFileKeys } from "./token-env-name.js";
|
|
136
140
|
import { discordGeneralChannelId } from "./discord-general.js";
|
|
137
141
|
import { parseEnvText } from "./env-file.js";
|
|
@@ -180,7 +184,7 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
|
|
|
180
184
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
181
185
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
182
186
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
183
|
-
import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
187
|
+
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
184
188
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
185
189
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
186
190
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
@@ -1274,7 +1278,20 @@ export class FleetManager {
|
|
|
1274
1278
|
}
|
|
1275
1279
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
1276
1280
|
this.ensureDeliveryOutbox();
|
|
1277
|
-
|
|
1281
|
+
const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
|
|
1282
|
+
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
1283
|
+
return {
|
|
1284
|
+
...page,
|
|
1285
|
+
items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1286
|
+
? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
|
|
1287
|
+
: item),
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
/** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
|
|
1291
|
+
deliveryWaitingReason(target) {
|
|
1292
|
+
if (this.lifecycle.isPaused(target))
|
|
1293
|
+
return t("delivery.hold_state", "paused");
|
|
1294
|
+
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
1278
1295
|
}
|
|
1279
1296
|
getDaemonBootId(instanceName) {
|
|
1280
1297
|
return this.daemons.get(instanceName)?.bootId;
|
|
@@ -4152,6 +4169,33 @@ export class FleetManager {
|
|
|
4152
4169
|
names.add(channel.instanceName);
|
|
4153
4170
|
return [...names];
|
|
4154
4171
|
}
|
|
4172
|
+
/** #1624: migration cannot infer ownership from the running daemon roster.
|
|
4173
|
+
* Stopped/paused ClassicBot entries count too; their workspace is exactly
|
|
4174
|
+
* the one startClassicInstance builds, not this FleetManager's dataDir.
|
|
4175
|
+
*/
|
|
4176
|
+
kiroPeerWorkingDirectories(name) {
|
|
4177
|
+
if (!this.fleetConfig || !this.classicChannels)
|
|
4178
|
+
throw new Error("Kiro peer configuration is not loaded");
|
|
4179
|
+
const peers = [];
|
|
4180
|
+
for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
|
|
4181
|
+
if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
|
|
4182
|
+
continue;
|
|
4183
|
+
if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
|
|
4184
|
+
throw new Error("Kiro peer cwd is unknown");
|
|
4185
|
+
peers.push(cfg.working_directory);
|
|
4186
|
+
}
|
|
4187
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
4188
|
+
if (ch.instanceName === name) {
|
|
4189
|
+
if (Object.hasOwn(this.fleetConfig.instances, name))
|
|
4190
|
+
throw new Error("Kiro instance name has two configurations");
|
|
4191
|
+
continue;
|
|
4192
|
+
}
|
|
4193
|
+
if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
|
|
4194
|
+
peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
return peers;
|
|
4198
|
+
}
|
|
4155
4199
|
/**
|
|
4156
4200
|
* Probe the same executable set exposed by the web backend catalog.
|
|
4157
4201
|
*
|
|
@@ -4549,6 +4593,9 @@ export class FleetManager {
|
|
|
4549
4593
|
this.webSessions = new WebSessionStore({
|
|
4550
4594
|
dataDir: this.dataDir,
|
|
4551
4595
|
onWarn: message => this.logger.warn(message),
|
|
4596
|
+
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
4597
|
+
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
4598
|
+
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
4552
4599
|
});
|
|
4553
4600
|
}
|
|
4554
4601
|
if (!this.returningDevices) {
|
|
@@ -4734,6 +4781,42 @@ export class FleetManager {
|
|
|
4734
4781
|
this.logger.info({ err: err?.message }, "The code-only follow-up was not sent; the sign-in message holds the code");
|
|
4735
4782
|
}
|
|
4736
4783
|
}
|
|
4784
|
+
/**
|
|
4785
|
+
* #1610: re-arm the public link's close prompt on the SAME message — as its confirm step (確定關閉 / 取消) or back as
|
|
4786
|
+
* the close button — with a fresh nonce carrying the same owner gate (owner, requester, exposure, message, adapter),
|
|
4787
|
+
* and swap the message's buttons keyboard-only: its text is the sign-in message, which must stay. A swap that fails
|
|
4788
|
+
* disarms the new nonce and leaves the link open (the General menu and /dashboard revoke still close it).
|
|
4789
|
+
*/
|
|
4790
|
+
async armDashboardCloseStep(prev, confirm, data) {
|
|
4791
|
+
const nonce = randomBytes(16).toString("hex");
|
|
4792
|
+
const { timer: _timer, nonce: _old, ...rest } = prev;
|
|
4793
|
+
const entry = { ...rest, nonce, closeStep: confirm, createdAt: Date.now() };
|
|
4794
|
+
entry.timer = setTimeout(() => {
|
|
4795
|
+
if (this.pendingNonceButtons.get(nonce) !== entry)
|
|
4796
|
+
return;
|
|
4797
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4798
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err }, "Failed to expire the public link's close prompt"));
|
|
4799
|
+
}, 5 * 60_000);
|
|
4800
|
+
entry.timer.unref?.();
|
|
4801
|
+
this.pendingNonceButtons.set(nonce, entry);
|
|
4802
|
+
const choices = confirm
|
|
4803
|
+
? [{ id: `dashboard:${nonce}:confirm`, label: t("dashboard.public_close_confirm") }, { id: `dashboard:${nonce}:keep`, label: t("dashboard.public_close_keep") }]
|
|
4804
|
+
: [{ id: `dashboard:${nonce}:close`, label: t("dashboard.public_close") }];
|
|
4805
|
+
try {
|
|
4806
|
+
if (data.replaceClickedButtons)
|
|
4807
|
+
await data.replaceClickedButtons(choices);
|
|
4808
|
+
else if (prev.adapter.replaceMessageButtons)
|
|
4809
|
+
await prev.adapter.replaceMessageButtons(prev.chatId, prev.messageId ?? data.messageId, choices, prev.threadId);
|
|
4810
|
+
else
|
|
4811
|
+
throw new Error("adapter cannot replace buttons");
|
|
4812
|
+
}
|
|
4813
|
+
catch (err) {
|
|
4814
|
+
if (this.pendingNonceButtons.get(nonce) === entry)
|
|
4815
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4816
|
+
clearTimeout(entry.timer);
|
|
4817
|
+
this.logger.warn({ err: err?.message }, "Could not swap the public link's close buttons; the link stays open");
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4737
4820
|
/** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
|
|
4738
4821
|
logDashboardDmFailure(err) {
|
|
4739
4822
|
const code = err?.code;
|
|
@@ -4771,7 +4854,11 @@ export class FleetManager {
|
|
|
4771
4854
|
return posted !== null;
|
|
4772
4855
|
}
|
|
4773
4856
|
async handleDashboardCallback(data, adapterId, adapter) {
|
|
4774
|
-
|
|
4857
|
+
// #1610: `confirm`/`keep` answer the close button's confirm step and nothing else; `close` is never a confirm step.
|
|
4858
|
+
// A click of the wrong kind is refused like a non-admin's — unconsumed, so the real buttons still work.
|
|
4859
|
+
const kind = /:([a-z]+)$/.exec(data.callbackData)?.[1];
|
|
4860
|
+
const stepAction = kind === "confirm" || kind === "keep";
|
|
4861
|
+
const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close|confirm|keep)$/, data, adapterId, adapter, undefined, (userId, e) => this.isFleetAdmin(userId, adapterId) && stepAction === (e.closeStep === true));
|
|
4775
4862
|
if (claimed === null)
|
|
4776
4863
|
return false;
|
|
4777
4864
|
if (claimed === "consumed")
|
|
@@ -4782,7 +4869,15 @@ export class FleetManager {
|
|
|
4782
4869
|
data.ack?.(t("not_authorized"));
|
|
4783
4870
|
return true;
|
|
4784
4871
|
}
|
|
4785
|
-
if (action === "close") {
|
|
4872
|
+
if (action === "close" || action === "keep") {
|
|
4873
|
+
// A tap on 關閉公開連結 closes nothing: it offers 確定關閉 / 取消 on the same message (the button sits right above
|
|
4874
|
+
// the code a phone taps to copy). 取消 puts the close button back. Both only re-arm the same, owner-gated prompt.
|
|
4875
|
+
if (!entry.publicExposureId)
|
|
4876
|
+
return true;
|
|
4877
|
+
await this.armDashboardCloseStep(entry, action === "close", data);
|
|
4878
|
+
return true;
|
|
4879
|
+
}
|
|
4880
|
+
if (action === "confirm") {
|
|
4786
4881
|
if (!entry.publicExposureId)
|
|
4787
4882
|
return true;
|
|
4788
4883
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
@@ -7662,10 +7757,12 @@ export class FleetManager {
|
|
|
7662
7757
|
respond(null, "No adapter available");
|
|
7663
7758
|
return;
|
|
7664
7759
|
}
|
|
7760
|
+
// Only channel-addressing schemas receive ClassicBot routing metadata.
|
|
7761
|
+
// Non-channel calls (notably strict delivery_status) retain their arguments.
|
|
7665
7762
|
// For classic instances: force chat_id to channelId and clear thread_id
|
|
7666
7763
|
// (daemon may have set chat_id to guild_id which is wrong for DC; TG may have set thread_id which causes 'thread not found')
|
|
7667
7764
|
const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
|
|
7668
|
-
if (classicChannelId) {
|
|
7765
|
+
if (classicChannelId && usesChannelAddress(tool)) {
|
|
7669
7766
|
args.chat_id = classicChannelId;
|
|
7670
7767
|
// #1085: a Telegram forum ClassicBot replies in the topic it was asked
|
|
7671
7768
|
// in. Any other thread_id (threadless Telegram, where it echoes the chat
|
|
@@ -9454,6 +9551,151 @@ export class FleetManager {
|
|
|
9454
9551
|
/** Keyed `<connection>\0<server id>`. */
|
|
9455
9552
|
guildEmojiCache = new Map();
|
|
9456
9553
|
guildEmojiFetches = new Map();
|
|
9554
|
+
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
9555
|
+
webImageCaches = null;
|
|
9556
|
+
webImageCache(kind) {
|
|
9557
|
+
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
9558
|
+
this.webImageCaches ??= {
|
|
9559
|
+
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
9560
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
9561
|
+
};
|
|
9562
|
+
return this.webImageCaches[kind];
|
|
9563
|
+
}
|
|
9564
|
+
/**
|
|
9565
|
+
* instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
|
|
9566
|
+
* whatever an instance still names — #1635 review), not the primary, and answering for it alone.
|
|
9567
|
+
*/
|
|
9568
|
+
ownBots() {
|
|
9569
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
9570
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
9571
|
+
const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
|
|
9572
|
+
const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
|
|
9573
|
+
const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
|
|
9574
|
+
for (const room of this.classicChannels?.getAll() ?? []) {
|
|
9575
|
+
if (!Object.hasOwn(instances, room.instanceName))
|
|
9576
|
+
assignments.push([room.instanceName, assigned(room.instanceName)]);
|
|
9577
|
+
}
|
|
9578
|
+
return ownBotConnections(this.getPrimaryAdapterId(), assignments);
|
|
9579
|
+
}
|
|
9580
|
+
/**
|
|
9581
|
+
* The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
|
|
9582
|
+
* keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
|
|
9583
|
+
* face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
|
|
9584
|
+
*/
|
|
9585
|
+
botOwner(connection) {
|
|
9586
|
+
const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
|
|
9587
|
+
return adapter && id ? { adapter, id } : null;
|
|
9588
|
+
}
|
|
9589
|
+
/** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
|
|
9590
|
+
botStillOwns(connection, owner) {
|
|
9591
|
+
return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
|
|
9592
|
+
}
|
|
9593
|
+
/** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
|
|
9594
|
+
webAccents = new Map();
|
|
9595
|
+
webAccentReads = new Set();
|
|
9596
|
+
webAccent(connection, owner, channelId) {
|
|
9597
|
+
const adapter = owner.adapter;
|
|
9598
|
+
if (!adapter.botAccentColor)
|
|
9599
|
+
return null;
|
|
9600
|
+
const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
|
|
9601
|
+
if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
|
|
9602
|
+
this.webAccentReads.add(key);
|
|
9603
|
+
adapter.botAccentColor(channelId)
|
|
9604
|
+
// Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
|
|
9605
|
+
.then(value => { this.webAccents.set(key, { value: accentColor(value), until: performance.now() + WEB_IMAGE_MAX_AGE_MS }); }, () => { this.webAccents.set(key, { value: cached?.value ?? null, until: performance.now() + WEB_IMAGE_RETRY_MS }); })
|
|
9606
|
+
.finally(() => this.webAccentReads.delete(key));
|
|
9607
|
+
}
|
|
9608
|
+
return cached?.value ?? null;
|
|
9609
|
+
}
|
|
9610
|
+
/** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
|
|
9611
|
+
botAvatarSource(connection, owner) {
|
|
9612
|
+
return {
|
|
9613
|
+
key: `bot:${connection}:${owner.id}`,
|
|
9614
|
+
load: async () => {
|
|
9615
|
+
const got = await owner.adapter.fetchBotAvatar();
|
|
9616
|
+
if (!this.botStillOwns(connection, owner))
|
|
9617
|
+
throw new Error("the bot was replaced while its avatar was fetched");
|
|
9618
|
+
return got;
|
|
9619
|
+
},
|
|
9620
|
+
};
|
|
9621
|
+
}
|
|
9622
|
+
/** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
|
|
9623
|
+
webIdentityImage(name, displayName, profileAvatar, own) {
|
|
9624
|
+
let profileVersion = null;
|
|
9625
|
+
if (profileAvatar && imageMimeForFile(profileAvatar)) {
|
|
9626
|
+
try {
|
|
9627
|
+
profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
|
|
9628
|
+
}
|
|
9629
|
+
catch { /* removed */ }
|
|
9630
|
+
}
|
|
9631
|
+
const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
|
|
9632
|
+
const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
|
|
9633
|
+
const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
|
|
9634
|
+
const cfg = this.ownInstanceConfig(name);
|
|
9635
|
+
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
9636
|
+
personaEmoji: cfg?.status_emojis?.delivered });
|
|
9637
|
+
// The ring belongs to the bot's own face: only when that is what is shown.
|
|
9638
|
+
const accent = !profileVersion && bot && connection && owner
|
|
9639
|
+
? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
9640
|
+
: null;
|
|
9641
|
+
return accent ? { avatar, accent } : { avatar };
|
|
9642
|
+
}
|
|
9643
|
+
/** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
|
|
9644
|
+
webImage(kind, key) {
|
|
9645
|
+
if (kind === "bot") {
|
|
9646
|
+
if (![...this.ownBots().values()].includes(key))
|
|
9647
|
+
return null;
|
|
9648
|
+
const owner = this.botOwner(key);
|
|
9649
|
+
return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
|
|
9650
|
+
}
|
|
9651
|
+
const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
|
|
9652
|
+
if (!known)
|
|
9653
|
+
return null;
|
|
9654
|
+
const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
|
|
9655
|
+
const mime = file ? imageMimeForFile(file) : null;
|
|
9656
|
+
if (!file || !mime)
|
|
9657
|
+
return null;
|
|
9658
|
+
try {
|
|
9659
|
+
const path = join(this.dataDir, "avatars", basename(file));
|
|
9660
|
+
return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
|
|
9661
|
+
}
|
|
9662
|
+
catch {
|
|
9663
|
+
return null;
|
|
9664
|
+
}
|
|
9665
|
+
}
|
|
9666
|
+
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
9667
|
+
async webEmojiList() {
|
|
9668
|
+
const adopted = [];
|
|
9669
|
+
for (const [id, world] of [...this.worlds]) {
|
|
9670
|
+
if (world.type !== "discord")
|
|
9671
|
+
continue;
|
|
9672
|
+
const listed = await this.listGuildEmojis(id);
|
|
9673
|
+
if (listed.ok)
|
|
9674
|
+
adopted.push({ id, world, emojis: listed.emojis });
|
|
9675
|
+
}
|
|
9676
|
+
// After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
|
|
9677
|
+
// removed or replaced while a later list was awaited takes its servers along, whenever it went.
|
|
9678
|
+
const out = new Map();
|
|
9679
|
+
for (const a of adopted) {
|
|
9680
|
+
if (this.worlds.get(a.id) !== a.world)
|
|
9681
|
+
continue;
|
|
9682
|
+
for (const e of a.emojis)
|
|
9683
|
+
if (!out.has(e.id))
|
|
9684
|
+
out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
|
|
9685
|
+
}
|
|
9686
|
+
return [...out.values()];
|
|
9687
|
+
}
|
|
9688
|
+
/** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
|
|
9689
|
+
async webEmoji(id) {
|
|
9690
|
+
if (!/^\d{15,25}$/.test(id))
|
|
9691
|
+
return null;
|
|
9692
|
+
const emoji = (await this.webEmojiList()).find(e => e.id === id);
|
|
9693
|
+
if (!emoji)
|
|
9694
|
+
return null;
|
|
9695
|
+
const image = await this.webImageCache("emoji").get(`emoji:${id}`, () => downloadStickerImage(`https://cdn.discordapp.com/emojis/${id}.${emoji.animated ? "gif" : "png"}?size=64`, { maxBytes: WEB_IMAGE_MAX_BYTES }));
|
|
9696
|
+
// Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
|
|
9697
|
+
return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
|
|
9698
|
+
}
|
|
9457
9699
|
/**
|
|
9458
9700
|
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
9459
9701
|
* tool): its current status set with where each value comes from, the
|
|
@@ -10641,6 +10883,15 @@ export class FleetManager {
|
|
|
10641
10883
|
if (pending && pending.prefix !== prefix)
|
|
10642
10884
|
pending = undefined;
|
|
10643
10885
|
if (!match || !pending) {
|
|
10886
|
+
// #1610 (Fable r1): the same message may already carry the prompt's NEXT step (the public link's 確定關閉 / 取消
|
|
10887
|
+
// on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
|
|
10888
|
+
// here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
|
|
10889
|
+
// told; the message is left alone.
|
|
10890
|
+
// #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
|
|
10891
|
+
if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
|
|
10892
|
+
data.ack?.(t("buttons.stale_notice"));
|
|
10893
|
+
return "consumed";
|
|
10894
|
+
}
|
|
10644
10895
|
const adapter = receivingAdapter ?? this.adapter;
|
|
10645
10896
|
// The clicker always hears why nothing happens (#1133); collapsing the
|
|
10646
10897
|
// dead buttons is a courtesy that can fail (>48h on Telegram, message gone).
|
|
@@ -17884,7 +18135,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17884
18135
|
// Never the token: fleet.log is readable by anything that can read the
|
|
17885
18136
|
// data dir, is copied into bug reports, and is tailed in shared terminals.
|
|
17886
18137
|
// `/dashboard` and `agend web` are the ways to get an authorized link.
|
|
17887
|
-
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /
|
|
18138
|
+
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
|
|
17888
18139
|
this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
|
|
17889
18140
|
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
17890
18141
|
const bound = this.healthServer?.address();
|
|
@@ -18026,6 +18277,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18026
18277
|
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
18027
18278
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
18028
18279
|
const profiles = profileIdentities(this.dataDir);
|
|
18280
|
+
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
18281
|
+
let ownBots = new Map();
|
|
18282
|
+
try {
|
|
18283
|
+
ownBots = this.ownBots();
|
|
18284
|
+
}
|
|
18285
|
+
catch (err) {
|
|
18286
|
+
this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
|
|
18287
|
+
}
|
|
18029
18288
|
const instances = names.map(name => {
|
|
18030
18289
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
18031
18290
|
let cost = 0;
|
|
@@ -18063,9 +18322,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18063
18322
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
18064
18323
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
18065
18324
|
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
18325
|
+
let image;
|
|
18326
|
+
try {
|
|
18327
|
+
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
|
|
18328
|
+
}
|
|
18329
|
+
catch (err) {
|
|
18330
|
+
this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
|
|
18331
|
+
image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
|
|
18332
|
+
}
|
|
18066
18333
|
return {
|
|
18067
18334
|
name,
|
|
18068
18335
|
display_name: identity.display_name || undefined,
|
|
18336
|
+
avatar: image.avatar,
|
|
18337
|
+
...(image.accent ? { accent: image.accent } : {}),
|
|
18069
18338
|
description: identity.description || undefined,
|
|
18070
18339
|
role: identity.role || undefined,
|
|
18071
18340
|
tags: identity.tags,
|