@songsid/agend 2.3.4-beta.7 → 2.3.4-beta.9

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.
Files changed (146) hide show
  1. package/THIRD_PARTY_NOTICES.md +37 -0
  2. package/dist/agent-cli-instructions.md +1 -1
  3. package/dist/agent-credentials.d.ts +24 -0
  4. package/dist/agent-credentials.js +66 -0
  5. package/dist/agent-credentials.js.map +1 -0
  6. package/dist/agent-endpoint.d.ts +3 -1
  7. package/dist/agent-endpoint.js +13 -0
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/authority/session-authorizations.d.ts +28 -4
  10. package/dist/authority/session-authorizations.js +63 -17
  11. package/dist/authority/session-authorizations.js.map +1 -1
  12. package/dist/backend/antigravity.js +3 -1
  13. package/dist/backend/antigravity.js.map +1 -1
  14. package/dist/backend/codex-footer-hints.d.ts +4 -0
  15. package/dist/backend/codex-footer-hints.js +27 -0
  16. package/dist/backend/codex-footer-hints.js.map +1 -0
  17. package/dist/backend/codex.js +8 -3
  18. package/dist/backend/codex.js.map +1 -1
  19. package/dist/backend/kiro.d.ts +3 -0
  20. package/dist/backend/kiro.js +39 -1
  21. package/dist/backend/kiro.js.map +1 -1
  22. package/dist/backend/muse.js +5 -3
  23. package/dist/backend/muse.js.map +1 -1
  24. package/dist/backend/types.d.ts +6 -1
  25. package/dist/backend/types.js +3 -0
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/cache-ledger.d.ts +85 -0
  28. package/dist/cache-ledger.js +236 -1
  29. package/dist/cache-ledger.js.map +1 -1
  30. package/dist/cache-service.d.ts +5 -0
  31. package/dist/cache-service.js +16 -4
  32. package/dist/cache-service.js.map +1 -1
  33. package/dist/channel/adapters/discord.js +11 -3
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.js +4 -3
  36. package/dist/channel/adapters/telegram.js.map +1 -1
  37. package/dist/channel/mcp-tools.js +1 -1
  38. package/dist/channel/mcp-tools.js.map +1 -1
  39. package/dist/cross-instance-notice.d.ts +10 -0
  40. package/dist/cross-instance-notice.js +67 -5
  41. package/dist/cross-instance-notice.js.map +1 -1
  42. package/dist/daemon.d.ts +43 -0
  43. package/dist/daemon.js +361 -17
  44. package/dist/daemon.js.map +1 -1
  45. package/dist/delivery-consumed-watch.d.ts +23 -1
  46. package/dist/delivery-consumed-watch.js +30 -3
  47. package/dist/delivery-consumed-watch.js.map +1 -1
  48. package/dist/delivery-reconciliation.d.ts +7 -0
  49. package/dist/delivery-reconciliation.js +9 -1
  50. package/dist/delivery-reconciliation.js.map +1 -1
  51. package/dist/export-import.d.ts +7 -0
  52. package/dist/export-import.js +44 -10
  53. package/dist/export-import.js.map +1 -1
  54. package/dist/fleet-context.d.ts +2 -0
  55. package/dist/fleet-manager.d.ts +85 -4
  56. package/dist/fleet-manager.js +375 -30
  57. package/dist/fleet-manager.js.map +1 -1
  58. package/dist/general-knowledge/skills/concepts/SKILL.md +390 -0
  59. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
  60. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  61. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  62. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  63. package/dist/instance-lifecycle.d.ts +8 -2
  64. package/dist/instance-lifecycle.js +37 -6
  65. package/dist/instance-lifecycle.js.map +1 -1
  66. package/dist/instance-topic-delete.d.ts +77 -0
  67. package/dist/instance-topic-delete.js +123 -0
  68. package/dist/instance-topic-delete.js.map +1 -0
  69. package/dist/instructions.d.ts +1 -1
  70. package/dist/instructions.js +1 -1
  71. package/dist/instructions.js.map +1 -1
  72. package/dist/kiro-engine-status.d.ts +11 -0
  73. package/dist/kiro-engine-status.js +6 -0
  74. package/dist/kiro-engine-status.js.map +1 -1
  75. package/dist/locale.js +47 -1
  76. package/dist/locale.js.map +1 -1
  77. package/dist/log-steps.d.ts +4 -3
  78. package/dist/log-steps.js +32 -7
  79. package/dist/log-steps.js.map +1 -1
  80. package/dist/login-flows.d.ts +2 -0
  81. package/dist/login-flows.js +5 -2
  82. package/dist/login-flows.js.map +1 -1
  83. package/dist/needs-you-hub.js +1 -1
  84. package/dist/needs-you-hub.js.map +1 -1
  85. package/dist/needs-you.d.ts +6 -0
  86. package/dist/needs-you.js +8 -2
  87. package/dist/needs-you.js.map +1 -1
  88. package/dist/outbound-handlers.js +3 -3
  89. package/dist/outbound-handlers.js.map +1 -1
  90. package/dist/outbound-schemas.js +5 -2
  91. package/dist/outbound-schemas.js.map +1 -1
  92. package/dist/queued-handoffs.d.ts +33 -0
  93. package/dist/queued-handoffs.js +75 -0
  94. package/dist/queued-handoffs.js.map +1 -0
  95. package/dist/scheduler/db.d.ts +2 -1
  96. package/dist/scheduler/db.js +23 -9
  97. package/dist/scheduler/db.js.map +1 -1
  98. package/dist/settings-api.d.ts +13 -0
  99. package/dist/settings-api.js +12 -1
  100. package/dist/settings-api.js.map +1 -1
  101. package/dist/tool-permissions.d.ts +5 -2
  102. package/dist/tool-permissions.js +6 -5
  103. package/dist/tool-permissions.js.map +1 -1
  104. package/dist/tool-progress.d.ts +2 -0
  105. package/dist/tool-progress.js +19 -3
  106. package/dist/tool-progress.js.map +1 -1
  107. package/dist/topic-commands.js +4 -0
  108. package/dist/topic-commands.js.map +1 -1
  109. package/dist/ui/chat-strings.js +2 -2
  110. package/dist/ui/fleet-usage.js +90 -27
  111. package/dist/ui/panel-chat.js +2 -0
  112. package/dist/ui/panel-needs.js +4 -3
  113. package/dist/ui/settings-strings.js +2 -2
  114. package/dist/ui/shared/app-i18n.js +2 -2
  115. package/dist/ui/shared/app-needs.js +1 -1
  116. package/dist/ui/shared/app.css +15 -0
  117. package/dist/ui/shared/usage-dialog.js +18 -1
  118. package/dist/ui/shared/view-strings.js +16 -0
  119. package/dist/usage/capacity.d.ts +69 -0
  120. package/dist/usage/capacity.js +119 -0
  121. package/dist/usage/capacity.js.map +1 -0
  122. package/dist/usage/i18n-keys.d.ts +1 -1
  123. package/dist/usage/i18n-keys.js +3 -0
  124. package/dist/usage/i18n-keys.js.map +1 -1
  125. package/dist/usage/profile-timeline.d.ts +16 -0
  126. package/dist/usage/profile-timeline.js +58 -0
  127. package/dist/usage/profile-timeline.js.map +1 -0
  128. package/dist/usage/quota-history.d.ts +43 -0
  129. package/dist/usage/quota-history.js +168 -0
  130. package/dist/usage/quota-history.js.map +1 -0
  131. package/dist/usage/usage-api.d.ts +31 -0
  132. package/dist/usage/usage-api.js +80 -1
  133. package/dist/usage/usage-api.js.map +1 -1
  134. package/dist/usage-report.d.ts +25 -1
  135. package/dist/usage-report.js +55 -4
  136. package/dist/usage-report.js.map +1 -1
  137. package/dist/usage-rollup.d.ts +4 -1
  138. package/dist/usage-rollup.js +12 -3
  139. package/dist/usage-rollup.js.map +1 -1
  140. package/dist/web-api.d.ts +6 -0
  141. package/dist/web-api.js +8 -2
  142. package/dist/web-api.js.map +1 -1
  143. package/dist/web-session-authorization.d.ts +13 -2
  144. package/dist/web-session-authorization.js +10 -3
  145. package/dist/web-session-authorization.js.map +1 -1
  146. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { listKiroV1Sessions, readKiroClassicLinks } from "./backend/kiro-identit
3
3
  import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
4
4
  import { sharedKiroV2StoreLane } from "./backend/kiro-v2-store.js";
5
5
  import { SEPARATE_FEATURE_KEYS, hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
6
+ import { maskSecrets } from "./log-steps.js";
6
7
  import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
7
8
  import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
8
9
  import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
@@ -75,6 +76,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
75
76
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
76
77
  import { EventLog } from "./event-log.js";
77
78
  import { binaryProbe } from "./binary-probe.js";
79
+ import { deletePlannedTopic, planInstanceTopicDelete, topicDestination, topicMustStay } from "./instance-topic-delete.js";
78
80
  import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
79
81
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
80
82
  import { OutboxOpenError } from "./outbox-open-error.js";
@@ -201,7 +203,7 @@ import { envFileKeys } from "./token-env-name.js";
201
203
  import { discordGeneralChannelId } from "./discord-general.js";
202
204
  import { parseEnvText } from "./env-file.js";
203
205
  import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
204
- import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
206
+ import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, setCapacityLookup, usageProviderIdForBackend } from "./usage/usage-api.js";
205
207
  import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
206
208
  import { LoginSession } from "./login-manager.js";
207
209
  import { runOnLoginShell } from "./login-shell.js";
@@ -232,6 +234,8 @@ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteC
232
234
  import { recentChatContext } from "./log-tail.js";
233
235
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
234
236
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
237
+ import { AgentCredentials, agentCredentialPolicy } from "./agent-credentials.js";
238
+ import { writeSecretFile } from "./secret-file.js";
235
239
  import { ConfigHistory, ConfigSyntaxError, configIssueText } from "./config-history.js";
236
240
  import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
237
241
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
@@ -290,7 +294,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
290
294
  const SELF_RESTART_DEADLINE_MS = 300_000;
291
295
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
292
296
  import { kiroHome } from "./backend/kiro-kas-store.js";
293
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
297
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile, listConfiguredProfiles } from "./backend/credential-profile.js";
294
298
  import { carryClaudeConversation } from "./backend/claude-carry.js";
295
299
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
296
300
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -306,6 +310,12 @@ import { BackendOutageTracker } from "./backend-outage.js";
306
310
  import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
307
311
  import { getTmuxSession } from "./config.js";
308
312
  import { OwnStatusMessages, discordMessageKey } from "./chat-log-ids.js";
313
+ import { PROFILE_TIMELINE_FILE, PROFILE_UNRECORDED, recordProfileLaunch } from "./usage/profile-timeline.js";
314
+ import { readQuotaHistory } from "./usage/quota-history.js";
315
+ import { estimateCapacity } from "./usage/capacity.js";
316
+ import { covered, hourSources } from "./usage-rollup.js";
317
+ import { KEEP_DAYS } from "./cache-ledger.js";
318
+ import { ratesFor } from "./cache-prices.js";
309
319
  // Closed migration cohort: these 11 names existed immediately before the first
310
320
  // managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
311
321
  // adding a bundled skill: its new name is not proof of old AgEnD ownership.
@@ -887,6 +897,7 @@ export class FleetManager {
887
897
  startupRetryNotices = new Map();
888
898
  /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
889
899
  unsupportedCliNotices = new Map();
900
+ kiroCompatibilityHolds = new Map();
890
901
  kiroIncompatNotices = new Map();
891
902
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
892
903
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
@@ -1030,6 +1041,9 @@ export class FleetManager {
1030
1041
  discordPresenceEagerPending = false;
1031
1042
  discordPresenceInFlight = null;
1032
1043
  static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
1044
+ /** #3066 §1d: how often a fleet with no Discord bot reads the quota for its history (the user's choice: 30 min). */
1045
+ static QUOTA_BACKGROUND_READ_MS = 30 * 60_000;
1046
+ quotaReadTimer = null;
1033
1047
  /** Days of event/activity history to keep. */
1034
1048
  static EVENT_LOG_RETENTION_DAYS = 30;
1035
1049
  watchdogTimer = null;
@@ -2153,6 +2167,22 @@ export class FleetManager {
2153
2167
  * startup-only key at startup. See checkStartupSignatureConsistency(). */
2154
2168
  fleetSignatureMismatch = null;
2155
2169
  healthServerListening = false;
2170
+ agentCredentialsCache;
2171
+ get agentCredentials() {
2172
+ return this.agentCredentialsCache ??= new AgentCredentials(name => agentCredentialPolicy(this.fleetConfig, name, this.isClassicInstance(name)), name => {
2173
+ try {
2174
+ const token = randomBytes(32).toString("hex");
2175
+ const result = writeSecretFile(join(this.getInstanceDir(name), "agent.token"), token);
2176
+ if (!result.ok)
2177
+ this.logger.error({ name, mode: result.mode, reason: result.reason }, "Agent token file is not owner-only");
2178
+ return token;
2179
+ }
2180
+ catch {
2181
+ this.logger.error({ name }, "Agent credential renewal failed; prior generation remains retired");
2182
+ return null;
2183
+ }
2184
+ });
2185
+ }
2156
2186
  constructor(dataDir) {
2157
2187
  this.dataDir = dataDir;
2158
2188
  FleetManager.signalTarget = this;
@@ -2533,6 +2563,10 @@ export class FleetManager {
2533
2563
  const daemon = this.daemons.get(name);
2534
2564
  if (daemon?.bootId === daemonBootId)
2535
2565
  this.listenForWorkingState(name, daemon);
2566
+ // #3066 §1d: a Codex launch records the credential profile it runs under, so its usage is told by account.
2567
+ if (daemon?.bootId === daemonBootId && this.backendNameOf(name) === "codex") {
2568
+ void recordProfileLaunch(join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE), this.credentialProfileOf(name, "codex") ?? "default");
2569
+ }
2536
2570
  // #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
2537
2571
  // request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
2538
2572
  this.scheduleDeliveryOutboxPump();
@@ -3370,6 +3404,7 @@ export class FleetManager {
3370
3404
  * guild channel.
3371
3405
  */
3372
3406
  reregisterClassicChannels() {
3407
+ this.agentCredentialsCache?.observePolicies();
3373
3408
  if (!this.classicChannels)
3374
3409
  return;
3375
3410
  const channels = this.classicChannels.getAll();
@@ -6155,6 +6190,17 @@ export class FleetManager {
6155
6190
  this.queueKiroIncompatNotice(name, err.message);
6156
6191
  return true;
6157
6192
  }
6193
+ /** No pane is needed for a compatibility refusal to remain in Needs you. */
6194
+ setKiroCompatibilityHold(name, message) {
6195
+ const holds = this.kiroCompatibilityHolds ??= new Map();
6196
+ if ((message === null && !holds.has(name)) || (message !== null && holds.get(name)?.message === message))
6197
+ return;
6198
+ if (message === null)
6199
+ holds.delete(name);
6200
+ else
6201
+ holds.set(name, { message, since: Date.now() });
6202
+ this.needsYou?.poke();
6203
+ }
6158
6204
  /**
6159
6205
  * LifecycleContext: a kiro instance the installed kiro-cli cannot run as
6160
6206
  * configured (#1109) — at start, or at a respawn after kiro-cli replaced
@@ -8579,6 +8625,9 @@ export class FleetManager {
8579
8625
  if (others.length > 0 || classicAtBoot.length > 0) {
8580
8626
  await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
8581
8627
  }
8628
+ // #3066 §1d (Fable r1): on every fleet — a web-only one (#2910) has no chat connection at all, and is the clearest
8629
+ // case with no Discord bot reading. Each tick decides for itself whether to read (backgroundQuotaRead).
8630
+ this.startQuotaBackgroundReads();
8582
8631
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
8583
8632
  await adapterStartup;
8584
8633
  this.startDiscordUsagePresence();
@@ -8750,6 +8799,178 @@ export class FleetManager {
8750
8799
  this.logger.warn({ health }, "Fleet started with problems — see /health");
8751
8800
  }
8752
8801
  }
8802
+ /**
8803
+ * #3066 §1d (the user: yes, every 30 min): the quota history (usage/quota-history.ts) records only the readings AgEnD
8804
+ * makes. A Discord bot's presence refresh reads every 15 min; without one, nothing reads while nobody looks. So, with no
8805
+ * Discord bot and while some account has a running agent, read every 30 min — the same cached read (its 5-minute TTL
8806
+ * still bounds the vendor calls), recorded like any other.
8807
+ */
8808
+ startQuotaBackgroundReads() {
8809
+ if (this.quotaReadTimer)
8810
+ clearInterval(this.quotaReadTimer);
8811
+ this.quotaReadTimer = setInterval(() => { void this.backgroundQuotaRead(); }, FleetManager.QUOTA_BACKGROUND_READ_MS);
8812
+ this.quotaReadTimer.unref?.();
8813
+ // #3066 §1: the measured capacity, published for the usage reads (get_usage, the page) — refreshed every 10 min.
8814
+ setCapacityLookup((provider, metric) => this.capacityEstimates.get(`${provider}\u0000${metric}`) ?? null);
8815
+ if (this.capacityTimer)
8816
+ clearInterval(this.capacityTimer);
8817
+ void this.refreshCapacity();
8818
+ this.capacityTimer = setInterval(() => { void this.refreshCapacity(); }, FleetManager.CAPACITY_REFRESH_MS);
8819
+ this.capacityTimer.unref?.();
8820
+ }
8821
+ /** #3066 §1: the latest estimates, by `provider\0metric` (what usage-api's lookup reads, synchronously). */
8822
+ capacityEstimates = new Map();
8823
+ capacityTimer = null;
8824
+ static CAPACITY_REFRESH_MS = 10 * 60_000;
8825
+ /**
8826
+ * #3066 §1: estimate each account's weekly 100% from the quota history (§1d) and the usage ledger's hours — both read
8827
+ * asynchronously, the arithmetic small (usage/capacity.ts). An account is its usage provider id ("claude",
8828
+ * "claude:hsinlin", "codex:yanxianchen"): its hours are the ledger sources of that backend under that credential profile
8829
+ * (Codex's "unrecorded" hours are no account's). A per-model weekly ("Sonnet (weekly)") counts only that model's tokens.
8830
+ *
8831
+ * Prism #3073: no estimate rather than a wrong one —
8832
+ * - a window is measured only where the ledger has its hours: kept (31 days) and scanned (`days.coverage`) for every
8833
+ * instance of the account's backend, and every instance whose hours count for it; the quota history goes back 12 weeks;
8834
+ * - a profile literally named "default", "unknown" or "unrecorded" (configured, on disk, or in the quota history) makes
8835
+ * those hours ambiguous: neither the host
8836
+ * account nor the named one is estimated (each would be credited the other's use, or lose its own);
8837
+ * - only a metric the vendor marks per-model (`scope: "model"`) is filtered by model; another named weekly pool
8838
+ * ("Luna Reserve (weekly)", Antigravity pools) is no model name and is not estimated.
8839
+ */
8840
+ async refreshCapacity() {
8841
+ try {
8842
+ const readings = await readQuotaHistory();
8843
+ const groups = new Map();
8844
+ for (const r of readings) {
8845
+ const k = `${r.provider}\u0000${r.metric}`;
8846
+ (groups.get(k) ?? groups.set(k, []).get(k)).push(r);
8847
+ }
8848
+ if (!groups.size) {
8849
+ this.capacityEstimates = new Map();
8850
+ return 0;
8851
+ }
8852
+ const data = await this.cacheServiceFor().usageData();
8853
+ const now = Date.now(), DAY = 24 * 3_600_000, HOUR = 3_600_000;
8854
+ const keptFrom = now - KEEP_DAYS * DAY + HOUR; // the oldest hour still in every ledger (an hour of margin)
8855
+ // Profiles whose names are also the ledger's own tags: their hours cannot be told from the host's / unattributed.
8856
+ // Any trace of such a profile counts (Prism #3073 r2), not only today's config — a binding removed or switched
8857
+ // leaves its hours in the ledger: configured now, its directory on this host, or its account in the quota history.
8858
+ const RESERVED = new Set(["default", "unknown", PROFILE_UNRECORDED]);
8859
+ const historyProviders = new Set(readings.map((r) => r.provider));
8860
+ const reservedNamed = new Map();
8861
+ const reservedFor = (backend) => {
8862
+ const known = reservedNamed.get(backend);
8863
+ if (known)
8864
+ return known;
8865
+ const found = new Set(listConfiguredProfiles(this.fleetConfig, backend).filter((n) => RESERVED.has(n)));
8866
+ const base = usageProviderIdForBackend(backend);
8867
+ for (const n of RESERVED) {
8868
+ if (base && historyProviders.has(`${base}:${n}`))
8869
+ found.add(n);
8870
+ if (credentialHomeSpec(backend) && existsSync(credentialProfileHome(this.dataDir, backend, n)))
8871
+ found.add(n);
8872
+ }
8873
+ return reservedNamed.set(backend, found).get(backend);
8874
+ };
8875
+ // Every instance of a backend may have used any of its accounts (bindings change): an account is measured only
8876
+ // where all of them were scanned (Prism #3073 r2) — one still waiting for its first scan has no hours yet, not none.
8877
+ const daysOfBase = new Map();
8878
+ for (const inst of data.instances) {
8879
+ const base = usageProviderIdForBackend(inst.inst.backend);
8880
+ if (base)
8881
+ (daysOfBase.get(base) ?? daysOfBase.set(base, []).get(base)).push(inst.days);
8882
+ }
8883
+ // provider id → hour → model key → totals; and the instances whose hours count for it (their scan coverage).
8884
+ const byProvider = new Map();
8885
+ const daysOf = new Map();
8886
+ for (const inst of data.instances) {
8887
+ for (const [hk, hour] of Object.entries(inst.ledger.hours)) {
8888
+ for (const src of hourSources(hour)) {
8889
+ const base = usageProviderIdForBackend(src.backend);
8890
+ if (!base)
8891
+ continue;
8892
+ // Ambiguous (a named profile with the tag's name), or no account's: counted for no one, so an account whose
8893
+ // hours these may be has none — and gets no estimate, never a low one.
8894
+ if (reservedFor(src.backend).has(src.profile) || src.profile === "unknown" || src.profile === PROFILE_UNRECORDED)
8895
+ continue;
8896
+ const provider = src.profile === "default" ? base : `${base}:${src.profile}`;
8897
+ const list = daysOf.get(provider) ?? daysOf.set(provider, []).get(provider);
8898
+ if (!list.includes(inst.days))
8899
+ list.push(inst.days);
8900
+ const hours = byProvider.get(provider) ?? byProvider.set(provider, new Map()).get(provider);
8901
+ const into = hours.get(Number(hk)) ?? hours.set(Number(hk), Object.create(null)).get(Number(hk));
8902
+ for (const [mk, tot] of Object.entries(src.m)) {
8903
+ const cur = into[mk] ??= [0, 0, 0, 0, 0, 0, 0];
8904
+ for (let i = 0; i < 7; i++)
8905
+ cur[i] = (cur[i] ?? 0) + (tot[i] ?? 0);
8906
+ }
8907
+ }
8908
+ }
8909
+ }
8910
+ const next = new Map();
8911
+ for (const [key, list] of groups) {
8912
+ const [provider, metric] = key.split("\u0000");
8913
+ // A per-model weekly ("Sonnet (weekly)", the vendor's scope "model"): that model's tokens only. Another named
8914
+ // pool is not a model: no estimate. "Weekly", "Weekly limit": everything.
8915
+ const named = /^(.+) \((?:weekly|monthly|\d+d)\)$/i.exec(metric)?.[1]?.toLowerCase() ?? null;
8916
+ const perModel = list.some((r) => r.scope === "model");
8917
+ if (named && !perModel)
8918
+ continue;
8919
+ const hours = [];
8920
+ for (const [hour, models] of byProvider.get(provider) ?? []) {
8921
+ let api = 0, output = 0, all = 0;
8922
+ for (const [mk, tot] of Object.entries(models)) {
8923
+ const bar = mk.lastIndexOf("|"), model = mk.slice(0, bar), long = mk.slice(bar + 1) === "1";
8924
+ if (named && !model.toLowerCase().includes(named))
8925
+ continue;
8926
+ const [, uncached, read, w5, w1h, w, out] = tot;
8927
+ const r = ratesFor(model, long ? Number.MAX_SAFE_INTEGER : 0);
8928
+ 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;
8929
+ output += out;
8930
+ all += uncached + read + w5 + w1h + w + out;
8931
+ }
8932
+ if (all > 0)
8933
+ hours.push({ hour, api, output, all });
8934
+ }
8935
+ const base = provider.split(":")[0];
8936
+ const scanned = [...new Set([...(daysOf.get(provider) ?? []), ...(daysOfBase.get(base) ?? [])])];
8937
+ const available = (from, to) => {
8938
+ if (from < keptFrom || !scanned.length)
8939
+ return false;
8940
+ for (let h = from; h <= to; h += HOUR)
8941
+ if (!scanned.every((d) => covered(d, h)))
8942
+ return false;
8943
+ return true;
8944
+ };
8945
+ const est = estimateCapacity(list, hours, available);
8946
+ if (est)
8947
+ next.set(key, est);
8948
+ }
8949
+ this.capacityEstimates = next;
8950
+ return next.size;
8951
+ }
8952
+ catch (err) {
8953
+ this.logger.debug({ err }, "capacity estimate refresh failed");
8954
+ return -1;
8955
+ }
8956
+ }
8957
+ /** One background quota read, when nothing else reads (no Discord bot) and an account has a running agent. */
8958
+ async backgroundQuotaRead() {
8959
+ if (this.shuttingDown)
8960
+ return false;
8961
+ if ([...this.adapters.values()].some(a => a.type === "discord" && typeof a.setActivity === "function"))
8962
+ return false;
8963
+ const active = this.getActiveUsageProviderIds();
8964
+ if (active.size === 0)
8965
+ return false;
8966
+ try {
8967
+ await getUsageSnapshot(false, active);
8968
+ return true;
8969
+ }
8970
+ catch {
8971
+ return false;
8972
+ }
8973
+ }
8753
8974
  /** Keep Discord profile activity aligned with the same cached usage source as /usage. */
8754
8975
  startDiscordUsagePresence() {
8755
8976
  if (this.discordPresenceTimer)
@@ -11737,6 +11958,11 @@ export class FleetManager {
11737
11958
  let result;
11738
11959
  switch (msg.type) {
11739
11960
  case "fleet_decision_create": {
11961
+ const supersedes = this.decisionSupersedes("ipc-typed", instanceName, payload.supersedes);
11962
+ if ("error" in supersedes) {
11963
+ ipc.send({ type: "fleet_decision_response", fleetRequestId, error: supersedes.error });
11964
+ return;
11965
+ }
11740
11966
  // Prune expired decisions on create
11741
11967
  db.pruneExpiredDecisions();
11742
11968
  result = db.createDecision({
@@ -11747,7 +11973,7 @@ export class FleetManager {
11747
11973
  tags: payload.tags,
11748
11974
  ttl_days: payload.ttl_days,
11749
11975
  created_by: instanceName,
11750
- supersedes: payload.supersedes,
11976
+ supersedes: supersedes.id,
11751
11977
  });
11752
11978
  break;
11753
11979
  }
@@ -11924,6 +12150,21 @@ export class FleetManager {
11924
12150
  return "ok";
11925
12151
  }
11926
12152
  }
12153
+ /**
12154
+ * `post_decision` with `supersedes` changes a decision someone else may have recorded (it stops being active): the
12155
+ * same permission as `update_decision`, asked through the same live check (tool set + dashboard switches), on both
12156
+ * create paths. An empty or absent value supersedes nothing and needs nothing; anything else must be an id.
12157
+ */
12158
+ decisionSupersedes(sink, instanceName, value) {
12159
+ if (value === undefined || value === null || value === "")
12160
+ return { id: undefined };
12161
+ if (typeof value !== "string")
12162
+ return { error: "supersedes must be a decision id" };
12163
+ const permitted = this.checkToolPermission(sink, instanceName, "update_decision");
12164
+ if (!permitted.allowed)
12165
+ return { error: `Superseding a decision needs update_decision. ${permitted.message}` };
12166
+ return { id: value };
12167
+ }
11927
12168
  async handleDecisionCrudHttp(instance, op, args) {
11928
12169
  if (!this.scheduler)
11929
12170
  return { error: "Scheduler not available" };
@@ -11939,16 +12180,24 @@ export class FleetManager {
11939
12180
  if (!title || !content)
11940
12181
  return { error: "title and content are required" };
11941
12182
  const scope = args.scope === "fleet" ? "fleet" : "project";
11942
- return db.createDecision({
11943
- project_root: projectRoot,
11944
- scope,
11945
- title,
11946
- content,
11947
- tags: asStrArr(args.tags),
11948
- ttl_days: asNum(args.ttl_days),
11949
- supersedes: asStr(args.supersedes),
11950
- created_by: instance,
11951
- });
12183
+ const supersedes = this.decisionSupersedes("agent-endpoint", instance, args.supersedes);
12184
+ if ("error" in supersedes)
12185
+ return { error: supersedes.error };
12186
+ try {
12187
+ return db.createDecision({
12188
+ project_root: projectRoot,
12189
+ scope,
12190
+ title,
12191
+ content,
12192
+ tags: asStrArr(args.tags),
12193
+ ttl_days: asNum(args.ttl_days),
12194
+ supersedes: supersedes.id,
12195
+ created_by: instance,
12196
+ });
12197
+ }
12198
+ catch (err) {
12199
+ return { error: err.message }; // a refused predecessor: nothing was inserted
12200
+ }
11952
12201
  }
11953
12202
  // #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
11954
12203
  // The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
@@ -12237,16 +12486,89 @@ export class FleetManager {
12237
12486
  }
12238
12487
  return adapter.createTopic(topicName);
12239
12488
  }
12240
- async deleteForumTopic(topicId, adapterId) {
12241
- try {
12242
- const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
12243
- if (!adapter?.deleteTopic)
12244
- return;
12245
- await adapter.deleteTopic(topicId);
12246
- }
12247
- catch (err) {
12248
- this.logger.warn({ err, topicId }, "Failed to delete forum topic during rollback");
12249
- }
12489
+ /**
12490
+ * Delete an agent and, unless asked not to, its own chat topic (user decision: by default). The topic is planned
12491
+ * before the removal (its id and bot go with the fleet.yaml entry) and deleted through the agent's OWN bot after it;
12492
+ * the outcome is the platform's answer (instance-topic-delete.ts). A topic that cannot go never fails the removal.
12493
+ */
12494
+ async removeInstanceAndTopic(name, authorization, opts) {
12495
+ const plan = opts.deleteTopic ? this.planInstanceTopicDelete(name) : null;
12496
+ if (opts.execution)
12497
+ await this.removeInstance(name, authorization, opts.execution);
12498
+ else
12499
+ await this.removeInstance(name, authorization);
12500
+ // #3055 review (Prism P1): asked again right before the platform call — the fleet as it is NOW, not as planned
12501
+ // before the removal was awaited (a new owner of the topic, or the bot rebuilt).
12502
+ return deletePlannedTopic(plan, this.logger, (p) => {
12503
+ if (this.instanceTopicAdapter(p.adapterId) !== p.adapter)
12504
+ return "the agent's bot changed while it was being deleted, so its topic was left";
12505
+ return topicMustStay(p.destination, this.topicOwnership(), null); // the original is gone: every owner now counts
12506
+ }, { begin: (d) => this.topicDeletionsInFlight.add(d), end: (d) => this.topicDeletionsInFlight.delete(d) });
12507
+ }
12508
+ /** #3055: destinations whose platform deletion is running now; a binding to one waits until it is over. */
12509
+ topicDeletionsInFlight = new Set();
12510
+ /** True while a topic with this id is being deleted on any connection (a new binding of it is refused meanwhile). */
12511
+ isTopicDeletionInFlight(topicId) {
12512
+ const tail = `:${topicId}`;
12513
+ return [...this.topicDeletionsInFlight].some(d => d.endsWith(tail));
12514
+ }
12515
+ /**
12516
+ * #3055 review r2 (Prism P1-3): would this instance config bind a topic whose deletion is running now? Compared by
12517
+ * physical destination, so the same thread number in another Telegram group is not held. Settings asks it at commit.
12518
+ */
12519
+ topicBindingInFlight(inst) {
12520
+ if (this.topicDeletionsInFlight.size === 0 || inst.topic_id == null || inst.topic_id === "")
12521
+ return false;
12522
+ const adapterId = typeof inst.channel_id === "string" && inst.channel_id ? inst.channel_id : this.getPrimaryAdapterId();
12523
+ return this.topicDeletionsInFlight.has(this.destinationFor(adapterId, inst.topic_id));
12524
+ }
12525
+ /** Any topic deletion running now (a connection's group or General may not be re-pointed meanwhile). */
12526
+ topicDeletionPending() { return this.topicDeletionsInFlight.size > 0; }
12527
+ /** The physical destination of `topicId` on the bot `adapterId`. */
12528
+ destinationFor(adapterId, topicId) {
12529
+ const world = adapterId ? this.worlds.get(adapterId) : undefined;
12530
+ const platform = world?.adapter?.type ?? this.getChannelConfig(adapterId)?.type;
12531
+ const groupId = world?.groupId ?? String(this.getChannelConfig(adapterId)?.group_id ?? "");
12532
+ return topicDestination(platform, groupId, adapterId, topicId);
12533
+ }
12534
+ /** The adapter of the bot `adapterId` names — never a substitute for a bot that is not there. */
12535
+ instanceTopicAdapter(adapterId) {
12536
+ return (adapterId ? this.worlds.get(adapterId)?.adapter : this.worlds.size === 0 ? this.adapter ?? undefined : undefined) ?? undefined;
12537
+ }
12538
+ /**
12539
+ * Topic ownership by physical destination (#3055 review, Prism P1): an instance's topic is where its bot's platform
12540
+ * puts it (a Telegram group + thread, a Discord channel), so two bots in one group, or two instances on one
12541
+ * channel, are seen to share it; and every connection's own General is protected, with or without a General agent.
12542
+ */
12543
+ topicOwnership() {
12544
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
12545
+ const destinationOf = (n) => {
12546
+ const cfg = this.fleetConfig?.instances?.[n];
12547
+ if (!cfg || cfg.topic_id == null || cfg.topic_id === "")
12548
+ return null;
12549
+ return this.destinationFor(this.getInstanceAdapterId(n), cfg.topic_id);
12550
+ };
12551
+ const generalDestinations = () => {
12552
+ const out = new Set();
12553
+ for (const ch of channels) {
12554
+ const id = ch.id ?? ch.type;
12555
+ const groupId = this.worlds.get(id)?.groupId ?? (ch.group_id != null ? String(ch.group_id) : undefined);
12556
+ if (ch.type === "telegram")
12557
+ out.add(topicDestination("telegram", groupId, id, 1));
12558
+ if (ch.type === "discord") {
12559
+ const gcid = discordGeneralChannelId(ch);
12560
+ if (gcid)
12561
+ out.add(topicDestination("discord", groupId, id, gcid));
12562
+ }
12563
+ }
12564
+ return out;
12565
+ };
12566
+ return { instances: this.fleetConfig?.instances, destinationOf, generalDestinations };
12567
+ }
12568
+ /** What deleting `name`'s topic would act on: its own bot only — a bot that is not there is never substituted. */
12569
+ planInstanceTopicDelete(name) {
12570
+ const adapterId = this.getInstanceAdapterId(name);
12571
+ return planInstanceTopicDelete({ name, adapterId, adapter: this.instanceTopicAdapter(adapterId), ...this.topicOwnership() });
12250
12572
  }
12251
12573
  getForumTopicDeleter(adapterId) {
12252
12574
  const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
@@ -12647,6 +12969,7 @@ export class FleetManager {
12647
12969
  Object.assign(current, resolved);
12648
12970
  }
12649
12971
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
12972
+ this.agentCredentialsCache?.observePolicies();
12650
12973
  this.refreshSavedDashboardPermissions();
12651
12974
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
12652
12975
  }
@@ -14964,6 +15287,7 @@ export class FleetManager {
14964
15287
  const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
14965
15288
  name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
14966
15289
  claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
15290
+ ...(this.backendNameOf(name) === "codex" ? { profileTimelinePath: join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE) } : {}),
14967
15291
  kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
14968
15292
  }));
14969
15293
  const taken = new Set(fleet.map(i => i.name));
@@ -14975,6 +15299,9 @@ export class FleetManager {
14975
15299
  claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
14976
15300
  profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
14977
15301
  kiroDbPath: this.kiroDbPathOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
15302
+ // #3066 §1d (Fable r1 #2): a ClassicBot Codex room records its launches too (onDaemonReady) — read them the same way.
15303
+ ...((this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend) === "codex"
15304
+ ? { profileTimelinePath: join(this.getInstanceDir(ch.instanceName), PROFILE_TIMELINE_FILE) } : {}),
14978
15305
  }));
14979
15306
  return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
14980
15307
  }
@@ -15135,7 +15462,8 @@ export class FleetManager {
15135
15462
  out.push({
15136
15463
  name, state: p.state ?? undefined,
15137
15464
  interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
15138
- interactionSummary: p.interaction_summary ?? null,
15465
+ interactionSummary: p.interaction?.kind === "kiro_choice" ? t("kiro3.running") : p.interaction_summary ?? null,
15466
+ kiroCompatibility: this.kiroCompatibilityHolds?.get(name),
15139
15467
  pauseReason, pausedAt,
15140
15468
  crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
15141
15469
  signIn: pauseReason === "auth" ? this.signInFor(name) : null,
@@ -15925,8 +16253,9 @@ export class FleetManager {
15925
16253
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
15926
16254
  },
15927
16255
  signOut: (handle) => {
15928
- this.webSessions?.revokeByHandle(handle);
15929
- this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
16256
+ const session = this.webSessions?.revokeByHandle(handle) ?? { found: false, durable: true };
16257
+ const authorization = this.sessionAuthorizationStore()?.prune(a => a.handle !== handle) ?? { dropped: 0, durable: true };
16258
+ return { durable: session.durable, authorizationDurable: authorization.durable };
15930
16259
  },
15931
16260
  event: (type, payload) => { try {
15932
16261
  this.eventLog?.insert("web", type, payload);
@@ -15939,6 +16268,7 @@ export class FleetManager {
15939
16268
  expired: (c) => t("web_auth.card_expired", c.label),
15940
16269
  refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
15941
16270
  revoked: (c) => t("web_auth.card_revoked", c.label),
16271
+ notSaved: (c) => t("web_auth.card_not_saved", c.label),
15942
16272
  ended: (c) => t("web_auth.card_ended", c.label),
15943
16273
  generalHint: () => t("web_auth.general_hint"),
15944
16274
  confirmLabel: () => t("web_auth.confirm"),
@@ -17005,14 +17335,16 @@ export class FleetManager {
17005
17335
  * Make a tool summary safe to paste into a channel message.
17006
17336
  *
17007
17337
  * The text is agent-controlled (it is built from tool inputs — file paths,
17008
- * shell commands), so it gets flattened to one line, capped, and stripped of
17009
- * the two Discord mass-mention triggers. Neither channel renders it with a
17010
- * parse mode, so no markup escaping is needed beyond that.
17338
+ * shell commands), so it gets flattened to one line, secrets masked, capped,
17339
+ * and stripped of the two Discord mass-mention triggers. Masking comes BEFORE
17340
+ * the cap: a cap can cut a mask's context but must never be what hides (or
17341
+ * fails to hide) a secret. Neither channel renders it with a parse mode, so no
17342
+ * markup escaping is needed beyond that.
17011
17343
  */
17012
17344
  static sanitizeActivity(activity) {
17013
17345
  if (!activity)
17014
17346
  return null;
17015
- const flat = activity.replace(/\s+/g, " ").replace(/@(everyone|here)/g, "@​$1").trim();
17347
+ const flat = maskSecrets(activity.replace(/\s+/g, " ")).replace(/@(everyone|here)/g, "@​$1").trim();
17016
17348
  if (!flat)
17017
17349
  return null;
17018
17350
  return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
@@ -21608,6 +21940,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21608
21940
  clearInterval(this.discordPresenceTimer);
21609
21941
  this.discordPresenceTimer = null;
21610
21942
  }
21943
+ if (this.quotaReadTimer) {
21944
+ clearInterval(this.quotaReadTimer);
21945
+ this.quotaReadTimer = null;
21946
+ }
21947
+ if (this.capacityTimer) {
21948
+ clearInterval(this.capacityTimer);
21949
+ this.capacityTimer = null;
21950
+ }
21951
+ setCapacityLookup(null);
21611
21952
  if (this.discordPresenceEagerTimer) {
21612
21953
  clearTimeout(this.discordPresenceEagerTimer);
21613
21954
  this.discordPresenceEagerTimer = null;
@@ -21912,6 +22253,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21912
22253
  // mark every row done and tell the user "changes applied".
21913
22254
  return { rejected: why };
21914
22255
  }
22256
+ this.agentCredentialsCache?.observePolicies();
21915
22257
  if (this.loadedFleetSource)
21916
22258
  this.getConfigHistory().accepted("fleet.yaml", this.configPath, this.loadedFleetSource);
21917
22259
  this.migrateOfficeStorage();
@@ -22812,6 +23154,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22812
23154
  }
22813
23155
  async runConnectionBindingApply(job, binding, execution) {
22814
23156
  try {
23157
+ // #3055 review r2: a connection is not re-pointed while one of its topics is being deleted (nothing is changed).
23158
+ if (this.topicDeletionPending())
23159
+ throw new Error("a topic deletion is running; try again in a moment");
22815
23160
  await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
22816
23161
  job.result = "applied";
22817
23162
  }