@songsid/agend 2.3.4-beta.1 → 2.3.4-beta.10
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/agend-home.d.ts +13 -0
- package/dist/agend-home.js +42 -0
- package/dist/agend-home.js.map +1 -1
- 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 +10 -2
- 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/claude-code.d.ts +3 -0
- package/dist/backend/claude-code.js +89 -4
- package/dist/backend/claude-code.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-session-lookup.d.ts +35 -0
- package/dist/backend/codex-session-lookup.js +93 -0
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +27 -0
- package/dist/backend/codex.js +124 -23
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +2 -0
- package/dist/backend/kiro-identity.js +13 -6
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +3 -0
- package/dist/backend/kiro.js +68 -5
- 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/backend/types.d.ts +6 -1
- package/dist/backend/types.js +3 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +6 -0
- package/dist/backend-availability.js +13 -5
- package/dist/backend-availability.js.map +1 -1
- package/dist/cache-ledger.d.ts +85 -0
- package/dist/cache-ledger.js +236 -1
- 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 +27 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +51 -0
- package/dist/channel/adapters/telegram.js +238 -18
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/inbox-temp-name.d.ts +9 -0
- package/dist/channel/inbox-temp-name.js +13 -0
- package/dist/channel/inbox-temp-name.js.map +1 -0
- package/dist/channel/mcp-tools.js +3 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +2 -0
- package/dist/chat-log-ids.d.ts +48 -0
- package/dist/chat-log-ids.js +182 -0
- package/dist/chat-log-ids.js.map +1 -0
- package/dist/classic-channel-manager.d.ts +8 -1
- package/dist/classic-channel-manager.js +24 -2
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +4 -6
- package/dist/cli.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 +52 -0
- package/dist/daemon.js +530 -34
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-consumed-watch.d.ts +23 -1
- package/dist/delivery-consumed-watch.js +30 -3
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +7 -0
- package/dist/delivery-reconciliation.js +9 -1
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/dialog-screen-text.d.ts +16 -0
- package/dist/dialog-screen-text.js +51 -0
- package/dist/dialog-screen-text.js.map +1 -0
- 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 +147 -11
- package/dist/fleet-manager.js +747 -118
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-yaml-patch.d.ts +3 -0
- package/dist/fleet-yaml-patch.js +103 -0
- package/dist/fleet-yaml-patch.js.map +1 -0
- package/dist/general-knowledge/skills/concepts/SKILL.md +393 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +10 -4
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/install-stream.d.ts +53 -0
- package/dist/install-stream.js +134 -0
- package/dist/install-stream.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +20 -2
- package/dist/instance-lifecycle.js +80 -12
- 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/kiro-engine-status.d.ts +11 -0
- package/dist/kiro-engine-status.js +6 -0
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-resume-summary.d.ts +37 -0
- package/dist/kiro-resume-summary.js +85 -0
- package/dist/kiro-resume-summary.js.map +1 -0
- package/dist/locale.js +82 -6
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +30 -0
- package/dist/log-service.js +36 -3
- 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/login-controller.js +9 -1
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +2 -0
- package/dist/login-flows.js +5 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/login-manager.d.ts +6 -0
- package/dist/login-manager.js +10 -1
- package/dist/login-manager.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 +31 -8
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +22 -2
- package/dist/needs-you.js +17 -3
- package/dist/needs-you.js.map +1 -1
- package/dist/office-share-page.d.ts +1 -1
- package/dist/office-share-page.js +6 -2
- package/dist/office-share-page.js.map +1 -1
- package/dist/outbound-handlers.js +59 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +9 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/package-preimage.js +27 -2
- package/dist/package-preimage.js.map +1 -1
- package/dist/queued-handoffs.d.ts +33 -0
- package/dist/queued-handoffs.js +75 -0
- package/dist/queued-handoffs.js.map +1 -0
- package/dist/reply-buttons-controller.js +10 -0
- package/dist/reply-buttons-controller.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/selection-card.d.ts +2 -3
- package/dist/selection-card.js +99 -26
- package/dist/selection-card.js.map +1 -1
- package/dist/settings-api.d.ts +13 -0
- package/dist/settings-api.js +12 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/stall-reporter.d.ts +21 -0
- package/dist/stall-reporter.js +82 -0
- package/dist/stall-reporter.js.map +1 -0
- package/dist/tmux-control.d.ts +2 -2
- package/dist/tmux-control.js +4 -2
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +6 -3
- package/dist/tmux-exec-helper.js +115 -53
- package/dist/tmux-exec-helper.js.map +1 -1
- package/dist/tmux-manager.d.ts +6 -0
- package/dist/tmux-manager.js +17 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +5 -2
- package/dist/tool-permissions.js +6 -5
- package/dist/tool-permissions.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/tunnel/cloudflared-install.d.ts +4 -0
- package/dist/tunnel/cloudflared-install.js +20 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +8 -1
- package/dist/tunnel/cloudflared.js +34 -6
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/lease.d.ts +3 -3
- package/dist/tunnel/lease.js +23 -19
- package/dist/tunnel/lease.js.map +1 -1
- package/dist/tunnel/manager.js +6 -7
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/ui/chat-store.js +20 -3
- package/dist/ui/chat-strings.js +4 -4
- package/dist/ui/chat-thread.js +52 -10
- package/dist/ui/fleet-canvas.js +4 -3
- package/dist/ui/fleet-office-model.js +55 -22
- package/dist/ui/fleet-office-overview.js +6 -38
- package/dist/ui/fleet-office-pack.js +4 -3
- package/dist/ui/fleet-office-skin.js +114 -21
- package/dist/ui/fleet-office-store.js +5 -0
- package/dist/ui/fleet-office-zones.js +2 -2
- package/dist/ui/fleet-office.js +41 -9
- package/dist/ui/fleet-usage.js +90 -27
- package/dist/ui/panel-chat.js +2 -0
- package/dist/ui/panel-log.js +7 -1
- package/dist/ui/panel-needs.js +17 -8
- package/dist/ui/settings-strings.js +2 -2
- package/dist/ui/shared/app-i18n.js +4 -4
- package/dist/ui/shared/app-needs.js +1 -1
- package/dist/ui/shared/app-shell.js +4 -2
- package/dist/ui/shared/app-stream.js +26 -0
- package/dist/ui/shared/app.css +51 -3
- package/dist/ui/shared/office/guild.png +0 -0
- package/dist/ui/shared/office/school.png +0 -0
- package/dist/ui/shared/panel-view.js +339 -17
- 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 +42 -0
- package/dist/unattended-install.d.ts +6 -0
- package/dist/unattended-install.js +79 -0
- package/dist/unattended-install.js.map +1 -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 +4 -1
- package/dist/usage-rollup.js +12 -3
- package/dist/usage-rollup.js.map +1 -1
- package/dist/view-api.d.ts +31 -0
- package/dist/view-api.js +186 -41
- 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 +15 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +20 -0
- package/dist/web-chat-history.js +21 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-guest-access.js +5 -1
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-login.d.ts +4 -3
- package/dist/web-login.js +23 -14
- package/dist/web-login.js.map +1 -1
- package/dist/web-office-pack-tools.d.ts +1 -1
- package/dist/web-office-pack-tools.js +1 -1
- package/dist/web-office-pack-tools.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/dist/web-session.d.ts +5 -0
- package/dist/web-session.js +20 -8
- package/dist/web-session.js.map +1 -1
- package/launcher/agend-install.cjs +16 -2
- 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";
|
|
@@ -27,6 +28,8 @@ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from
|
|
|
27
28
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
28
29
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
29
30
|
import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
|
|
31
|
+
import { unattendedInstallCommand, loginShell } from "./unattended-install.js";
|
|
32
|
+
import { InstallStream } from "./install-stream.js";
|
|
30
33
|
import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
|
|
31
34
|
import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
|
|
32
35
|
import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
|
|
@@ -37,7 +40,7 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
37
40
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
38
41
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
39
42
|
import { ProfileControlServer } from "./profile-control.js";
|
|
40
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync,
|
|
43
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, realpathSync, lstatSync, closeSync } from "node:fs";
|
|
41
44
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
42
45
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
43
46
|
import { freemem, totalmem, homedir } from "node:os";
|
|
@@ -54,6 +57,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
|
|
|
54
57
|
import { fleetLabel } from "./fleet-label.js";
|
|
55
58
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
56
59
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
60
|
+
import { startStallReporter } from "./stall-reporter.js";
|
|
57
61
|
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
|
|
58
62
|
import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
|
|
59
63
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -72,7 +76,8 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
72
76
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
|
|
73
77
|
import { EventLog } from "./event-log.js";
|
|
74
78
|
import { binaryProbe } from "./binary-probe.js";
|
|
75
|
-
import {
|
|
79
|
+
import { deletePlannedTopic, planInstanceTopicDelete, topicDestination, topicMustStay } from "./instance-topic-delete.js";
|
|
80
|
+
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
|
|
76
81
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
77
82
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
78
83
|
import { AdapterWorld } from "./adapter-world.js";
|
|
@@ -87,6 +92,7 @@ import { formatFleetTime } from "./tz-utils.js";
|
|
|
87
92
|
import { createAdapter } from "./channel/factory.js";
|
|
88
93
|
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
89
94
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
95
|
+
import { MissedOfflineStore } from "./missed-offline.js";
|
|
90
96
|
import { createBackend } from "./backend/factory.js";
|
|
91
97
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
92
98
|
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
@@ -107,7 +113,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
|
|
|
107
113
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
108
114
|
import { safeHandler } from "./safe-async.js";
|
|
109
115
|
import { RoutingEngine } from "./routing-engine.js";
|
|
110
|
-
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
116
|
+
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, checkBinaryInstalledAsync, } from "./instance-lifecycle.js";
|
|
111
117
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
112
118
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
113
119
|
import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
|
|
@@ -166,6 +172,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
166
172
|
}
|
|
167
173
|
import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
168
174
|
import { LogService } from "./log-service.js";
|
|
175
|
+
import { viewHistory } from "./view-history.js";
|
|
169
176
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
|
|
170
177
|
import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
|
|
171
178
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
@@ -198,7 +205,7 @@ import { envFileKeys } from "./token-env-name.js";
|
|
|
198
205
|
import { discordGeneralChannelId } from "./discord-general.js";
|
|
199
206
|
import { parseEnvText } from "./env-file.js";
|
|
200
207
|
import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
|
|
201
|
-
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";
|
|
202
209
|
import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
203
210
|
import { LoginSession } from "./login-manager.js";
|
|
204
211
|
import { runOnLoginShell } from "./login-shell.js";
|
|
@@ -229,6 +236,8 @@ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteC
|
|
|
229
236
|
import { recentChatContext } from "./log-tail.js";
|
|
230
237
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
231
238
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
239
|
+
import { AgentCredentials, agentCredentialPolicy } from "./agent-credentials.js";
|
|
240
|
+
import { writeSecretFile } from "./secret-file.js";
|
|
232
241
|
import { ConfigHistory, ConfigSyntaxError, configIssueText } from "./config-history.js";
|
|
233
242
|
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
|
|
234
243
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -287,7 +296,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
|
|
|
287
296
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
288
297
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
289
298
|
import { kiroHome } from "./backend/kiro-kas-store.js";
|
|
290
|
-
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";
|
|
291
300
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
292
301
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
293
302
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
@@ -302,6 +311,13 @@ import { SpawnGate } from "./spawn-gate.js";
|
|
|
302
311
|
import { BackendOutageTracker } from "./backend-outage.js";
|
|
303
312
|
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
304
313
|
import { getTmuxSession } from "./config.js";
|
|
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";
|
|
305
321
|
// Closed migration cohort: these 11 names existed immediately before the first
|
|
306
322
|
// managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
|
|
307
323
|
// adding a bundled skill: its new name is not proof of old AgEnD ownership.
|
|
@@ -443,6 +459,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
|
443
459
|
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
444
460
|
/** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
|
|
445
461
|
const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
462
|
+
/** M-2281: how long `/login`'s install window may run before it is stopped. */
|
|
463
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
446
464
|
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
447
465
|
/**
|
|
448
466
|
* Default delay before the button starts showing elapsed time. Configurable via
|
|
@@ -451,6 +469,8 @@ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
|
451
469
|
* while anything real shows signs of life well before the old two minutes.
|
|
452
470
|
*/
|
|
453
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;
|
|
454
474
|
/** How much of a tool summary the progress line will show before eliding. */
|
|
455
475
|
const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
456
476
|
/**
|
|
@@ -622,6 +642,14 @@ function emojiListFilter(opts) {
|
|
|
622
642
|
}
|
|
623
643
|
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
624
644
|
const NO_GUEST_SCOPE = new Set();
|
|
645
|
+
/** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
|
|
646
|
+
export class RestartSupersededError extends Error {
|
|
647
|
+
/** `shown`: the instance as the chat list names it (shownInstanceName). */
|
|
648
|
+
constructor(shown) {
|
|
649
|
+
super(t("restart.superseded", shown));
|
|
650
|
+
this.name = "RestartSupersededError";
|
|
651
|
+
}
|
|
652
|
+
}
|
|
625
653
|
export class FleetManager {
|
|
626
654
|
dataDir;
|
|
627
655
|
static signalTarget = null;
|
|
@@ -873,6 +901,7 @@ export class FleetManager {
|
|
|
873
901
|
startupRetryNotices = new Map();
|
|
874
902
|
/** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
|
|
875
903
|
unsupportedCliNotices = new Map();
|
|
904
|
+
kiroCompatibilityHolds = new Map();
|
|
876
905
|
kiroIncompatNotices = new Map();
|
|
877
906
|
/** Backoff between automatic startup retries; the last step repeats while the backend is down. */
|
|
878
907
|
static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
|
|
@@ -901,6 +930,7 @@ export class FleetManager {
|
|
|
901
930
|
kiroMigrationRuns = new Map();
|
|
902
931
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
903
932
|
needsYou = null;
|
|
933
|
+
missedOfflineStore = null;
|
|
904
934
|
configHistory = null;
|
|
905
935
|
configHistoryTimer = null;
|
|
906
936
|
loadedFleetSource = null;
|
|
@@ -1016,11 +1046,15 @@ export class FleetManager {
|
|
|
1016
1046
|
discordPresenceEagerPending = false;
|
|
1017
1047
|
discordPresenceInFlight = null;
|
|
1018
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;
|
|
1019
1052
|
/** Days of event/activity history to keep. */
|
|
1020
1053
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
1021
1054
|
watchdogTimer = null;
|
|
1022
1055
|
startupMemoryDeadline = null;
|
|
1023
1056
|
eventLoopWatch = null;
|
|
1057
|
+
stallReporter = null;
|
|
1024
1058
|
startedAt = 0;
|
|
1025
1059
|
stormOpenNotifyTimer = null;
|
|
1026
1060
|
memoryLogAt = null;
|
|
@@ -1064,6 +1098,37 @@ export class FleetManager {
|
|
|
1064
1098
|
webChatRemovals = new Map();
|
|
1065
1099
|
/** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
|
|
1066
1100
|
webChatRemovalsPending = new Map();
|
|
1101
|
+
/**
|
|
1102
|
+
* #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
|
|
1103
|
+
* restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
|
|
1104
|
+
* or menu is claimed or offered, asked after every await before acting by name.
|
|
1105
|
+
*/
|
|
1106
|
+
/** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
|
|
1107
|
+
shownInstanceName(name) {
|
|
1108
|
+
try {
|
|
1109
|
+
const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
|
|
1110
|
+
return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
return name;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
lifecyclePin(name) {
|
|
1117
|
+
// Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
|
|
1118
|
+
let epoch;
|
|
1119
|
+
try {
|
|
1120
|
+
epoch = this.lifecycle?.epochOf(name);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return () => false;
|
|
1124
|
+
}
|
|
1125
|
+
return () => { try {
|
|
1126
|
+
return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return false;
|
|
1130
|
+
} };
|
|
1131
|
+
}
|
|
1067
1132
|
webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
|
|
1068
1133
|
/** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
|
|
1069
1134
|
webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
|
|
@@ -2107,6 +2172,22 @@ export class FleetManager {
|
|
|
2107
2172
|
* startup-only key at startup. See checkStartupSignatureConsistency(). */
|
|
2108
2173
|
fleetSignatureMismatch = null;
|
|
2109
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
|
+
}
|
|
2110
2191
|
constructor(dataDir) {
|
|
2111
2192
|
this.dataDir = dataDir;
|
|
2112
2193
|
FleetManager.signalTarget = this;
|
|
@@ -2129,6 +2210,7 @@ export class FleetManager {
|
|
|
2129
2210
|
this.topicCommands = new TopicCommands(this);
|
|
2130
2211
|
this.topicArchiver = new TopicArchiver(this);
|
|
2131
2212
|
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
2213
|
+
this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
|
|
2132
2214
|
}
|
|
2133
2215
|
/** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
|
|
2134
2216
|
replyButtonsCtl = null;
|
|
@@ -2487,6 +2569,10 @@ export class FleetManager {
|
|
|
2487
2569
|
const daemon = this.daemons.get(name);
|
|
2488
2570
|
if (daemon?.bootId === daemonBootId)
|
|
2489
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
|
+
}
|
|
2490
2576
|
// #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
|
|
2491
2577
|
// request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
|
|
2492
2578
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -3324,6 +3410,7 @@ export class FleetManager {
|
|
|
3324
3410
|
* guild channel.
|
|
3325
3411
|
*/
|
|
3326
3412
|
reregisterClassicChannels() {
|
|
3413
|
+
this.agentCredentialsCache?.observePolicies();
|
|
3327
3414
|
if (!this.classicChannels)
|
|
3328
3415
|
return;
|
|
3329
3416
|
const channels = this.classicChannels.getAll();
|
|
@@ -4014,6 +4101,13 @@ export class FleetManager {
|
|
|
4014
4101
|
changed: () => this.emitSseEvent("status", this.getUiStatus()),
|
|
4015
4102
|
});
|
|
4016
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
|
+
}
|
|
4017
4111
|
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
4018
4112
|
logSteps(name, q) { return this.log.steps(name, q); }
|
|
4019
4113
|
/**
|
|
@@ -5636,6 +5730,23 @@ export class FleetManager {
|
|
|
5636
5730
|
if (resumePaused)
|
|
5637
5731
|
this.lifecycle.markActivitySeedNow(name);
|
|
5638
5732
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
5733
|
+
// #1490 row 67: off-loop resolve before skill/instruction publication
|
|
5734
|
+
// constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
|
|
5735
|
+
// ownership first: a Stop during this wait finishes and bumps the epoch,
|
|
5736
|
+
// and the inner lifecycle check would otherwise capture the new epoch and
|
|
5737
|
+
// accept the stale start. Lifecycle start looks up again before its own
|
|
5738
|
+
// createBackend; the probe cache makes that cheap.
|
|
5739
|
+
const epoch = this.lifecycle.epochOf(name);
|
|
5740
|
+
const stopGen = this.explicitStopGeneration.get(name) ?? 0;
|
|
5741
|
+
const resolved = await resolveBackendBinary(backend);
|
|
5742
|
+
execution?.assert();
|
|
5743
|
+
if (this.lifecycle.epochOf(name) !== epoch
|
|
5744
|
+
|| (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
|
|
5745
|
+
throw new SupersededStartError(name);
|
|
5746
|
+
}
|
|
5747
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
5748
|
+
if (binary)
|
|
5749
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
5639
5750
|
if (config.general_topic) {
|
|
5640
5751
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
5641
5752
|
}
|
|
@@ -6092,6 +6203,17 @@ export class FleetManager {
|
|
|
6092
6203
|
this.queueKiroIncompatNotice(name, err.message);
|
|
6093
6204
|
return true;
|
|
6094
6205
|
}
|
|
6206
|
+
/** No pane is needed for a compatibility refusal to remain in Needs you. */
|
|
6207
|
+
setKiroCompatibilityHold(name, message) {
|
|
6208
|
+
const holds = this.kiroCompatibilityHolds ??= new Map();
|
|
6209
|
+
if ((message === null && !holds.has(name)) || (message !== null && holds.get(name)?.message === message))
|
|
6210
|
+
return;
|
|
6211
|
+
if (message === null)
|
|
6212
|
+
holds.delete(name);
|
|
6213
|
+
else
|
|
6214
|
+
holds.set(name, { message, since: Date.now() });
|
|
6215
|
+
this.needsYou?.poke();
|
|
6216
|
+
}
|
|
6095
6217
|
/**
|
|
6096
6218
|
* LifecycleContext: a kiro instance the installed kiro-cli cannot run as
|
|
6097
6219
|
* configured (#1109) — at start, or at a respawn after kiro-cli replaced
|
|
@@ -6286,6 +6408,8 @@ export class FleetManager {
|
|
|
6286
6408
|
}
|
|
6287
6409
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
6288
6410
|
async restartSingleInstance(name, opts) {
|
|
6411
|
+
if (opts?.ownerCurrent && !opts.ownerCurrent())
|
|
6412
|
+
throw new RestartSupersededError(this.shownInstanceName(name));
|
|
6289
6413
|
const migration = this.kiroMigrationRuns?.get(name);
|
|
6290
6414
|
if (migration && migration.token !== opts?.kiroMigration)
|
|
6291
6415
|
throw new Error(t("kiro_migrate.in_progress"));
|
|
@@ -7586,7 +7710,7 @@ export class FleetManager {
|
|
|
7586
7710
|
return { kind: "refused" };
|
|
7587
7711
|
if (device.tier === "guest")
|
|
7588
7712
|
return this.requestGuestDeviceCode(device, input, token);
|
|
7589
|
-
const paused = this.webLoginCodes.pausedForMs();
|
|
7713
|
+
const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
|
|
7590
7714
|
if (paused > 0)
|
|
7591
7715
|
return { kind: "limited", retryAfterMs: paused };
|
|
7592
7716
|
const admitted = this.codeRequestLimiter.admit(device.idHash);
|
|
@@ -8071,6 +8195,9 @@ export class FleetManager {
|
|
|
8071
8195
|
this.watchdogTimer = startSdWatchdog();
|
|
8072
8196
|
this.eventLoopWatch?.stop();
|
|
8073
8197
|
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
8198
|
+
// #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
|
|
8199
|
+
this.stallReporter?.stop();
|
|
8200
|
+
this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
|
|
8074
8201
|
try {
|
|
8075
8202
|
await this.startAllWithinDeadline(configPath, deadline);
|
|
8076
8203
|
}
|
|
@@ -8084,6 +8211,8 @@ export class FleetManager {
|
|
|
8084
8211
|
this.watchdogTimer = null;
|
|
8085
8212
|
this.eventLoopWatch?.stop();
|
|
8086
8213
|
this.eventLoopWatch = null;
|
|
8214
|
+
this.stallReporter?.stop();
|
|
8215
|
+
this.stallReporter = null;
|
|
8087
8216
|
}
|
|
8088
8217
|
}
|
|
8089
8218
|
}
|
|
@@ -8351,6 +8480,10 @@ export class FleetManager {
|
|
|
8351
8480
|
const generalDir = join(getAgendHome(), name);
|
|
8352
8481
|
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
8353
8482
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
8483
|
+
const resolved = await resolveBackendBinary(backendName);
|
|
8484
|
+
const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
|
|
8485
|
+
if (binary)
|
|
8486
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
8354
8487
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
8355
8488
|
fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
|
|
8356
8489
|
generalsCreated = true;
|
|
@@ -8505,6 +8638,9 @@ export class FleetManager {
|
|
|
8505
8638
|
if (others.length > 0 || classicAtBoot.length > 0) {
|
|
8506
8639
|
await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
|
|
8507
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();
|
|
8508
8644
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
8509
8645
|
await adapterStartup;
|
|
8510
8646
|
this.startDiscordUsagePresence();
|
|
@@ -8676,6 +8812,178 @@ export class FleetManager {
|
|
|
8676
8812
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
8677
8813
|
}
|
|
8678
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
|
+
}
|
|
8679
8987
|
/** Keep Discord profile activity aligned with the same cached usage source as /usage. */
|
|
8680
8988
|
startDiscordUsagePresence() {
|
|
8681
8989
|
if (this.discordPresenceTimer)
|
|
@@ -9140,6 +9448,9 @@ export class FleetManager {
|
|
|
9140
9448
|
return;
|
|
9141
9449
|
await this.handleInboundMessage(msg);
|
|
9142
9450
|
}, this.logger, "adapter.message"));
|
|
9451
|
+
// #2940: a deleted message's chat-log line goes (and it is never logged after this).
|
|
9452
|
+
adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
|
|
9453
|
+
this.onPlatformMessageDeleted(e); });
|
|
9143
9454
|
adapter.on("reaction", safeHandler(async (r) => {
|
|
9144
9455
|
if (!isCurrentAdapter())
|
|
9145
9456
|
return;
|
|
@@ -9148,6 +9459,11 @@ export class FleetManager {
|
|
|
9148
9459
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
9149
9460
|
await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
|
|
9150
9461
|
}, this.logger, "adapter.callback_query"));
|
|
9462
|
+
adapter.on("missed_offline", (ev) => {
|
|
9463
|
+
if (!isCurrentAdapter())
|
|
9464
|
+
return;
|
|
9465
|
+
this.recordMissedOffline(ev);
|
|
9466
|
+
});
|
|
9151
9467
|
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
9152
9468
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
9153
9469
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
@@ -9290,6 +9606,9 @@ export class FleetManager {
|
|
|
9290
9606
|
return;
|
|
9291
9607
|
await this.handleInboundMessage(msg);
|
|
9292
9608
|
}, this.logger, `adapter[${adapterId}].message`));
|
|
9609
|
+
// #2940: a deleted message's chat-log line goes (and it is never logged after this).
|
|
9610
|
+
adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
|
|
9611
|
+
this.onPlatformMessageDeleted(e); });
|
|
9293
9612
|
adapter.on("reaction", safeHandler(async (r) => {
|
|
9294
9613
|
if (!isCurrentAdapter())
|
|
9295
9614
|
return;
|
|
@@ -9298,6 +9617,11 @@ export class FleetManager {
|
|
|
9298
9617
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
9299
9618
|
await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
|
|
9300
9619
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
9620
|
+
adapter.on("missed_offline", (ev) => {
|
|
9621
|
+
if (!isCurrentAdapter())
|
|
9622
|
+
return;
|
|
9623
|
+
this.recordMissedOffline(ev);
|
|
9624
|
+
});
|
|
9301
9625
|
this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
|
|
9302
9626
|
// Slash commands: classic bot + admin commands
|
|
9303
9627
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
@@ -10034,6 +10358,34 @@ export class FleetManager {
|
|
|
10034
10358
|
}
|
|
10035
10359
|
return true;
|
|
10036
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
|
+
}
|
|
10037
10389
|
/**
|
|
10038
10390
|
* #1085: coincident topic numbers must not route across Telegram groups.
|
|
10039
10391
|
*/
|
|
@@ -11657,6 +12009,11 @@ export class FleetManager {
|
|
|
11657
12009
|
let result;
|
|
11658
12010
|
switch (msg.type) {
|
|
11659
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
|
+
}
|
|
11660
12017
|
// Prune expired decisions on create
|
|
11661
12018
|
db.pruneExpiredDecisions();
|
|
11662
12019
|
result = db.createDecision({
|
|
@@ -11667,7 +12024,7 @@ export class FleetManager {
|
|
|
11667
12024
|
tags: payload.tags,
|
|
11668
12025
|
ttl_days: payload.ttl_days,
|
|
11669
12026
|
created_by: instanceName,
|
|
11670
|
-
supersedes:
|
|
12027
|
+
supersedes: supersedes.id,
|
|
11671
12028
|
});
|
|
11672
12029
|
break;
|
|
11673
12030
|
}
|
|
@@ -11844,6 +12201,21 @@ export class FleetManager {
|
|
|
11844
12201
|
return "ok";
|
|
11845
12202
|
}
|
|
11846
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
|
+
}
|
|
11847
12219
|
async handleDecisionCrudHttp(instance, op, args) {
|
|
11848
12220
|
if (!this.scheduler)
|
|
11849
12221
|
return { error: "Scheduler not available" };
|
|
@@ -11859,16 +12231,24 @@ export class FleetManager {
|
|
|
11859
12231
|
if (!title || !content)
|
|
11860
12232
|
return { error: "title and content are required" };
|
|
11861
12233
|
const scope = args.scope === "fleet" ? "fleet" : "project";
|
|
11862
|
-
|
|
11863
|
-
|
|
11864
|
-
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
|
|
11868
|
-
|
|
11869
|
-
|
|
11870
|
-
|
|
11871
|
-
|
|
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
|
+
}
|
|
11872
12252
|
}
|
|
11873
12253
|
// #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
|
|
11874
12254
|
// The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
|
|
@@ -12157,16 +12537,89 @@ export class FleetManager {
|
|
|
12157
12537
|
}
|
|
12158
12538
|
return adapter.createTopic(topicName);
|
|
12159
12539
|
}
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
this.
|
|
12169
|
-
|
|
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() });
|
|
12170
12623
|
}
|
|
12171
12624
|
getForumTopicDeleter(adapterId) {
|
|
12172
12625
|
const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
|
|
@@ -12567,6 +13020,7 @@ export class FleetManager {
|
|
|
12567
13020
|
Object.assign(current, resolved);
|
|
12568
13021
|
}
|
|
12569
13022
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
13023
|
+
this.agentCredentialsCache?.observePolicies();
|
|
12570
13024
|
this.refreshSavedDashboardPermissions();
|
|
12571
13025
|
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
12572
13026
|
}
|
|
@@ -12855,6 +13309,9 @@ export class FleetManager {
|
|
|
12855
13309
|
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
12856
13310
|
else
|
|
12857
13311
|
await this.lifecycle.remove(name, authorization);
|
|
13312
|
+
// #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
|
|
13313
|
+
// through the lifecycle directly). The offers' own lifecycle pins refuse them too.
|
|
13314
|
+
this.clearNoncePromptsForInstance(name);
|
|
12858
13315
|
// #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
|
|
12859
13316
|
// throws above: its replies stay its own).
|
|
12860
13317
|
this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
|
|
@@ -14529,6 +14986,7 @@ export class FleetManager {
|
|
|
14529
14986
|
threadId: opts.threadId,
|
|
14530
14987
|
expiredText: opts.expiredText,
|
|
14531
14988
|
createdAt: Date.now(),
|
|
14989
|
+
lifecycleCurrent: this.lifecyclePin(opts.instanceName),
|
|
14532
14990
|
...opts.extra,
|
|
14533
14991
|
};
|
|
14534
14992
|
entry.timer = setTimeout(() => {
|
|
@@ -14880,6 +15338,7 @@ export class FleetManager {
|
|
|
14880
15338
|
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
14881
15339
|
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
14882
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) } : {}),
|
|
14883
15342
|
kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
|
|
14884
15343
|
}));
|
|
14885
15344
|
const taken = new Set(fleet.map(i => i.name));
|
|
@@ -14891,6 +15350,9 @@ export class FleetManager {
|
|
|
14891
15350
|
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
14892
15351
|
profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
14893
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) } : {}),
|
|
14894
15356
|
}));
|
|
14895
15357
|
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
14896
15358
|
}
|
|
@@ -15051,7 +15513,8 @@ export class FleetManager {
|
|
|
15051
15513
|
out.push({
|
|
15052
15514
|
name, state: p.state ?? undefined,
|
|
15053
15515
|
interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
|
|
15054
|
-
interactionSummary: p.interaction_summary ?? null,
|
|
15516
|
+
interactionSummary: p.interaction?.kind === "kiro_choice" ? t("kiro3.running") : p.interaction_summary ?? null,
|
|
15517
|
+
kiroCompatibility: this.kiroCompatibilityHolds?.get(name),
|
|
15055
15518
|
pauseReason, pausedAt,
|
|
15056
15519
|
crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
|
|
15057
15520
|
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
@@ -15127,6 +15590,10 @@ export class FleetManager {
|
|
|
15127
15590
|
throw new Error("the delivery outbox is not open");
|
|
15128
15591
|
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
15129
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,
|
|
15130
15597
|
tasks: () => this.needsYouTaskItems(),
|
|
15131
15598
|
backendSignIns: () => this.needsYouBackendSignIns(),
|
|
15132
15599
|
configIssues: () => (this.configHistory?.currentIssues() ?? []).map(issue => ({
|
|
@@ -15841,8 +16308,9 @@ export class FleetManager {
|
|
|
15841
16308
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
15842
16309
|
},
|
|
15843
16310
|
signOut: (handle) => {
|
|
15844
|
-
this.webSessions?.revokeByHandle(handle);
|
|
15845
|
-
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 };
|
|
15846
16314
|
},
|
|
15847
16315
|
event: (type, payload) => { try {
|
|
15848
16316
|
this.eventLog?.insert("web", type, payload);
|
|
@@ -15855,6 +16323,7 @@ export class FleetManager {
|
|
|
15855
16323
|
expired: (c) => t("web_auth.card_expired", c.label),
|
|
15856
16324
|
refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
|
|
15857
16325
|
revoked: (c) => t("web_auth.card_revoked", c.label),
|
|
16326
|
+
notSaved: (c) => t("web_auth.card_not_saved", c.label),
|
|
15858
16327
|
ended: (c) => t("web_auth.card_ended", c.label),
|
|
15859
16328
|
generalHint: () => t("web_auth.general_hint"),
|
|
15860
16329
|
confirmLabel: () => t("web_auth.confirm"),
|
|
@@ -16242,21 +16711,27 @@ export class FleetManager {
|
|
|
16242
16711
|
if (claimed === "consumed")
|
|
16243
16712
|
return true;
|
|
16244
16713
|
const { entry: pending, action } = claimed;
|
|
16714
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
16715
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
16245
16716
|
this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
|
|
16246
16717
|
if (action === "ignore") {
|
|
16247
16718
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
16248
16719
|
return true;
|
|
16249
16720
|
}
|
|
16721
|
+
if (!current()) {
|
|
16722
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
16723
|
+
return true;
|
|
16724
|
+
}
|
|
16250
16725
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
16251
16726
|
try {
|
|
16252
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
16727
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
16253
16728
|
...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
|
|
16254
16729
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
16255
16730
|
}
|
|
16256
16731
|
catch (err) {
|
|
16257
16732
|
const message = err instanceof Error ? err.message : String(err);
|
|
16258
16733
|
this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
|
|
16259
|
-
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
16734
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
16260
16735
|
}
|
|
16261
16736
|
return true;
|
|
16262
16737
|
}
|
|
@@ -16769,7 +17244,14 @@ export class FleetManager {
|
|
|
16769
17244
|
return;
|
|
16770
17245
|
}
|
|
16771
17246
|
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
16772
|
-
|
|
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));
|
|
16773
17255
|
try {
|
|
16774
17256
|
const sent = await adapter.notifyAlert(chatId, {
|
|
16775
17257
|
type: "cancel",
|
|
@@ -16778,6 +17260,9 @@ export class FleetManager {
|
|
|
16778
17260
|
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
16779
17261
|
}, threadId ? { threadId } : undefined);
|
|
16780
17262
|
publication.inFlight = false;
|
|
17263
|
+
// #2940: a status bubble of ours — never a chat-log line, for any bot of this fleet in the room; one a sibling
|
|
17264
|
+
// already logged (its gateway event can come before this answer) is taken out again.
|
|
17265
|
+
this.noteOwnStatusMessage(adapter, sent);
|
|
16781
17266
|
const entry = {
|
|
16782
17267
|
instanceName,
|
|
16783
17268
|
adapterId,
|
|
@@ -16789,7 +17274,7 @@ export class FleetManager {
|
|
|
16789
17274
|
// Elapsed time is measured from when this button was posted — i.e. from
|
|
16790
17275
|
// when the work was handed over — not from the pane's working transition,
|
|
16791
17276
|
// which resets if the CLI blips idle mid-turn.
|
|
16792
|
-
startedAt: Date.now(),
|
|
17277
|
+
startedAt: carriedStart ?? Date.now(),
|
|
16793
17278
|
// Matches the text notifyAlert just posted, so the first 60s tick does
|
|
16794
17279
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
16795
17280
|
// with nothing visibly changed.
|
|
@@ -16912,14 +17397,16 @@ export class FleetManager {
|
|
|
16912
17397
|
* Make a tool summary safe to paste into a channel message.
|
|
16913
17398
|
*
|
|
16914
17399
|
* The text is agent-controlled (it is built from tool inputs — file paths,
|
|
16915
|
-
* shell commands), so it gets flattened to one line, capped,
|
|
16916
|
-
* the two Discord mass-mention triggers.
|
|
16917
|
-
*
|
|
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.
|
|
16918
17405
|
*/
|
|
16919
17406
|
static sanitizeActivity(activity) {
|
|
16920
17407
|
if (!activity)
|
|
16921
17408
|
return null;
|
|
16922
|
-
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();
|
|
16923
17410
|
if (!flat)
|
|
16924
17411
|
return null;
|
|
16925
17412
|
return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
|
|
@@ -17038,6 +17525,12 @@ export class FleetManager {
|
|
|
17038
17525
|
}
|
|
17039
17526
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
17040
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
|
+
}
|
|
17041
17534
|
startProgressTicker(entry) {
|
|
17042
17535
|
const tick = () => this.refreshBubble(entry);
|
|
17043
17536
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
@@ -17047,7 +17540,9 @@ export class FleetManager {
|
|
|
17047
17540
|
// one additional edit per turn that lives past the threshold.
|
|
17048
17541
|
const firstAt = this.progressMinElapsedMs() - (Date.now() - (entry.startedAt ?? Date.now()));
|
|
17049
17542
|
if (firstAt > 0 && firstAt < PROGRESS_UPDATE_INTERVAL_MS) {
|
|
17050
|
-
|
|
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);
|
|
17051
17546
|
firstTick.unref?.();
|
|
17052
17547
|
}
|
|
17053
17548
|
}
|
|
@@ -17365,7 +17860,10 @@ export class FleetManager {
|
|
|
17365
17860
|
return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
|
|
17366
17861
|
dataDir: this.dataDir, lock: this.loginWindow,
|
|
17367
17862
|
current: owner => this.browserSignInOwnerCurrent(owner),
|
|
17368
|
-
installed: async () =>
|
|
17863
|
+
installed: async () => {
|
|
17864
|
+
const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
|
|
17865
|
+
return found.filter((backend) => backend !== null);
|
|
17866
|
+
},
|
|
17369
17867
|
checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
|
|
17370
17868
|
checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
|
|
17371
17869
|
transport: () => createStructuredSignInTransport(),
|
|
@@ -17469,13 +17967,13 @@ export class FleetManager {
|
|
|
17469
17967
|
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
17470
17968
|
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
17471
17969
|
*/
|
|
17472
|
-
startWebDashboardLogin(backendArg, chat, opts) {
|
|
17970
|
+
async startWebDashboardLogin(backendArg, chat, opts) {
|
|
17473
17971
|
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
17474
17972
|
if (isRemovedBackend(wanted))
|
|
17475
|
-
return
|
|
17973
|
+
return removedBackendMessage(wanted);
|
|
17476
17974
|
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
17477
|
-
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.
|
|
17478
|
-
return
|
|
17975
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
|
|
17976
|
+
return t("login.web_dashboard_not_installed", wanted, info.install);
|
|
17479
17977
|
}
|
|
17480
17978
|
if (!opts.skipAuthCheck)
|
|
17481
17979
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
@@ -17523,8 +18021,10 @@ export class FleetManager {
|
|
|
17523
18021
|
? removedBackendMessage(backend)
|
|
17524
18022
|
: !installed.has(backend)
|
|
17525
18023
|
? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
|
|
18024
|
+
// #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
|
|
18025
|
+
// a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
|
|
17526
18026
|
: flow?.remoteLogin === "unsupported"
|
|
17527
|
-
? t("login.
|
|
18027
|
+
? t("login.host_only", flow.command)
|
|
17528
18028
|
: backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
|
|
17529
18029
|
await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
|
|
17530
18030
|
.catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
|
|
@@ -17703,7 +18203,8 @@ export class FleetManager {
|
|
|
17703
18203
|
return admitted.refusal;
|
|
17704
18204
|
opts = { ...opts, profile: admitted.profile };
|
|
17705
18205
|
}
|
|
17706
|
-
|
|
18206
|
+
// M-2281: asked off the fleet loop — this runs on a chat click.
|
|
18207
|
+
if (BACKEND_INSTALLATION_INFO[wanted] && !(await this.isCliInstalledOffLoop(wanted))) {
|
|
17707
18208
|
const flow = LOGIN_FLOWS[wanted];
|
|
17708
18209
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
17709
18210
|
return opts.profile !== undefined
|
|
@@ -17928,6 +18429,8 @@ export class FleetManager {
|
|
|
17928
18429
|
// installer, judge by exit code, verify the binary on a fresh login shell,
|
|
17929
18430
|
// then sign in (the hand-off below).
|
|
17930
18431
|
activeInstall = null;
|
|
18432
|
+
/** M-2281: how long `/login`'s install window may run (a field so a smoke can shorten it). */
|
|
18433
|
+
installTimeoutMs = INSTALL_TIMEOUT_MS;
|
|
17931
18434
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
17932
18435
|
installHandoff = null;
|
|
17933
18436
|
/** Start a CLI install session. Caller enforces admin. */
|
|
@@ -17936,11 +18439,13 @@ export class FleetManager {
|
|
|
17936
18439
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
17937
18440
|
if (!info)
|
|
17938
18441
|
return t("install.unsupported", backendArg);
|
|
17939
|
-
if (checkBinaryInstalled(info.binary))
|
|
17940
|
-
return t("install.already", backend, info.binary);
|
|
17941
18442
|
// Reserve the fleet-wide window before the first await (shared with web
|
|
17942
18443
|
// login). Owned by this method until the session is published.
|
|
17943
18444
|
const claim = this.loginWindow.tryClaim("install", backend);
|
|
18445
|
+
if (claim && await checkBinaryInstalledAsync(info.binary)) {
|
|
18446
|
+
this.loginWindow.release(claim);
|
|
18447
|
+
return t("install.already", backend, info.binary);
|
|
18448
|
+
}
|
|
17944
18449
|
if (!claim)
|
|
17945
18450
|
return this.loginWindow.busyMessage();
|
|
17946
18451
|
let tmux;
|
|
@@ -17960,16 +18465,44 @@ export class FleetManager {
|
|
|
17960
18465
|
// A synthetic flow: success is decided by the installer's exit code
|
|
17961
18466
|
// (LoginSession treats a clean exit as success), never by pane text. The
|
|
17962
18467
|
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
18468
|
+
// M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
|
|
18469
|
+
// unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
|
|
18470
|
+
const shell = (await loginShell()) ?? null;
|
|
18471
|
+
if (!this.loginWindow.isCurrent(claim)) {
|
|
18472
|
+
this.loginWindow.release(claim);
|
|
18473
|
+
return t("login.web_shutting_down");
|
|
18474
|
+
}
|
|
17963
18475
|
const flow = {
|
|
17964
18476
|
backend,
|
|
17965
|
-
command: info.unattended ?? info.install,
|
|
18477
|
+
command: unattendedInstallCommand(info.unattended ?? info.install, shell),
|
|
17966
18478
|
successPattern: /$^/,
|
|
17967
|
-
timeoutMs:
|
|
18479
|
+
timeoutMs: this.installTimeoutMs,
|
|
17968
18480
|
};
|
|
18481
|
+
// M-2281: the installer's output, live, in the chat that asked — no page shows this window.
|
|
18482
|
+
// Telegram gets its own <pre> (its markdown conversion keeps a fence as literal backticks); others a fenced block.
|
|
18483
|
+
const frameStyle = chat.adapter.type === "telegram" ? "html" : "markdown";
|
|
18484
|
+
const stream = new InstallStream({
|
|
18485
|
+
send: text => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId, allowedMentions: { parse: [] } }),
|
|
18486
|
+
edit: (messageId, text) => chat.adapter.editMessage(chat.chatId, messageId, text, chat.threadId, { format: frameStyle }),
|
|
18487
|
+
warn: (message, err) => this.logger.debug({ backend, err: err?.message ?? String(err) }, message),
|
|
18488
|
+
}, t("install.stream_running", backend), undefined, undefined, frameStyle);
|
|
18489
|
+
const minutes = String(Math.max(1, Math.round(this.installTimeoutMs / 60_000)));
|
|
17969
18490
|
const session = new LoginSession(flow, tmux, {
|
|
18491
|
+
onProgress: pane => stream.update(pane),
|
|
17970
18492
|
onDone: async ({ ok, detail, cleanupFailed }) => {
|
|
17971
18493
|
this.activeInstall = null;
|
|
17972
18494
|
this.loginWindow.release(claim);
|
|
18495
|
+
// Between the install ending and the sign-in starting there is no
|
|
18496
|
+
// session to cancel; this hand-off is what `/login cancel` stops — armed
|
|
18497
|
+
// before the first await here, so a cancel during any of them counts.
|
|
18498
|
+
const handoff = ok ? { backend, cancelled: false } : null;
|
|
18499
|
+
if (handoff)
|
|
18500
|
+
this.installHandoff = handoff;
|
|
18501
|
+
// The live view's last frame first, so the result line below follows the output it is about.
|
|
18502
|
+
await stream.finish(ok ? t("install.stream_ok", backend)
|
|
18503
|
+
: detail === "timeout" ? t("install.stream_timeout", backend, minutes)
|
|
18504
|
+
: detail === "cancelled" ? t("install.stream_cancelled", backend)
|
|
18505
|
+
: t("install.stream_failed", backend)).catch(() => { });
|
|
17973
18506
|
if (cleanupFailed) {
|
|
17974
18507
|
await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
|
|
17975
18508
|
}
|
|
@@ -17977,18 +18510,17 @@ export class FleetManager {
|
|
|
17977
18510
|
// A cancel is user-initiated — the cancel command's own reply already
|
|
17978
18511
|
// said so; a second message here would be a duplicate.
|
|
17979
18512
|
if (detail !== "cancelled") {
|
|
17980
|
-
await chat.adapter.sendText(chat.chatId, t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
|
|
18513
|
+
await chat.adapter.sendText(chat.chatId, detail === "timeout" ? t("install.timeout", backend, minutes) : t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
|
|
17981
18514
|
}
|
|
17982
18515
|
return;
|
|
17983
18516
|
}
|
|
17984
|
-
|
|
17985
|
-
|
|
17986
|
-
// here on, as the login-shell lookup below is an await.
|
|
17987
|
-
const handoff = { backend, cancelled: false };
|
|
17988
|
-
this.installHandoff = handoff;
|
|
18517
|
+
if (!handoff || handoff.cancelled || this.installHandoff !== handoff)
|
|
18518
|
+
return;
|
|
17989
18519
|
// The installer may only have added the binary to a profile PATH; a
|
|
17990
18520
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
17991
|
-
|
|
18521
|
+
// #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
|
|
18522
|
+
// then the installer's own directories through backend-availability's check (a killable child tests each one).
|
|
18523
|
+
const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
|
|
17992
18524
|
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
17993
18525
|
return;
|
|
17994
18526
|
if (!installedAt) {
|
|
@@ -18058,6 +18590,11 @@ export class FleetManager {
|
|
|
18058
18590
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
18059
18591
|
return !!info && checkBinaryInstalled(info.binary);
|
|
18060
18592
|
}
|
|
18593
|
+
/** M-2281: isCliInstalled without holding the fleet loop (a chat click's path: `which` on a slow PATH can take seconds). */
|
|
18594
|
+
async isCliInstalledOffLoop(backend) {
|
|
18595
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
18596
|
+
return !!info && await checkBinaryInstalledAsync(info.binary);
|
|
18597
|
+
}
|
|
18061
18598
|
/** Abort the active install (`/login cancel`) and remove its window. */
|
|
18062
18599
|
async cancelInstallSession() {
|
|
18063
18600
|
if (!this.activeInstall)
|
|
@@ -18075,38 +18612,27 @@ export class FleetManager {
|
|
|
18075
18612
|
*/
|
|
18076
18613
|
async locateBinaryOnLoginShell(binary) {
|
|
18077
18614
|
try {
|
|
18078
|
-
|
|
18615
|
+
// #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
|
|
18616
|
+
// on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
|
|
18617
|
+
const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
|
|
18079
18618
|
if (result.status !== 0)
|
|
18080
18619
|
return null;
|
|
18081
18620
|
const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
18082
|
-
|
|
18083
|
-
return null;
|
|
18084
|
-
accessSync(path, fsConstants.X_OK);
|
|
18085
|
-
return statSync(path).isFile() ? path : null;
|
|
18621
|
+
return isAbsolute(path) ? path : null;
|
|
18086
18622
|
}
|
|
18087
18623
|
catch {
|
|
18088
18624
|
return null;
|
|
18089
18625
|
}
|
|
18090
18626
|
}
|
|
18091
18627
|
/**
|
|
18092
|
-
* #1092: the binary in one of the directories its own installer installs to,
|
|
18093
|
-
*
|
|
18094
|
-
*
|
|
18095
|
-
*
|
|
18628
|
+
* #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
|
|
18629
|
+
* installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
|
|
18630
|
+
* backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
|
|
18631
|
+
* accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
|
|
18096
18632
|
*/
|
|
18097
|
-
|
|
18098
|
-
|
|
18099
|
-
|
|
18100
|
-
continue;
|
|
18101
|
-
const path = join(dir, info.binary);
|
|
18102
|
-
try {
|
|
18103
|
-
accessSync(path, fsConstants.X_OK);
|
|
18104
|
-
if (statSync(path).isFile())
|
|
18105
|
-
return path;
|
|
18106
|
-
}
|
|
18107
|
-
catch { /* not there */ }
|
|
18108
|
-
}
|
|
18109
|
-
return null;
|
|
18633
|
+
async locateInstalledBackend(backend) {
|
|
18634
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
18635
|
+
return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
|
|
18110
18636
|
}
|
|
18111
18637
|
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
18112
18638
|
adoptBinaryDirectory(binaryPath, backend) {
|
|
@@ -18422,20 +18948,26 @@ export class FleetManager {
|
|
|
18422
18948
|
if (claimed === "consumed")
|
|
18423
18949
|
return true;
|
|
18424
18950
|
const { entry: pending, action } = claimed;
|
|
18951
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
18952
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
18425
18953
|
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
18426
18954
|
if (action === "wait") {
|
|
18427
18955
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
18428
18956
|
return true;
|
|
18429
18957
|
}
|
|
18958
|
+
if (!current()) {
|
|
18959
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
18960
|
+
return true;
|
|
18961
|
+
}
|
|
18430
18962
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
18431
18963
|
try {
|
|
18432
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true });
|
|
18964
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
|
|
18433
18965
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
18434
18966
|
}
|
|
18435
18967
|
catch (err) {
|
|
18436
18968
|
const message = err instanceof Error ? err.message : String(err);
|
|
18437
18969
|
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
18438
|
-
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
18970
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
18439
18971
|
}
|
|
18440
18972
|
return true;
|
|
18441
18973
|
}
|
|
@@ -18511,6 +19043,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18511
19043
|
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
18512
19044
|
// Backend resolution may create/normalize the real cwd. Instructions and
|
|
18513
19045
|
// skills must land where the CLI actually runs, not an assumed path.
|
|
19046
|
+
// Caller pins resolveBackendBinary first so createBackend does not `which`.
|
|
18514
19047
|
try {
|
|
18515
19048
|
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
18516
19049
|
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
@@ -18889,6 +19422,45 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18889
19422
|
await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
|
|
18890
19423
|
return true;
|
|
18891
19424
|
}
|
|
19425
|
+
/** #2940: this fleet's own status bubbles and the messages the platform said were deleted (ids, never text). */
|
|
19426
|
+
ownStatusMessages = new OwnStatusMessages();
|
|
19427
|
+
/**
|
|
19428
|
+
* #2940: a status bubble of ours was posted: never logged, and a line already logged for it (by a sibling bot) goes.
|
|
19429
|
+
* Discord only (#2943 review: the only platform where our bots see each other's posts), keyed by channel and id.
|
|
19430
|
+
*/
|
|
19431
|
+
noteOwnStatusMessage(adapter, sent) {
|
|
19432
|
+
if (adapter.type !== "discord")
|
|
19433
|
+
return;
|
|
19434
|
+
const key = discordMessageKey(sent.threadId ?? sent.chatId, sent.messageId);
|
|
19435
|
+
if (!key)
|
|
19436
|
+
return;
|
|
19437
|
+
this.ownStatusMessages.addStatus(key);
|
|
19438
|
+
const removed = ClassicChannelManager.loggedLines.remove(key);
|
|
19439
|
+
if (removed)
|
|
19440
|
+
this.logger.debug({ key, removed }, "A status bubble logged before its id was known was taken out of the chat log");
|
|
19441
|
+
}
|
|
19442
|
+
/** #2940: Discord says a message was deleted — its chat-log line goes, and it is never logged after this. */
|
|
19443
|
+
onPlatformMessageDeleted(e) {
|
|
19444
|
+
const key = discordMessageKey(e?.channelId, e?.messageId);
|
|
19445
|
+
if (!key)
|
|
19446
|
+
return;
|
|
19447
|
+
this.ownStatusMessages.markDeleted(key);
|
|
19448
|
+
const removed = ClassicChannelManager.loggedLines.remove(key);
|
|
19449
|
+
if (removed)
|
|
19450
|
+
this.logger.debug({ key, removed }, "A deleted message was taken out of the chat log");
|
|
19451
|
+
}
|
|
19452
|
+
/** #2940: the key a Discord message's chat-log line is kept under (none for another platform: nothing is tracked). */
|
|
19453
|
+
chatLogKey(msg) {
|
|
19454
|
+
return msg.source === "discord" ? discordMessageKey(msg.threadId ?? msg.chatId, msg.messageId) : undefined;
|
|
19455
|
+
}
|
|
19456
|
+
/** #2940: whether `msg` must not enter the chat log (only its key is looked at; the debug trail names keys, not text). */
|
|
19457
|
+
chatLogExcludes(instanceName, msg) {
|
|
19458
|
+
const key = this.chatLogKey(msg);
|
|
19459
|
+
if (!this.ownStatusMessages.excluded(key))
|
|
19460
|
+
return false;
|
|
19461
|
+
this.logger.debug({ instanceName, key }, "Not logged: a status bubble of this fleet, or a deleted message");
|
|
19462
|
+
return true;
|
|
19463
|
+
}
|
|
18892
19464
|
/** Handle a message in a classic channel: log it, forward only /chat messages */
|
|
18893
19465
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
18894
19466
|
const text = msg.text ?? "";
|
|
@@ -18932,7 +19504,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18932
19504
|
: (msg.attachments?.length
|
|
18933
19505
|
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
18934
19506
|
: "");
|
|
18935
|
-
|
|
19507
|
+
// #2940: one of this fleet's own status bubbles (any bot's), or a message already deleted, never enters the log.
|
|
19508
|
+
if (this.chatLogExcludes(instanceName, msg))
|
|
19509
|
+
return;
|
|
19510
|
+
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
|
|
18936
19511
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
18937
19512
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
|
|
18938
19513
|
// Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
|
|
@@ -19033,7 +19608,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19033
19608
|
const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
19034
19609
|
: msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]`
|
|
19035
19610
|
: "";
|
|
19036
|
-
|
|
19611
|
+
// #2940: likewise in a room without collab (a bot's message reaches here only through a /chat text command).
|
|
19612
|
+
if (!this.chatLogExcludes(instanceName, msg))
|
|
19613
|
+
ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
|
|
19037
19614
|
// Bare attachment without /chat: save + log only, don't trigger agent
|
|
19038
19615
|
if (!isChat) {
|
|
19039
19616
|
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
@@ -19789,7 +20366,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19789
20366
|
}));
|
|
19790
20367
|
const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
19791
20368
|
timer.unref?.();
|
|
19792
|
-
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
20369
|
+
this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
19793
20370
|
try {
|
|
19794
20371
|
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
19795
20372
|
}
|
|
@@ -19824,7 +20401,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19824
20401
|
}
|
|
19825
20402
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
19826
20403
|
timer.unref?.();
|
|
19827
|
-
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
20404
|
+
this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
19828
20405
|
try {
|
|
19829
20406
|
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
19830
20407
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
@@ -19849,6 +20426,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19849
20426
|
const pending = this.pendingEffortSelects.get(match[1]);
|
|
19850
20427
|
if (!pending)
|
|
19851
20428
|
return true;
|
|
20429
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
20430
|
+
if (!pending.instanceCurrent()) {
|
|
20431
|
+
this.pendingEffortSelects.delete(match[1]);
|
|
20432
|
+
clearTimeout(pending.timer);
|
|
20433
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
20434
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
20435
|
+
return true;
|
|
20436
|
+
}
|
|
19852
20437
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
19853
20438
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
19854
20439
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -19891,17 +20476,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19891
20476
|
let result;
|
|
19892
20477
|
try {
|
|
19893
20478
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
19894
|
-
result =
|
|
19895
|
-
|
|
19896
|
-
|
|
19897
|
-
if (
|
|
19898
|
-
|
|
20479
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
20480
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
20481
|
+
? await withSpawnHoldReply(async (text) => {
|
|
20482
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
20483
|
+
if (progressMsgId)
|
|
20484
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
20485
|
+
else
|
|
20486
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
20487
|
+
}
|
|
19899
20488
|
else
|
|
19900
|
-
await pending.
|
|
19901
|
-
}
|
|
19902
|
-
else
|
|
19903
|
-
await pending.respond(text);
|
|
19904
|
-
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
20489
|
+
await pending.respond(text);
|
|
20490
|
+
}, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
19905
20491
|
}
|
|
19906
20492
|
catch (err) {
|
|
19907
20493
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -19958,7 +20544,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19958
20544
|
const choices = this.modelMenuChoices(name, nonce, options, currentModel);
|
|
19959
20545
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
19960
20546
|
timer.unref?.();
|
|
19961
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
20547
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
19962
20548
|
try {
|
|
19963
20549
|
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
19964
20550
|
}
|
|
@@ -19994,7 +20580,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19994
20580
|
}
|
|
19995
20581
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
19996
20582
|
timer.unref?.();
|
|
19997
|
-
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
20583
|
+
this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
19998
20584
|
try {
|
|
19999
20585
|
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
20000
20586
|
const pending = this.pendingModelSelects.get(nonce);
|
|
@@ -20167,6 +20753,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20167
20753
|
const pending = this.pendingModelSelects.get(match[1]);
|
|
20168
20754
|
if (!pending)
|
|
20169
20755
|
return true;
|
|
20756
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
20757
|
+
if (!pending.instanceCurrent()) {
|
|
20758
|
+
this.pendingModelSelects.delete(match[1]);
|
|
20759
|
+
clearTimeout(pending.timer);
|
|
20760
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
20761
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
20762
|
+
return true;
|
|
20763
|
+
}
|
|
20170
20764
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
20171
20765
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
20172
20766
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -20227,17 +20821,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20227
20821
|
let result;
|
|
20228
20822
|
try {
|
|
20229
20823
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
20230
|
-
result =
|
|
20231
|
-
|
|
20232
|
-
|
|
20233
|
-
if (
|
|
20234
|
-
|
|
20824
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
20825
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
20826
|
+
? await withSpawnHoldReply(async (text) => {
|
|
20827
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
20828
|
+
if (progressMsgId)
|
|
20829
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
20830
|
+
else
|
|
20831
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
20832
|
+
}
|
|
20235
20833
|
else
|
|
20236
|
-
await pending.
|
|
20237
|
-
}
|
|
20238
|
-
else
|
|
20239
|
-
await pending.respond(text);
|
|
20240
|
-
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
20834
|
+
await pending.respond(text);
|
|
20835
|
+
}, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
20241
20836
|
}
|
|
20242
20837
|
catch (err) {
|
|
20243
20838
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -20330,7 +20925,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20330
20925
|
* restart backends only read it at launch, so it is persisted and the
|
|
20331
20926
|
* instance respawns.
|
|
20332
20927
|
*/
|
|
20333
|
-
async applyEffort(instanceName, requested) {
|
|
20928
|
+
async applyEffort(instanceName, requested, opts) {
|
|
20929
|
+
// #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
|
|
20930
|
+
if (opts?.current && !opts.current())
|
|
20931
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
20334
20932
|
const level = requested.trim().toLowerCase();
|
|
20335
20933
|
const backendName = this.backendNameForInstance(instanceName);
|
|
20336
20934
|
let strategy = "unsupported";
|
|
@@ -20365,10 +20963,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20365
20963
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
20366
20964
|
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
20367
20965
|
}
|
|
20368
|
-
await this.restartSingleInstance(instanceName);
|
|
20966
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
20369
20967
|
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
20370
20968
|
}
|
|
20371
|
-
async applyModel(instanceName, model) {
|
|
20969
|
+
async applyModel(instanceName, model, opts) {
|
|
20970
|
+
// #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
|
|
20971
|
+
// await, and fenced into the restart.
|
|
20972
|
+
if (opts?.current && !opts.current())
|
|
20973
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
20372
20974
|
// Reject model names with newlines or control characters: they would be
|
|
20373
20975
|
// persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
|
|
20374
20976
|
if (/[\x00-\x1f\x7f]/.test(model)) {
|
|
@@ -20394,6 +20996,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20394
20996
|
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
20395
20997
|
if (strategy === "runtime" && backendName === "claude-code") {
|
|
20396
20998
|
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
20999
|
+
if (opts?.current && !opts.current())
|
|
21000
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
20397
21001
|
if (result.outcome === "mismatch")
|
|
20398
21002
|
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
20399
21003
|
if (result.outcome === "not_sent")
|
|
@@ -20414,7 +21018,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20414
21018
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
20415
21019
|
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
20416
21020
|
}
|
|
20417
|
-
await this.restartSingleInstance(instanceName);
|
|
21021
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
20418
21022
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
20419
21023
|
}
|
|
20420
21024
|
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
@@ -21293,6 +21897,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21293
21897
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
21294
21898
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
21295
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
|
+
}));
|
|
21296
21916
|
this.workingRecovery?.reset();
|
|
21297
21917
|
if (this.workingRecoveryTimer)
|
|
21298
21918
|
clearInterval(this.workingRecoveryTimer);
|
|
@@ -21347,9 +21967,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21347
21967
|
}
|
|
21348
21968
|
this.eventLoopWatch?.stop();
|
|
21349
21969
|
this.eventLoopWatch = null;
|
|
21970
|
+
this.stallReporter?.stop();
|
|
21971
|
+
this.stallReporter = null;
|
|
21350
21972
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
21351
21973
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
21352
21974
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
21975
|
+
await inboundStopped;
|
|
21353
21976
|
await publicStopped;
|
|
21354
21977
|
await sharesStopped?.catch(() => { });
|
|
21355
21978
|
await profileStopped;
|
|
@@ -21360,13 +21983,6 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21360
21983
|
await this.cpuProfileControl?.close();
|
|
21361
21984
|
this.cpuProfileControl = null;
|
|
21362
21985
|
await this.shutdownLoginWindows();
|
|
21363
|
-
// Cancel adapter retry timers
|
|
21364
|
-
for (const state of this.adapterState.values()) {
|
|
21365
|
-
if (state.retryTimer) {
|
|
21366
|
-
clearTimeout(state.retryTimer);
|
|
21367
|
-
state.retryTimer = undefined;
|
|
21368
|
-
}
|
|
21369
|
-
}
|
|
21370
21986
|
this.clearStatuslineWatchers();
|
|
21371
21987
|
this.costGuard?.stop();
|
|
21372
21988
|
this.dailySummary?.stop();
|
|
@@ -21404,6 +22020,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21404
22020
|
clearInterval(this.discordPresenceTimer);
|
|
21405
22021
|
this.discordPresenceTimer = null;
|
|
21406
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);
|
|
21407
22032
|
if (this.discordPresenceEagerTimer) {
|
|
21408
22033
|
clearTimeout(this.discordPresenceEagerTimer);
|
|
21409
22034
|
this.discordPresenceEagerTimer = null;
|
|
@@ -21708,6 +22333,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21708
22333
|
// mark every row done and tell the user "changes applied".
|
|
21709
22334
|
return { rejected: why };
|
|
21710
22335
|
}
|
|
22336
|
+
this.agentCredentialsCache?.observePolicies();
|
|
21711
22337
|
if (this.loadedFleetSource)
|
|
21712
22338
|
this.getConfigHistory().accepted("fleet.yaml", this.configPath, this.loadedFleetSource);
|
|
21713
22339
|
this.migrateOfficeStorage();
|
|
@@ -22608,6 +23234,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22608
23234
|
}
|
|
22609
23235
|
async runConnectionBindingApply(job, binding, execution) {
|
|
22610
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");
|
|
22611
23240
|
await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
|
|
22612
23241
|
job.result = "applied";
|
|
22613
23242
|
}
|