@songsid/agend 2.3.4-beta.8 → 2.3.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/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent-cli-instructions.md +1 -1
- package/dist/agent-credentials.d.ts +24 -0
- package/dist/agent-credentials.js +66 -0
- package/dist/agent-credentials.js.map +1 -0
- package/dist/agent-endpoint.d.ts +3 -1
- package/dist/agent-endpoint.js +13 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/auth-api.js +5 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +28 -4
- package/dist/authority/session-authorizations.js +63 -17
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/backend/antigravity.js +3 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/codex-footer-hints.d.ts +4 -0
- package/dist/backend/codex-footer-hints.js +27 -0
- package/dist/backend/codex-footer-hints.js.map +1 -0
- package/dist/backend/codex-metadata.d.ts +14 -0
- package/dist/backend/codex-metadata.js +45 -0
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex-speed.d.ts +14 -0
- package/dist/backend/codex-speed.js +35 -0
- package/dist/backend/codex-speed.js.map +1 -0
- package/dist/backend/codex.js +17 -5
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +11 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.js +5 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/cache-ledger.d.ts +95 -0
- package/dist/cache-ledger.js +276 -4
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-service.d.ts +5 -0
- package/dist/cache-service.js +16 -4
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.js +11 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +51 -0
- package/dist/channel/adapters/telegram.js +235 -17
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +2 -0
- package/dist/config-validator.js +5 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +10 -0
- package/dist/cross-instance-notice.js +67 -5
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +11 -4
- package/dist/daemon.js.map +1 -1
- package/dist/export-import.d.ts +7 -0
- package/dist/export-import.js +44 -10
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +93 -4
- package/dist/fleet-manager.js +451 -39
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/concepts/SKILL.md +3 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +7 -2
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/instance-config-impact.js +2 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +6 -2
- package/dist/instance-lifecycle.js +11 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instance-topic-delete.d.ts +77 -0
- package/dist/instance-topic-delete.js +123 -0
- package/dist/instance-topic-delete.js.map +1 -0
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +1 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +33 -1
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +28 -0
- package/dist/log-service.js +29 -0
- package/dist/log-service.js.map +1 -1
- package/dist/log-steps.d.ts +4 -3
- package/dist/log-steps.js +32 -7
- package/dist/log-steps.js.map +1 -1
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +43 -1
- package/dist/logger.js.map +1 -1
- package/dist/missed-offline.d.ts +25 -0
- package/dist/missed-offline.js +79 -0
- package/dist/missed-offline.js.map +1 -0
- package/dist/needs-you-hub.d.ts +6 -1
- package/dist/needs-you-hub.js +30 -7
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +16 -2
- package/dist/needs-you.js +9 -1
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.js +5 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/scheduler/db.d.ts +2 -1
- package/dist/scheduler/db.js +23 -9
- package/dist/scheduler/db.js.map +1 -1
- package/dist/settings-api.d.ts +13 -0
- package/dist/settings-api.js +22 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +16 -1
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-instance-patch.js +27 -0
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/tool-progress.d.ts +2 -0
- package/dist/tool-progress.js +19 -3
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-commands.js +4 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/chat-md-strings.js +9 -0
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +53 -3
- package/dist/ui/fleet-usage.js +90 -27
- package/dist/ui/panel-chat.js +5 -2
- package/dist/ui/panel-needs.js +13 -5
- package/dist/ui/preview-panel.js +78 -2
- package/dist/ui/settings-codex-copy.js +19 -0
- package/dist/ui/settings-dialogs.js +8 -0
- package/dist/ui/settings-model.js +16 -2
- package/dist/ui/settings-strings.js +2 -2
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app.css +19 -0
- package/dist/ui/shared/panel-view.js +77 -4
- package/dist/ui/shared/usage-dialog.js +18 -1
- package/dist/ui/shared/view-history.css +17 -0
- package/dist/ui/shared/view-history.js +438 -0
- package/dist/ui/shared/view-strings.js +16 -0
- package/dist/usage/capacity.d.ts +69 -0
- package/dist/usage/capacity.js +119 -0
- package/dist/usage/capacity.js.map +1 -0
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +3 -0
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/profile-timeline.d.ts +16 -0
- package/dist/usage/profile-timeline.js +58 -0
- package/dist/usage/profile-timeline.js.map +1 -0
- package/dist/usage/quota-history.d.ts +43 -0
- package/dist/usage/quota-history.js +168 -0
- package/dist/usage/quota-history.js.map +1 -0
- package/dist/usage/usage-api.d.ts +31 -0
- package/dist/usage/usage-api.js +80 -1
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/usage-report.d.ts +25 -1
- package/dist/usage-report.js +55 -4
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.d.ts +6 -1
- package/dist/usage-rollup.js +16 -4
- package/dist/usage-rollup.js.map +1 -1
- package/dist/view-api.d.ts +16 -0
- package/dist/view-api.js +100 -16
- package/dist/view-api.js.map +1 -1
- package/dist/view-history.d.ts +84 -0
- package/dist/view-history.js +116 -0
- package/dist/view-history.js.map +1 -0
- package/dist/web-api.d.ts +6 -0
- package/dist/web-api.js +8 -2
- package/dist/web-api.js.map +1 -1
- package/dist/web-session-authorization.d.ts +13 -2
- package/dist/web-session-authorization.js +10 -3
- package/dist/web-session-authorization.js.map +1 -1
- package/launcher/private-home.cjs +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { listKiroV1Sessions, readKiroClassicLinks } from "./backend/kiro-identit
|
|
|
3
3
|
import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
|
|
4
4
|
import { sharedKiroV2StoreLane } from "./backend/kiro-v2-store.js";
|
|
5
5
|
import { SEPARATE_FEATURE_KEYS, hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
|
|
6
|
+
import { maskSecrets } from "./log-steps.js";
|
|
6
7
|
import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
|
|
7
8
|
import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
|
|
8
9
|
import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
|
|
@@ -75,6 +76,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
75
76
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
|
|
76
77
|
import { EventLog } from "./event-log.js";
|
|
77
78
|
import { binaryProbe } from "./binary-probe.js";
|
|
79
|
+
import { deletePlannedTopic, planInstanceTopicDelete, topicDestination, topicMustStay } from "./instance-topic-delete.js";
|
|
78
80
|
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
|
|
79
81
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
80
82
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
@@ -90,6 +92,7 @@ import { formatFleetTime } from "./tz-utils.js";
|
|
|
90
92
|
import { createAdapter } from "./channel/factory.js";
|
|
91
93
|
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
92
94
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
95
|
+
import { MissedOfflineStore } from "./missed-offline.js";
|
|
93
96
|
import { createBackend } from "./backend/factory.js";
|
|
94
97
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
95
98
|
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
@@ -169,6 +172,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
169
172
|
}
|
|
170
173
|
import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
171
174
|
import { LogService } from "./log-service.js";
|
|
175
|
+
import { viewHistory } from "./view-history.js";
|
|
172
176
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
|
|
173
177
|
import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
|
|
174
178
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
@@ -201,7 +205,7 @@ import { envFileKeys } from "./token-env-name.js";
|
|
|
201
205
|
import { discordGeneralChannelId } from "./discord-general.js";
|
|
202
206
|
import { parseEnvText } from "./env-file.js";
|
|
203
207
|
import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
|
|
204
|
-
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
208
|
+
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, setCapacityLookup, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
205
209
|
import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
206
210
|
import { LoginSession } from "./login-manager.js";
|
|
207
211
|
import { runOnLoginShell } from "./login-shell.js";
|
|
@@ -232,6 +236,8 @@ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteC
|
|
|
232
236
|
import { recentChatContext } from "./log-tail.js";
|
|
233
237
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
234
238
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
239
|
+
import { AgentCredentials, agentCredentialPolicy } from "./agent-credentials.js";
|
|
240
|
+
import { writeSecretFile } from "./secret-file.js";
|
|
235
241
|
import { ConfigHistory, ConfigSyntaxError, configIssueText } from "./config-history.js";
|
|
236
242
|
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
|
|
237
243
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -290,7 +296,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
|
|
|
290
296
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
291
297
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
292
298
|
import { kiroHome } from "./backend/kiro-kas-store.js";
|
|
293
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
299
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile, listConfiguredProfiles } from "./backend/credential-profile.js";
|
|
294
300
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
295
301
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
296
302
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
@@ -306,6 +312,12 @@ import { BackendOutageTracker } from "./backend-outage.js";
|
|
|
306
312
|
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
307
313
|
import { getTmuxSession } from "./config.js";
|
|
308
314
|
import { OwnStatusMessages, discordMessageKey } from "./chat-log-ids.js";
|
|
315
|
+
import { PROFILE_TIMELINE_FILE, PROFILE_UNRECORDED, recordProfileLaunch } from "./usage/profile-timeline.js";
|
|
316
|
+
import { readQuotaHistory } from "./usage/quota-history.js";
|
|
317
|
+
import { estimateCapacity } from "./usage/capacity.js";
|
|
318
|
+
import { covered, hourSources } from "./usage-rollup.js";
|
|
319
|
+
import { KEEP_DAYS } from "./cache-ledger.js";
|
|
320
|
+
import { ratesFor } from "./cache-prices.js";
|
|
309
321
|
// Closed migration cohort: these 11 names existed immediately before the first
|
|
310
322
|
// managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
|
|
311
323
|
// adding a bundled skill: its new name is not proof of old AgEnD ownership.
|
|
@@ -457,6 +469,8 @@ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
457
469
|
* while anything real shows signs of life well before the old two minutes.
|
|
458
470
|
*/
|
|
459
471
|
const PROGRESS_MIN_ELAPSED_MS = 30_000;
|
|
472
|
+
/** M-2391: the threshold tick fires this long after it, never a millisecond before (timers can fire early). */
|
|
473
|
+
const PROGRESS_THRESHOLD_MARGIN_MS = 50;
|
|
460
474
|
/** How much of a tool summary the progress line will show before eliding. */
|
|
461
475
|
const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
462
476
|
/**
|
|
@@ -916,6 +930,7 @@ export class FleetManager {
|
|
|
916
930
|
kiroMigrationRuns = new Map();
|
|
917
931
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
918
932
|
needsYou = null;
|
|
933
|
+
missedOfflineStore = null;
|
|
919
934
|
configHistory = null;
|
|
920
935
|
configHistoryTimer = null;
|
|
921
936
|
loadedFleetSource = null;
|
|
@@ -1031,6 +1046,9 @@ export class FleetManager {
|
|
|
1031
1046
|
discordPresenceEagerPending = false;
|
|
1032
1047
|
discordPresenceInFlight = null;
|
|
1033
1048
|
static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
|
|
1049
|
+
/** #3066 §1d: how often a fleet with no Discord bot reads the quota for its history (the user's choice: 30 min). */
|
|
1050
|
+
static QUOTA_BACKGROUND_READ_MS = 30 * 60_000;
|
|
1051
|
+
quotaReadTimer = null;
|
|
1034
1052
|
/** Days of event/activity history to keep. */
|
|
1035
1053
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
1036
1054
|
watchdogTimer = null;
|
|
@@ -2154,6 +2172,22 @@ export class FleetManager {
|
|
|
2154
2172
|
* startup-only key at startup. See checkStartupSignatureConsistency(). */
|
|
2155
2173
|
fleetSignatureMismatch = null;
|
|
2156
2174
|
healthServerListening = false;
|
|
2175
|
+
agentCredentialsCache;
|
|
2176
|
+
get agentCredentials() {
|
|
2177
|
+
return this.agentCredentialsCache ??= new AgentCredentials(name => agentCredentialPolicy(this.fleetConfig, name, this.isClassicInstance(name)), name => {
|
|
2178
|
+
try {
|
|
2179
|
+
const token = randomBytes(32).toString("hex");
|
|
2180
|
+
const result = writeSecretFile(join(this.getInstanceDir(name), "agent.token"), token);
|
|
2181
|
+
if (!result.ok)
|
|
2182
|
+
this.logger.error({ name, mode: result.mode, reason: result.reason }, "Agent token file is not owner-only");
|
|
2183
|
+
return token;
|
|
2184
|
+
}
|
|
2185
|
+
catch {
|
|
2186
|
+
this.logger.error({ name }, "Agent credential renewal failed; prior generation remains retired");
|
|
2187
|
+
return null;
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2157
2191
|
constructor(dataDir) {
|
|
2158
2192
|
this.dataDir = dataDir;
|
|
2159
2193
|
FleetManager.signalTarget = this;
|
|
@@ -2176,6 +2210,7 @@ export class FleetManager {
|
|
|
2176
2210
|
this.topicCommands = new TopicCommands(this);
|
|
2177
2211
|
this.topicArchiver = new TopicArchiver(this);
|
|
2178
2212
|
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
2213
|
+
this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
|
|
2179
2214
|
}
|
|
2180
2215
|
/** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
|
|
2181
2216
|
replyButtonsCtl = null;
|
|
@@ -2534,6 +2569,10 @@ export class FleetManager {
|
|
|
2534
2569
|
const daemon = this.daemons.get(name);
|
|
2535
2570
|
if (daemon?.bootId === daemonBootId)
|
|
2536
2571
|
this.listenForWorkingState(name, daemon);
|
|
2572
|
+
// #3066 §1d: a Codex launch records the credential profile it runs under, so its usage is told by account.
|
|
2573
|
+
if (daemon?.bootId === daemonBootId && this.backendNameOf(name) === "codex") {
|
|
2574
|
+
void recordProfileLaunch(join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE), this.credentialProfileOf(name, "codex") ?? "default");
|
|
2575
|
+
}
|
|
2537
2576
|
// #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
|
|
2538
2577
|
// request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
|
|
2539
2578
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -3371,6 +3410,7 @@ export class FleetManager {
|
|
|
3371
3410
|
* guild channel.
|
|
3372
3411
|
*/
|
|
3373
3412
|
reregisterClassicChannels() {
|
|
3413
|
+
this.agentCredentialsCache?.observePolicies();
|
|
3374
3414
|
if (!this.classicChannels)
|
|
3375
3415
|
return;
|
|
3376
3416
|
const channels = this.classicChannels.getAll();
|
|
@@ -4061,6 +4101,13 @@ export class FleetManager {
|
|
|
4061
4101
|
changed: () => this.emitSseEvent("status", this.getUiStatus()),
|
|
4062
4102
|
});
|
|
4063
4103
|
}
|
|
4104
|
+
/**
|
|
4105
|
+
* Claude history in the View: an instance's earlier conversation for its View, from the transcript the Log reads —
|
|
4106
|
+
* the View's route applied its own access rules first (view-api.ts); the Log's switch plays no part.
|
|
4107
|
+
*/
|
|
4108
|
+
viewHistory(name, q) {
|
|
4109
|
+
return viewHistory((n, rq) => this.log.readBack(n, rq), name, q);
|
|
4110
|
+
}
|
|
4064
4111
|
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
4065
4112
|
logSteps(name, q) { return this.log.steps(name, q); }
|
|
4066
4113
|
/**
|
|
@@ -8591,6 +8638,9 @@ export class FleetManager {
|
|
|
8591
8638
|
if (others.length > 0 || classicAtBoot.length > 0) {
|
|
8592
8639
|
await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
|
|
8593
8640
|
}
|
|
8641
|
+
// #3066 §1d (Fable r1): on every fleet — a web-only one (#2910) has no chat connection at all, and is the clearest
|
|
8642
|
+
// case with no Discord bot reading. Each tick decides for itself whether to read (backgroundQuotaRead).
|
|
8643
|
+
this.startQuotaBackgroundReads();
|
|
8594
8644
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
8595
8645
|
await adapterStartup;
|
|
8596
8646
|
this.startDiscordUsagePresence();
|
|
@@ -8762,6 +8812,178 @@ export class FleetManager {
|
|
|
8762
8812
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
8763
8813
|
}
|
|
8764
8814
|
}
|
|
8815
|
+
/**
|
|
8816
|
+
* #3066 §1d (the user: yes, every 30 min): the quota history (usage/quota-history.ts) records only the readings AgEnD
|
|
8817
|
+
* makes. A Discord bot's presence refresh reads every 15 min; without one, nothing reads while nobody looks. So, with no
|
|
8818
|
+
* Discord bot and while some account has a running agent, read every 30 min — the same cached read (its 5-minute TTL
|
|
8819
|
+
* still bounds the vendor calls), recorded like any other.
|
|
8820
|
+
*/
|
|
8821
|
+
startQuotaBackgroundReads() {
|
|
8822
|
+
if (this.quotaReadTimer)
|
|
8823
|
+
clearInterval(this.quotaReadTimer);
|
|
8824
|
+
this.quotaReadTimer = setInterval(() => { void this.backgroundQuotaRead(); }, FleetManager.QUOTA_BACKGROUND_READ_MS);
|
|
8825
|
+
this.quotaReadTimer.unref?.();
|
|
8826
|
+
// #3066 §1: the measured capacity, published for the usage reads (get_usage, the page) — refreshed every 10 min.
|
|
8827
|
+
setCapacityLookup((provider, metric) => this.capacityEstimates.get(`${provider}\u0000${metric}`) ?? null);
|
|
8828
|
+
if (this.capacityTimer)
|
|
8829
|
+
clearInterval(this.capacityTimer);
|
|
8830
|
+
void this.refreshCapacity();
|
|
8831
|
+
this.capacityTimer = setInterval(() => { void this.refreshCapacity(); }, FleetManager.CAPACITY_REFRESH_MS);
|
|
8832
|
+
this.capacityTimer.unref?.();
|
|
8833
|
+
}
|
|
8834
|
+
/** #3066 §1: the latest estimates, by `provider\0metric` (what usage-api's lookup reads, synchronously). */
|
|
8835
|
+
capacityEstimates = new Map();
|
|
8836
|
+
capacityTimer = null;
|
|
8837
|
+
static CAPACITY_REFRESH_MS = 10 * 60_000;
|
|
8838
|
+
/**
|
|
8839
|
+
* #3066 §1: estimate each account's weekly 100% from the quota history (§1d) and the usage ledger's hours — both read
|
|
8840
|
+
* asynchronously, the arithmetic small (usage/capacity.ts). An account is its usage provider id ("claude",
|
|
8841
|
+
* "claude:hsinlin", "codex:yanxianchen"): its hours are the ledger sources of that backend under that credential profile
|
|
8842
|
+
* (Codex's "unrecorded" hours are no account's). A per-model weekly ("Sonnet (weekly)") counts only that model's tokens.
|
|
8843
|
+
*
|
|
8844
|
+
* Prism #3073: no estimate rather than a wrong one —
|
|
8845
|
+
* - a window is measured only where the ledger has its hours: kept (31 days) and scanned (`days.coverage`) for every
|
|
8846
|
+
* instance of the account's backend, and every instance whose hours count for it; the quota history goes back 12 weeks;
|
|
8847
|
+
* - a profile literally named "default", "unknown" or "unrecorded" (configured, on disk, or in the quota history) makes
|
|
8848
|
+
* those hours ambiguous: neither the host
|
|
8849
|
+
* account nor the named one is estimated (each would be credited the other's use, or lose its own);
|
|
8850
|
+
* - only a metric the vendor marks per-model (`scope: "model"`) is filtered by model; another named weekly pool
|
|
8851
|
+
* ("Luna Reserve (weekly)", Antigravity pools) is no model name and is not estimated.
|
|
8852
|
+
*/
|
|
8853
|
+
async refreshCapacity() {
|
|
8854
|
+
try {
|
|
8855
|
+
const readings = await readQuotaHistory();
|
|
8856
|
+
const groups = new Map();
|
|
8857
|
+
for (const r of readings) {
|
|
8858
|
+
const k = `${r.provider}\u0000${r.metric}`;
|
|
8859
|
+
(groups.get(k) ?? groups.set(k, []).get(k)).push(r);
|
|
8860
|
+
}
|
|
8861
|
+
if (!groups.size) {
|
|
8862
|
+
this.capacityEstimates = new Map();
|
|
8863
|
+
return 0;
|
|
8864
|
+
}
|
|
8865
|
+
const data = await this.cacheServiceFor().usageData();
|
|
8866
|
+
const now = Date.now(), DAY = 24 * 3_600_000, HOUR = 3_600_000;
|
|
8867
|
+
const keptFrom = now - KEEP_DAYS * DAY + HOUR; // the oldest hour still in every ledger (an hour of margin)
|
|
8868
|
+
// Profiles whose names are also the ledger's own tags: their hours cannot be told from the host's / unattributed.
|
|
8869
|
+
// Any trace of such a profile counts (Prism #3073 r2), not only today's config — a binding removed or switched
|
|
8870
|
+
// leaves its hours in the ledger: configured now, its directory on this host, or its account in the quota history.
|
|
8871
|
+
const RESERVED = new Set(["default", "unknown", PROFILE_UNRECORDED]);
|
|
8872
|
+
const historyProviders = new Set(readings.map((r) => r.provider));
|
|
8873
|
+
const reservedNamed = new Map();
|
|
8874
|
+
const reservedFor = (backend) => {
|
|
8875
|
+
const known = reservedNamed.get(backend);
|
|
8876
|
+
if (known)
|
|
8877
|
+
return known;
|
|
8878
|
+
const found = new Set(listConfiguredProfiles(this.fleetConfig, backend).filter((n) => RESERVED.has(n)));
|
|
8879
|
+
const base = usageProviderIdForBackend(backend);
|
|
8880
|
+
for (const n of RESERVED) {
|
|
8881
|
+
if (base && historyProviders.has(`${base}:${n}`))
|
|
8882
|
+
found.add(n);
|
|
8883
|
+
if (credentialHomeSpec(backend) && existsSync(credentialProfileHome(this.dataDir, backend, n)))
|
|
8884
|
+
found.add(n);
|
|
8885
|
+
}
|
|
8886
|
+
return reservedNamed.set(backend, found).get(backend);
|
|
8887
|
+
};
|
|
8888
|
+
// Every instance of a backend may have used any of its accounts (bindings change): an account is measured only
|
|
8889
|
+
// where all of them were scanned (Prism #3073 r2) — one still waiting for its first scan has no hours yet, not none.
|
|
8890
|
+
const daysOfBase = new Map();
|
|
8891
|
+
for (const inst of data.instances) {
|
|
8892
|
+
const base = usageProviderIdForBackend(inst.inst.backend);
|
|
8893
|
+
if (base)
|
|
8894
|
+
(daysOfBase.get(base) ?? daysOfBase.set(base, []).get(base)).push(inst.days);
|
|
8895
|
+
}
|
|
8896
|
+
// provider id → hour → model key → totals; and the instances whose hours count for it (their scan coverage).
|
|
8897
|
+
const byProvider = new Map();
|
|
8898
|
+
const daysOf = new Map();
|
|
8899
|
+
for (const inst of data.instances) {
|
|
8900
|
+
for (const [hk, hour] of Object.entries(inst.ledger.hours)) {
|
|
8901
|
+
for (const src of hourSources(hour)) {
|
|
8902
|
+
const base = usageProviderIdForBackend(src.backend);
|
|
8903
|
+
if (!base)
|
|
8904
|
+
continue;
|
|
8905
|
+
// Ambiguous (a named profile with the tag's name), or no account's: counted for no one, so an account whose
|
|
8906
|
+
// hours these may be has none — and gets no estimate, never a low one.
|
|
8907
|
+
if (reservedFor(src.backend).has(src.profile) || src.profile === "unknown" || src.profile === PROFILE_UNRECORDED)
|
|
8908
|
+
continue;
|
|
8909
|
+
const provider = src.profile === "default" ? base : `${base}:${src.profile}`;
|
|
8910
|
+
const list = daysOf.get(provider) ?? daysOf.set(provider, []).get(provider);
|
|
8911
|
+
if (!list.includes(inst.days))
|
|
8912
|
+
list.push(inst.days);
|
|
8913
|
+
const hours = byProvider.get(provider) ?? byProvider.set(provider, new Map()).get(provider);
|
|
8914
|
+
const into = hours.get(Number(hk)) ?? hours.set(Number(hk), Object.create(null)).get(Number(hk));
|
|
8915
|
+
for (const [mk, tot] of Object.entries(src.m)) {
|
|
8916
|
+
const cur = into[mk] ??= [0, 0, 0, 0, 0, 0, 0];
|
|
8917
|
+
for (let i = 0; i < 7; i++)
|
|
8918
|
+
cur[i] = (cur[i] ?? 0) + (tot[i] ?? 0);
|
|
8919
|
+
}
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
}
|
|
8923
|
+
const next = new Map();
|
|
8924
|
+
for (const [key, list] of groups) {
|
|
8925
|
+
const [provider, metric] = key.split("\u0000");
|
|
8926
|
+
// A per-model weekly ("Sonnet (weekly)", the vendor's scope "model"): that model's tokens only. Another named
|
|
8927
|
+
// pool is not a model: no estimate. "Weekly", "Weekly limit": everything.
|
|
8928
|
+
const named = /^(.+) \((?:weekly|monthly|\d+d)\)$/i.exec(metric)?.[1]?.toLowerCase() ?? null;
|
|
8929
|
+
const perModel = list.some((r) => r.scope === "model");
|
|
8930
|
+
if (named && !perModel)
|
|
8931
|
+
continue;
|
|
8932
|
+
const hours = [];
|
|
8933
|
+
for (const [hour, models] of byProvider.get(provider) ?? []) {
|
|
8934
|
+
let api = 0, output = 0, all = 0;
|
|
8935
|
+
for (const [mk, tot] of Object.entries(models)) {
|
|
8936
|
+
const bar = mk.lastIndexOf("|"), model = mk.slice(0, bar), long = mk.slice(bar + 1) === "1";
|
|
8937
|
+
if (named && !model.toLowerCase().includes(named))
|
|
8938
|
+
continue;
|
|
8939
|
+
const [, uncached, read, w5, w1h, w, out] = tot;
|
|
8940
|
+
const r = ratesFor(model, long ? Number.MAX_SAFE_INTEGER : 0);
|
|
8941
|
+
api = api === null || !r ? null : api + (uncached * r.input + read * r.read + w5 * r.write5m + w1h * r.write1h + w * r.write + out * r.output) / 1_000_000;
|
|
8942
|
+
output += out;
|
|
8943
|
+
all += uncached + read + w5 + w1h + w + out;
|
|
8944
|
+
}
|
|
8945
|
+
if (all > 0)
|
|
8946
|
+
hours.push({ hour, api, output, all });
|
|
8947
|
+
}
|
|
8948
|
+
const base = provider.split(":")[0];
|
|
8949
|
+
const scanned = [...new Set([...(daysOf.get(provider) ?? []), ...(daysOfBase.get(base) ?? [])])];
|
|
8950
|
+
const available = (from, to) => {
|
|
8951
|
+
if (from < keptFrom || !scanned.length)
|
|
8952
|
+
return false;
|
|
8953
|
+
for (let h = from; h <= to; h += HOUR)
|
|
8954
|
+
if (!scanned.every((d) => covered(d, h)))
|
|
8955
|
+
return false;
|
|
8956
|
+
return true;
|
|
8957
|
+
};
|
|
8958
|
+
const est = estimateCapacity(list, hours, available);
|
|
8959
|
+
if (est)
|
|
8960
|
+
next.set(key, est);
|
|
8961
|
+
}
|
|
8962
|
+
this.capacityEstimates = next;
|
|
8963
|
+
return next.size;
|
|
8964
|
+
}
|
|
8965
|
+
catch (err) {
|
|
8966
|
+
this.logger.debug({ err }, "capacity estimate refresh failed");
|
|
8967
|
+
return -1;
|
|
8968
|
+
}
|
|
8969
|
+
}
|
|
8970
|
+
/** One background quota read, when nothing else reads (no Discord bot) and an account has a running agent. */
|
|
8971
|
+
async backgroundQuotaRead() {
|
|
8972
|
+
if (this.shuttingDown)
|
|
8973
|
+
return false;
|
|
8974
|
+
if ([...this.adapters.values()].some(a => a.type === "discord" && typeof a.setActivity === "function"))
|
|
8975
|
+
return false;
|
|
8976
|
+
const active = this.getActiveUsageProviderIds();
|
|
8977
|
+
if (active.size === 0)
|
|
8978
|
+
return false;
|
|
8979
|
+
try {
|
|
8980
|
+
await getUsageSnapshot(false, active);
|
|
8981
|
+
return true;
|
|
8982
|
+
}
|
|
8983
|
+
catch {
|
|
8984
|
+
return false;
|
|
8985
|
+
}
|
|
8986
|
+
}
|
|
8765
8987
|
/** Keep Discord profile activity aligned with the same cached usage source as /usage. */
|
|
8766
8988
|
startDiscordUsagePresence() {
|
|
8767
8989
|
if (this.discordPresenceTimer)
|
|
@@ -9237,6 +9459,11 @@ export class FleetManager {
|
|
|
9237
9459
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
9238
9460
|
await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
|
|
9239
9461
|
}, this.logger, "adapter.callback_query"));
|
|
9462
|
+
adapter.on("missed_offline", (ev) => {
|
|
9463
|
+
if (!isCurrentAdapter())
|
|
9464
|
+
return;
|
|
9465
|
+
this.recordMissedOffline(ev);
|
|
9466
|
+
});
|
|
9240
9467
|
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
9241
9468
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
9242
9469
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
@@ -9390,6 +9617,11 @@ export class FleetManager {
|
|
|
9390
9617
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
9391
9618
|
await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
|
|
9392
9619
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
9620
|
+
adapter.on("missed_offline", (ev) => {
|
|
9621
|
+
if (!isCurrentAdapter())
|
|
9622
|
+
return;
|
|
9623
|
+
this.recordMissedOffline(ev);
|
|
9624
|
+
});
|
|
9393
9625
|
this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
|
|
9394
9626
|
// Slash commands: classic bot + admin commands
|
|
9395
9627
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
@@ -10126,6 +10358,34 @@ export class FleetManager {
|
|
|
10126
10358
|
}
|
|
10127
10359
|
return true;
|
|
10128
10360
|
}
|
|
10361
|
+
/** File a Telegram update that was too old to inject; Needs you lists it until Acknowledge. */
|
|
10362
|
+
recordMissedOffline(ev) {
|
|
10363
|
+
if (!this.missedOfflineStore)
|
|
10364
|
+
this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
|
|
10365
|
+
const stub = {
|
|
10366
|
+
source: "telegram", adapterId: ev.adapterId, chatId: ev.chatId, threadId: ev.threadId,
|
|
10367
|
+
messageId: ev.messageId, userId: ev.userId, username: ev.username, text: ev.preview,
|
|
10368
|
+
timestamp: ev.timestamp, isBotMessage: false,
|
|
10369
|
+
};
|
|
10370
|
+
const threadId = this.inboundRouteThreadId(stub);
|
|
10371
|
+
const target = threadId ? this.resolveInboundTarget(stub, threadId) : undefined;
|
|
10372
|
+
let instance = target?.name ?? "";
|
|
10373
|
+
if (!instance) {
|
|
10374
|
+
instance = Object.entries(this.fleetConfig?.instances ?? {}).find(([name, cfg]) => cfg.general_topic && this.getInstanceAdapterId(name) === ev.adapterId)?.[0] ?? "";
|
|
10375
|
+
}
|
|
10376
|
+
const place = [ev.chatTitle, ev.threadId ? `#${ev.threadId}` : ""].filter(Boolean).join(" · ") || ev.chatId;
|
|
10377
|
+
this.missedOfflineStore.add({
|
|
10378
|
+
id: `missed:${ev.adapterId}:${ev.updateId}`,
|
|
10379
|
+
adapterId: ev.adapterId,
|
|
10380
|
+
instance,
|
|
10381
|
+
username: ev.username,
|
|
10382
|
+
place,
|
|
10383
|
+
preview: ev.preview,
|
|
10384
|
+
since: ev.timestamp.getTime(),
|
|
10385
|
+
});
|
|
10386
|
+
this.logger.info({ adapterId: ev.adapterId, updateId: ev.updateId, instance, chatId: ev.chatId, threadId: ev.threadId }, "Telegram message missed while AgEnD was offline — filed for Needs you");
|
|
10387
|
+
this.needsYou?.poke();
|
|
10388
|
+
}
|
|
10129
10389
|
/**
|
|
10130
10390
|
* #1085: coincident topic numbers must not route across Telegram groups.
|
|
10131
10391
|
*/
|
|
@@ -11749,6 +12009,11 @@ export class FleetManager {
|
|
|
11749
12009
|
let result;
|
|
11750
12010
|
switch (msg.type) {
|
|
11751
12011
|
case "fleet_decision_create": {
|
|
12012
|
+
const supersedes = this.decisionSupersedes("ipc-typed", instanceName, payload.supersedes);
|
|
12013
|
+
if ("error" in supersedes) {
|
|
12014
|
+
ipc.send({ type: "fleet_decision_response", fleetRequestId, error: supersedes.error });
|
|
12015
|
+
return;
|
|
12016
|
+
}
|
|
11752
12017
|
// Prune expired decisions on create
|
|
11753
12018
|
db.pruneExpiredDecisions();
|
|
11754
12019
|
result = db.createDecision({
|
|
@@ -11759,7 +12024,7 @@ export class FleetManager {
|
|
|
11759
12024
|
tags: payload.tags,
|
|
11760
12025
|
ttl_days: payload.ttl_days,
|
|
11761
12026
|
created_by: instanceName,
|
|
11762
|
-
supersedes:
|
|
12027
|
+
supersedes: supersedes.id,
|
|
11763
12028
|
});
|
|
11764
12029
|
break;
|
|
11765
12030
|
}
|
|
@@ -11936,6 +12201,21 @@ export class FleetManager {
|
|
|
11936
12201
|
return "ok";
|
|
11937
12202
|
}
|
|
11938
12203
|
}
|
|
12204
|
+
/**
|
|
12205
|
+
* `post_decision` with `supersedes` changes a decision someone else may have recorded (it stops being active): the
|
|
12206
|
+
* same permission as `update_decision`, asked through the same live check (tool set + dashboard switches), on both
|
|
12207
|
+
* create paths. An empty or absent value supersedes nothing and needs nothing; anything else must be an id.
|
|
12208
|
+
*/
|
|
12209
|
+
decisionSupersedes(sink, instanceName, value) {
|
|
12210
|
+
if (value === undefined || value === null || value === "")
|
|
12211
|
+
return { id: undefined };
|
|
12212
|
+
if (typeof value !== "string")
|
|
12213
|
+
return { error: "supersedes must be a decision id" };
|
|
12214
|
+
const permitted = this.checkToolPermission(sink, instanceName, "update_decision");
|
|
12215
|
+
if (!permitted.allowed)
|
|
12216
|
+
return { error: `Superseding a decision needs update_decision. ${permitted.message}` };
|
|
12217
|
+
return { id: value };
|
|
12218
|
+
}
|
|
11939
12219
|
async handleDecisionCrudHttp(instance, op, args) {
|
|
11940
12220
|
if (!this.scheduler)
|
|
11941
12221
|
return { error: "Scheduler not available" };
|
|
@@ -11951,16 +12231,24 @@ export class FleetManager {
|
|
|
11951
12231
|
if (!title || !content)
|
|
11952
12232
|
return { error: "title and content are required" };
|
|
11953
12233
|
const scope = args.scope === "fleet" ? "fleet" : "project";
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
11960
|
-
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
|
|
12234
|
+
const supersedes = this.decisionSupersedes("agent-endpoint", instance, args.supersedes);
|
|
12235
|
+
if ("error" in supersedes)
|
|
12236
|
+
return { error: supersedes.error };
|
|
12237
|
+
try {
|
|
12238
|
+
return db.createDecision({
|
|
12239
|
+
project_root: projectRoot,
|
|
12240
|
+
scope,
|
|
12241
|
+
title,
|
|
12242
|
+
content,
|
|
12243
|
+
tags: asStrArr(args.tags),
|
|
12244
|
+
ttl_days: asNum(args.ttl_days),
|
|
12245
|
+
supersedes: supersedes.id,
|
|
12246
|
+
created_by: instance,
|
|
12247
|
+
});
|
|
12248
|
+
}
|
|
12249
|
+
catch (err) {
|
|
12250
|
+
return { error: err.message }; // a refused predecessor: nothing was inserted
|
|
12251
|
+
}
|
|
11964
12252
|
}
|
|
11965
12253
|
// #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
|
|
11966
12254
|
// The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
|
|
@@ -12249,16 +12537,89 @@ export class FleetManager {
|
|
|
12249
12537
|
}
|
|
12250
12538
|
return adapter.createTopic(topicName);
|
|
12251
12539
|
}
|
|
12252
|
-
|
|
12253
|
-
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
this.
|
|
12261
|
-
|
|
12540
|
+
/**
|
|
12541
|
+
* Delete an agent and, unless asked not to, its own chat topic (user decision: by default). The topic is planned
|
|
12542
|
+
* before the removal (its id and bot go with the fleet.yaml entry) and deleted through the agent's OWN bot after it;
|
|
12543
|
+
* the outcome is the platform's answer (instance-topic-delete.ts). A topic that cannot go never fails the removal.
|
|
12544
|
+
*/
|
|
12545
|
+
async removeInstanceAndTopic(name, authorization, opts) {
|
|
12546
|
+
const plan = opts.deleteTopic ? this.planInstanceTopicDelete(name) : null;
|
|
12547
|
+
if (opts.execution)
|
|
12548
|
+
await this.removeInstance(name, authorization, opts.execution);
|
|
12549
|
+
else
|
|
12550
|
+
await this.removeInstance(name, authorization);
|
|
12551
|
+
// #3055 review (Prism P1): asked again right before the platform call — the fleet as it is NOW, not as planned
|
|
12552
|
+
// before the removal was awaited (a new owner of the topic, or the bot rebuilt).
|
|
12553
|
+
return deletePlannedTopic(plan, this.logger, (p) => {
|
|
12554
|
+
if (this.instanceTopicAdapter(p.adapterId) !== p.adapter)
|
|
12555
|
+
return "the agent's bot changed while it was being deleted, so its topic was left";
|
|
12556
|
+
return topicMustStay(p.destination, this.topicOwnership(), null); // the original is gone: every owner now counts
|
|
12557
|
+
}, { begin: (d) => this.topicDeletionsInFlight.add(d), end: (d) => this.topicDeletionsInFlight.delete(d) });
|
|
12558
|
+
}
|
|
12559
|
+
/** #3055: destinations whose platform deletion is running now; a binding to one waits until it is over. */
|
|
12560
|
+
topicDeletionsInFlight = new Set();
|
|
12561
|
+
/** True while a topic with this id is being deleted on any connection (a new binding of it is refused meanwhile). */
|
|
12562
|
+
isTopicDeletionInFlight(topicId) {
|
|
12563
|
+
const tail = `:${topicId}`;
|
|
12564
|
+
return [...this.topicDeletionsInFlight].some(d => d.endsWith(tail));
|
|
12565
|
+
}
|
|
12566
|
+
/**
|
|
12567
|
+
* #3055 review r2 (Prism P1-3): would this instance config bind a topic whose deletion is running now? Compared by
|
|
12568
|
+
* physical destination, so the same thread number in another Telegram group is not held. Settings asks it at commit.
|
|
12569
|
+
*/
|
|
12570
|
+
topicBindingInFlight(inst) {
|
|
12571
|
+
if (this.topicDeletionsInFlight.size === 0 || inst.topic_id == null || inst.topic_id === "")
|
|
12572
|
+
return false;
|
|
12573
|
+
const adapterId = typeof inst.channel_id === "string" && inst.channel_id ? inst.channel_id : this.getPrimaryAdapterId();
|
|
12574
|
+
return this.topicDeletionsInFlight.has(this.destinationFor(adapterId, inst.topic_id));
|
|
12575
|
+
}
|
|
12576
|
+
/** Any topic deletion running now (a connection's group or General may not be re-pointed meanwhile). */
|
|
12577
|
+
topicDeletionPending() { return this.topicDeletionsInFlight.size > 0; }
|
|
12578
|
+
/** The physical destination of `topicId` on the bot `adapterId`. */
|
|
12579
|
+
destinationFor(adapterId, topicId) {
|
|
12580
|
+
const world = adapterId ? this.worlds.get(adapterId) : undefined;
|
|
12581
|
+
const platform = world?.adapter?.type ?? this.getChannelConfig(adapterId)?.type;
|
|
12582
|
+
const groupId = world?.groupId ?? String(this.getChannelConfig(adapterId)?.group_id ?? "");
|
|
12583
|
+
return topicDestination(platform, groupId, adapterId, topicId);
|
|
12584
|
+
}
|
|
12585
|
+
/** The adapter of the bot `adapterId` names — never a substitute for a bot that is not there. */
|
|
12586
|
+
instanceTopicAdapter(adapterId) {
|
|
12587
|
+
return (adapterId ? this.worlds.get(adapterId)?.adapter : this.worlds.size === 0 ? this.adapter ?? undefined : undefined) ?? undefined;
|
|
12588
|
+
}
|
|
12589
|
+
/**
|
|
12590
|
+
* Topic ownership by physical destination (#3055 review, Prism P1): an instance's topic is where its bot's platform
|
|
12591
|
+
* puts it (a Telegram group + thread, a Discord channel), so two bots in one group, or two instances on one
|
|
12592
|
+
* channel, are seen to share it; and every connection's own General is protected, with or without a General agent.
|
|
12593
|
+
*/
|
|
12594
|
+
topicOwnership() {
|
|
12595
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
12596
|
+
const destinationOf = (n) => {
|
|
12597
|
+
const cfg = this.fleetConfig?.instances?.[n];
|
|
12598
|
+
if (!cfg || cfg.topic_id == null || cfg.topic_id === "")
|
|
12599
|
+
return null;
|
|
12600
|
+
return this.destinationFor(this.getInstanceAdapterId(n), cfg.topic_id);
|
|
12601
|
+
};
|
|
12602
|
+
const generalDestinations = () => {
|
|
12603
|
+
const out = new Set();
|
|
12604
|
+
for (const ch of channels) {
|
|
12605
|
+
const id = ch.id ?? ch.type;
|
|
12606
|
+
const groupId = this.worlds.get(id)?.groupId ?? (ch.group_id != null ? String(ch.group_id) : undefined);
|
|
12607
|
+
if (ch.type === "telegram")
|
|
12608
|
+
out.add(topicDestination("telegram", groupId, id, 1));
|
|
12609
|
+
if (ch.type === "discord") {
|
|
12610
|
+
const gcid = discordGeneralChannelId(ch);
|
|
12611
|
+
if (gcid)
|
|
12612
|
+
out.add(topicDestination("discord", groupId, id, gcid));
|
|
12613
|
+
}
|
|
12614
|
+
}
|
|
12615
|
+
return out;
|
|
12616
|
+
};
|
|
12617
|
+
return { instances: this.fleetConfig?.instances, destinationOf, generalDestinations };
|
|
12618
|
+
}
|
|
12619
|
+
/** What deleting `name`'s topic would act on: its own bot only — a bot that is not there is never substituted. */
|
|
12620
|
+
planInstanceTopicDelete(name) {
|
|
12621
|
+
const adapterId = this.getInstanceAdapterId(name);
|
|
12622
|
+
return planInstanceTopicDelete({ name, adapterId, adapter: this.instanceTopicAdapter(adapterId), ...this.topicOwnership() });
|
|
12262
12623
|
}
|
|
12263
12624
|
getForumTopicDeleter(adapterId) {
|
|
12264
12625
|
const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
|
|
@@ -12659,6 +13020,7 @@ export class FleetManager {
|
|
|
12659
13020
|
Object.assign(current, resolved);
|
|
12660
13021
|
}
|
|
12661
13022
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
13023
|
+
this.agentCredentialsCache?.observePolicies();
|
|
12662
13024
|
this.refreshSavedDashboardPermissions();
|
|
12663
13025
|
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
12664
13026
|
}
|
|
@@ -14976,6 +15338,7 @@ export class FleetManager {
|
|
|
14976
15338
|
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
14977
15339
|
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
14978
15340
|
claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
|
|
15341
|
+
...(this.backendNameOf(name) === "codex" ? { profileTimelinePath: join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE) } : {}),
|
|
14979
15342
|
kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
|
|
14980
15343
|
}));
|
|
14981
15344
|
const taken = new Set(fleet.map(i => i.name));
|
|
@@ -14987,6 +15350,9 @@ export class FleetManager {
|
|
|
14987
15350
|
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
14988
15351
|
profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
14989
15352
|
kiroDbPath: this.kiroDbPathOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
15353
|
+
// #3066 §1d (Fable r1 #2): a ClassicBot Codex room records its launches too (onDaemonReady) — read them the same way.
|
|
15354
|
+
...((this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend) === "codex"
|
|
15355
|
+
? { profileTimelinePath: join(this.getInstanceDir(ch.instanceName), PROFILE_TIMELINE_FILE) } : {}),
|
|
14990
15356
|
}));
|
|
14991
15357
|
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
14992
15358
|
}
|
|
@@ -15224,6 +15590,10 @@ export class FleetManager {
|
|
|
15224
15590
|
throw new Error("the delivery outbox is not open");
|
|
15225
15591
|
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
15226
15592
|
},
|
|
15593
|
+
missedOffline: () => (this.missedOfflineStore?.list(Date.now()) ?? []).map(m => ({
|
|
15594
|
+
id: m.id, instance: m.instance, owner: m.adapterId, username: m.username, place: m.place, preview: m.preview, since: m.since,
|
|
15595
|
+
})),
|
|
15596
|
+
acknowledgeMissed: (id, by) => this.missedOfflineStore?.acknowledge(id, by) ?? false,
|
|
15227
15597
|
tasks: () => this.needsYouTaskItems(),
|
|
15228
15598
|
backendSignIns: () => this.needsYouBackendSignIns(),
|
|
15229
15599
|
configIssues: () => (this.configHistory?.currentIssues() ?? []).map(issue => ({
|
|
@@ -15938,8 +16308,9 @@ export class FleetManager {
|
|
|
15938
16308
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
15939
16309
|
},
|
|
15940
16310
|
signOut: (handle) => {
|
|
15941
|
-
this.webSessions?.revokeByHandle(handle);
|
|
15942
|
-
this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
|
|
16311
|
+
const session = this.webSessions?.revokeByHandle(handle) ?? { found: false, durable: true };
|
|
16312
|
+
const authorization = this.sessionAuthorizationStore()?.prune(a => a.handle !== handle) ?? { dropped: 0, durable: true };
|
|
16313
|
+
return { durable: session.durable, authorizationDurable: authorization.durable };
|
|
15943
16314
|
},
|
|
15944
16315
|
event: (type, payload) => { try {
|
|
15945
16316
|
this.eventLog?.insert("web", type, payload);
|
|
@@ -15952,6 +16323,7 @@ export class FleetManager {
|
|
|
15952
16323
|
expired: (c) => t("web_auth.card_expired", c.label),
|
|
15953
16324
|
refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
|
|
15954
16325
|
revoked: (c) => t("web_auth.card_revoked", c.label),
|
|
16326
|
+
notSaved: (c) => t("web_auth.card_not_saved", c.label),
|
|
15955
16327
|
ended: (c) => t("web_auth.card_ended", c.label),
|
|
15956
16328
|
generalHint: () => t("web_auth.general_hint"),
|
|
15957
16329
|
confirmLabel: () => t("web_auth.confirm"),
|
|
@@ -16872,7 +17244,14 @@ export class FleetManager {
|
|
|
16872
17244
|
return;
|
|
16873
17245
|
}
|
|
16874
17246
|
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
16875
|
-
|
|
17247
|
+
// M-2391: a re-post below a reply (preserveProgress) continues the same turn — its elapsed time continues from
|
|
17248
|
+
// the live bubble's start, as its tool list does. Restarting it at 0 meant an agent replying more often than the
|
|
17249
|
+
// threshold never showed elapsed time at all. A fresh turn (no preserve) starts at 0 as before.
|
|
17250
|
+
const carriedStart = preserveProgress ? this.liveButtonStart(instanceName) : undefined;
|
|
17251
|
+
const carriedMs = carriedStart !== undefined ? Math.max(0, Date.now() - carriedStart) : 0;
|
|
17252
|
+
const initialProgressText = carriedStart !== undefined && carriedMs >= this.progressMinElapsedMs()
|
|
17253
|
+
? FleetManager.progressText(carriedMs, null, this.progressMinElapsedMs(), this.progressPrefixFor(instanceName, carriedMs))
|
|
17254
|
+
: FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
|
|
16876
17255
|
try {
|
|
16877
17256
|
const sent = await adapter.notifyAlert(chatId, {
|
|
16878
17257
|
type: "cancel",
|
|
@@ -16895,7 +17274,7 @@ export class FleetManager {
|
|
|
16895
17274
|
// Elapsed time is measured from when this button was posted — i.e. from
|
|
16896
17275
|
// when the work was handed over — not from the pane's working transition,
|
|
16897
17276
|
// which resets if the CLI blips idle mid-turn.
|
|
16898
|
-
startedAt: Date.now(),
|
|
17277
|
+
startedAt: carriedStart ?? Date.now(),
|
|
16899
17278
|
// Matches the text notifyAlert just posted, so the first 60s tick does
|
|
16900
17279
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
16901
17280
|
// with nothing visibly changed.
|
|
@@ -17018,14 +17397,16 @@ export class FleetManager {
|
|
|
17018
17397
|
* Make a tool summary safe to paste into a channel message.
|
|
17019
17398
|
*
|
|
17020
17399
|
* The text is agent-controlled (it is built from tool inputs — file paths,
|
|
17021
|
-
* shell commands), so it gets flattened to one line, capped,
|
|
17022
|
-
* the two Discord mass-mention triggers.
|
|
17023
|
-
*
|
|
17400
|
+
* shell commands), so it gets flattened to one line, secrets masked, capped,
|
|
17401
|
+
* and stripped of the two Discord mass-mention triggers. Masking comes BEFORE
|
|
17402
|
+
* the cap: a cap can cut a mask's context but must never be what hides (or
|
|
17403
|
+
* fails to hide) a secret. Neither channel renders it with a parse mode, so no
|
|
17404
|
+
* markup escaping is needed beyond that.
|
|
17024
17405
|
*/
|
|
17025
17406
|
static sanitizeActivity(activity) {
|
|
17026
17407
|
if (!activity)
|
|
17027
17408
|
return null;
|
|
17028
|
-
const flat = activity.replace(/\s+/g, " ").replace(/@(everyone|here)/g, "@$1").trim();
|
|
17409
|
+
const flat = maskSecrets(activity.replace(/\s+/g, " ")).replace(/@(everyone|here)/g, "@$1").trim();
|
|
17029
17410
|
if (!flat)
|
|
17030
17411
|
return null;
|
|
17031
17412
|
return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
|
|
@@ -17144,6 +17525,12 @@ export class FleetManager {
|
|
|
17144
17525
|
}
|
|
17145
17526
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
17146
17527
|
}
|
|
17528
|
+
/** M-2391: when the instance's live (not retiring) bubble started — the current turn's start — or undefined. */
|
|
17529
|
+
liveButtonStart(instanceName) {
|
|
17530
|
+
const starts = [...this.cancelButtons.values()].filter((e) => e.instanceName === instanceName && !e.retiring && typeof e.startedAt === "number")
|
|
17531
|
+
.map((e) => e.startedAt);
|
|
17532
|
+
return starts.length ? Math.min(...starts) : undefined;
|
|
17533
|
+
}
|
|
17147
17534
|
startProgressTicker(entry) {
|
|
17148
17535
|
const tick = () => this.refreshBubble(entry);
|
|
17149
17536
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
@@ -17153,7 +17540,9 @@ export class FleetManager {
|
|
|
17153
17540
|
// one additional edit per turn that lives past the threshold.
|
|
17154
17541
|
const firstAt = this.progressMinElapsedMs() - (Date.now() - (entry.startedAt ?? Date.now()));
|
|
17155
17542
|
if (firstAt > 0 && firstAt < PROGRESS_UPDATE_INTERVAL_MS) {
|
|
17156
|
-
|
|
17543
|
+
// M-2391: a little past the threshold — a timer can fire a millisecond before Date.now() reaches it, which
|
|
17544
|
+
// rendered the unchanged "處理中…" text and skipped the edit until the next 60 s interval.
|
|
17545
|
+
const firstTick = setTimeout(tick, firstAt + PROGRESS_THRESHOLD_MARGIN_MS);
|
|
17157
17546
|
firstTick.unref?.();
|
|
17158
17547
|
}
|
|
17159
17548
|
}
|
|
@@ -21508,6 +21897,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21508
21897
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
21509
21898
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
21510
21899
|
this.shuttingDown = true;
|
|
21900
|
+
// Stop receive admission before the first await or any instance shutdown; outbound
|
|
21901
|
+
// adapters remain available below to retire notices and pending prompts.
|
|
21902
|
+
for (const state of this.adapterState.values()) {
|
|
21903
|
+
if (state.retryTimer) {
|
|
21904
|
+
clearTimeout(state.retryTimer);
|
|
21905
|
+
state.retryTimer = undefined;
|
|
21906
|
+
}
|
|
21907
|
+
}
|
|
21908
|
+
const inboundStopped = Promise.all([...this.adapters.values()].map(async (adapter) => {
|
|
21909
|
+
try {
|
|
21910
|
+
await adapter.stopReceiving?.();
|
|
21911
|
+
}
|
|
21912
|
+
catch (err) {
|
|
21913
|
+
this.logger.warn({ adapterId: adapter.id, err }, "Adapter receive stop failed; inbound remains fenced");
|
|
21914
|
+
}
|
|
21915
|
+
}));
|
|
21511
21916
|
this.workingRecovery?.reset();
|
|
21512
21917
|
if (this.workingRecoveryTimer)
|
|
21513
21918
|
clearInterval(this.workingRecoveryTimer);
|
|
@@ -21567,6 +21972,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21567
21972
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
21568
21973
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
21569
21974
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
21975
|
+
await inboundStopped;
|
|
21570
21976
|
await publicStopped;
|
|
21571
21977
|
await sharesStopped?.catch(() => { });
|
|
21572
21978
|
await profileStopped;
|
|
@@ -21577,13 +21983,6 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21577
21983
|
await this.cpuProfileControl?.close();
|
|
21578
21984
|
this.cpuProfileControl = null;
|
|
21579
21985
|
await this.shutdownLoginWindows();
|
|
21580
|
-
// Cancel adapter retry timers
|
|
21581
|
-
for (const state of this.adapterState.values()) {
|
|
21582
|
-
if (state.retryTimer) {
|
|
21583
|
-
clearTimeout(state.retryTimer);
|
|
21584
|
-
state.retryTimer = undefined;
|
|
21585
|
-
}
|
|
21586
|
-
}
|
|
21587
21986
|
this.clearStatuslineWatchers();
|
|
21588
21987
|
this.costGuard?.stop();
|
|
21589
21988
|
this.dailySummary?.stop();
|
|
@@ -21621,6 +22020,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21621
22020
|
clearInterval(this.discordPresenceTimer);
|
|
21622
22021
|
this.discordPresenceTimer = null;
|
|
21623
22022
|
}
|
|
22023
|
+
if (this.quotaReadTimer) {
|
|
22024
|
+
clearInterval(this.quotaReadTimer);
|
|
22025
|
+
this.quotaReadTimer = null;
|
|
22026
|
+
}
|
|
22027
|
+
if (this.capacityTimer) {
|
|
22028
|
+
clearInterval(this.capacityTimer);
|
|
22029
|
+
this.capacityTimer = null;
|
|
22030
|
+
}
|
|
22031
|
+
setCapacityLookup(null);
|
|
21624
22032
|
if (this.discordPresenceEagerTimer) {
|
|
21625
22033
|
clearTimeout(this.discordPresenceEagerTimer);
|
|
21626
22034
|
this.discordPresenceEagerTimer = null;
|
|
@@ -21925,6 +22333,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21925
22333
|
// mark every row done and tell the user "changes applied".
|
|
21926
22334
|
return { rejected: why };
|
|
21927
22335
|
}
|
|
22336
|
+
this.agentCredentialsCache?.observePolicies();
|
|
21928
22337
|
if (this.loadedFleetSource)
|
|
21929
22338
|
this.getConfigHistory().accepted("fleet.yaml", this.configPath, this.loadedFleetSource);
|
|
21930
22339
|
this.migrateOfficeStorage();
|
|
@@ -22825,6 +23234,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22825
23234
|
}
|
|
22826
23235
|
async runConnectionBindingApply(job, binding, execution) {
|
|
22827
23236
|
try {
|
|
23237
|
+
// #3055 review r2: a connection is not re-pointed while one of its topics is being deleted (nothing is changed).
|
|
23238
|
+
if (this.topicDeletionPending())
|
|
23239
|
+
throw new Error("a topic deletion is running; try again in a moment");
|
|
22828
23240
|
await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
|
|
22829
23241
|
job.result = "applied";
|
|
22830
23242
|
}
|