@songsid/agend 2.1.4-beta.15 → 2.1.4-beta.16

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.
@@ -54,6 +54,8 @@ export interface FleetContext {
54
54
  readonly classicChannels: ClassicChannelManager | null;
55
55
  getSysInfo(): SysInfo;
56
56
  getInstanceStatus(name: string): "running" | "paused" | "stopped" | "crashed";
57
+ /** Subscription provider IDs used by running or paused fleet/Classic instances. */
58
+ getActiveUsageProviderIds?(): ReadonlySet<string>;
57
59
  toggleFleetCollab(instanceName: string): boolean;
58
60
  /** Apply a model to an instance (runtime paste or persist + restart). Returns a status string. */
59
61
  applyModel(instanceName: string, model: string): Promise<string>;
@@ -273,6 +273,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
273
273
  bindInstanceAdapter(name: string, adapterId: string, fromInbound?: boolean): void;
274
274
  getInstanceStatus(name: string): "running" | "paused" | "stopped" | "crashed";
275
275
  getInstanceExecutionState(name: string): InstanceState | null;
276
+ /**
277
+ * Subscription providers currently relevant to this fleet. Stopped/crashed
278
+ * rows do not keep a usage card alive, while persisted paused instances do.
279
+ * Classic channels use their effective backend merge chain, not the raw row.
280
+ */
281
+ getActiveUsageProviderIds(): ReadonlySet<string>;
276
282
  isClassicInstance(name: string): boolean;
277
283
  private cacheInstanceExecutionState;
278
284
  private cancelIdleButtonRetirement;
@@ -1010,7 +1016,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1010
1016
  /**
1011
1017
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
1012
1018
  * Starts new, stops removed, restarts modified instances.
1013
- * Fleet-level config (access, cost_guard, etc.) requires /restart to take effect.
1019
+ * Whitelisted runtime fields are pushed into live daemons; all other instance
1020
+ * fields, plus cold fleet-level settings, retain restart semantics.
1014
1021
  */
1015
1022
  private reconcileInstances;
1016
1023
  restartInstances(): Promise<void>;
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
4
  import { join, dirname, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { isDeepStrictEqual } from "node:util";
6
7
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
7
8
  import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
8
9
  import { formatUpdateProgress } from "./update-progress.js";
@@ -42,7 +43,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
42
43
  import { outboundHandlers } from "./outbound-handlers.js";
43
44
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
44
45
  import { handleViewRequest, isViewPath } from "./view-api.js";
45
- import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
46
+ import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
46
47
  import { handleSettingsRequest } from "./settings-api.js";
47
48
  import { setLocale, detectLocale, t } from "./locale.js";
48
49
  import { handleAgentRequest } from "./agent-endpoint.js";
@@ -165,6 +166,30 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
165
166
  * emoji never changes the documented delivery-state protocol.
166
167
  */
167
168
  const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
169
+ const HOT_INSTANCE_CONFIG_KEYS = new Set([
170
+ "tool_progress",
171
+ "mcp_proxy_reply",
172
+ "auto_pause_after",
173
+ "warm_cap",
174
+ "display_name",
175
+ "description",
176
+ "tags",
177
+ "log_level",
178
+ ]);
179
+ function splitHotColdConfig(config) {
180
+ const hot = {};
181
+ const cold = {};
182
+ for (const [key, value] of Object.entries(config)) {
183
+ (HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
184
+ }
185
+ return { hot, cold };
186
+ }
187
+ function hotConfigUpdate(config) {
188
+ const update = {};
189
+ for (const key of HOT_INSTANCE_CONFIG_KEYS)
190
+ update[key] = config[key] ?? null;
191
+ return update;
192
+ }
168
193
  /**
169
194
  * How long a delivery waits out a disconnected instance IPC before giving up.
170
195
  *
@@ -750,6 +775,30 @@ export class FleetManager {
750
775
  return null;
751
776
  return this.instanceStateCache.get(name)?.state ?? null;
752
777
  }
778
+ /**
779
+ * Subscription providers currently relevant to this fleet. Stopped/crashed
780
+ * rows do not keep a usage card alive, while persisted paused instances do.
781
+ * Classic channels use their effective backend merge chain, not the raw row.
782
+ */
783
+ getActiveUsageProviderIds() {
784
+ const providers = new Set();
785
+ const add = (name, backend) => {
786
+ const status = this.getInstanceStatus(name);
787
+ if (status !== "running" && status !== "paused")
788
+ return;
789
+ const provider = usageProviderIdForBackend(backend);
790
+ if (provider)
791
+ providers.add(provider);
792
+ };
793
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
794
+ // loadFleetConfig() has already merged the fleet default into each row.
795
+ add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
796
+ }
797
+ for (const channel of this.classicChannels?.getAll() ?? []) {
798
+ add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
799
+ }
800
+ return providers;
801
+ }
753
802
  isClassicInstance(name) {
754
803
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
755
804
  }
@@ -2378,7 +2427,7 @@ export class FleetManager {
2378
2427
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2379
2428
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2380
2429
  // slash_command is Discord-only; editReply renders Markdown natively.
2381
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2430
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2382
2431
  }
2383
2432
  catch (err) {
2384
2433
  await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
@@ -2680,7 +2729,7 @@ export class FleetManager {
2680
2729
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2681
2730
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2682
2731
  // slash_command is Discord-only; editReply renders Markdown natively.
2683
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2732
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2684
2733
  }
2685
2734
  catch (err) {
2686
2735
  await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
@@ -7639,7 +7688,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7639
7688
  /**
7640
7689
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
7641
7690
  * Starts new, stops removed, restarts modified instances.
7642
- * Fleet-level config (access, cost_guard, etc.) requires /restart to take effect.
7691
+ * Whitelisted runtime fields are pushed into live daemons; all other instance
7692
+ * fields, plus cold fleet-level settings, retain restart semantics.
7643
7693
  */
7644
7694
  async reconcileInstances() {
7645
7695
  if (!this.configPath)
@@ -7693,9 +7743,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7693
7743
  this.scheduler?.reload();
7694
7744
  const newInstances = this.fleetConfig.instances;
7695
7745
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
7696
- // Detect fleet-level config changes and warn
7697
- const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldConfig?.defaults });
7698
- const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: this.fleetConfig?.defaults });
7746
+ // Detect fleet-level changes which still need a restart. Hot defaults are
7747
+ // reconciled below and must not produce a misleading restart warning.
7748
+ const oldDefaultCold = oldConfig?.defaults
7749
+ ? splitHotColdConfig(oldConfig.defaults).cold
7750
+ : {};
7751
+ const newDefaultCold = this.fleetConfig?.defaults
7752
+ ? splitHotColdConfig(this.fleetConfig.defaults).cold
7753
+ : {};
7754
+ const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
7755
+ const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
7699
7756
  if (oldFleetLevel !== newFleetLevel) {
7700
7757
  this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
7701
7758
  }
@@ -7707,7 +7764,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7707
7764
  await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
7708
7765
  }
7709
7766
  }
7710
- // Start new + restart modified instances
7767
+ // Start new + reconcile modified instances. Hot values are always sent as a
7768
+ // complete snapshot: Settings mutates FleetManager's config before SIGHUP,
7769
+ // so an old/new diff alone can miss the live daemon's stale value.
7711
7770
  for (const [name, config] of Object.entries(newInstances)) {
7712
7771
  if (!this.daemons.has(name)) {
7713
7772
  // New instance — startInstance already calls connectIpcToInstance
@@ -7715,14 +7774,33 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7715
7774
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
7716
7775
  }
7717
7776
  else if (oldConfig?.instances[name]) {
7718
- // Restart if any config field changed
7719
- if (JSON.stringify(oldConfig.instances[name]) !== JSON.stringify(config)) {
7777
+ const daemon = this.daemons.get(name);
7778
+ const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
7779
+ const oldParts = splitHotColdConfig(runtimeConfig);
7780
+ const newParts = splitHotColdConfig(config);
7781
+ // Every field not explicitly classified hot is cold by default.
7782
+ if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
7720
7783
  this.logger.info({ name }, "Instance config changed — restarting");
7721
7784
  await this.stopInstance(name).catch(() => { });
7722
7785
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
7723
7786
  }
7787
+ else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
7788
+ const update = hotConfigUpdate(config);
7789
+ const ipc = this.instanceIpcClients.get(name);
7790
+ const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
7791
+ if (!sent) {
7792
+ // Daemon is in-process, so a reconnect gap must not leave runtime
7793
+ // state stale. Normal operation still uses the explicit IPC contract.
7794
+ daemon.applyConfigUpdate(update);
7795
+ this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
7796
+ }
7797
+ this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
7798
+ }
7724
7799
  }
7725
7800
  }
7801
+ // warm_cap is fleet-owned; enforce the reloaded value immediately against
7802
+ // currently idle instances instead of waiting for a future state edge.
7803
+ this.enforceWarmCap();
7726
7804
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
7727
7805
  }
7728
7806
  async restartInstances() {