@songsid/agend 2.1.4-beta.14 → 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;
@@ -289,6 +295,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
289
295
  */
290
296
  private enforceWarmCap;
291
297
  private waitForInstanceIdle;
298
+ /**
299
+ * Ask the daemon to capture the pane before answering, then wait for any state
300
+ * report produced after this request. The ordinary query is intentionally
301
+ * cache-only (idle gates call it frequently); this opt-in refresh is reserved
302
+ * for lifecycle decisions where a stale state would strand UI.
303
+ */
304
+ private refreshInstanceExecutionState;
292
305
  private deliverWithIdleGate;
293
306
  /**
294
307
  * Hand a payload to an instance's IPC, waiting out a *transient* disconnect.
@@ -712,10 +725,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
712
725
  private startProgressTicker;
713
726
  /**
714
727
  * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
715
- * it has not, retire its button. Re-arming replaces the previous timer, so a
716
- * burst of replies ends with exactly one pending check.
728
+ * it has not, retire its button. The daemon is asked for a fresh pane capture
729
+ * before deciding. Reading only the transition cache stranded the first
730
+ * post-restart bubble when its startup "working" report never got a matching
731
+ * idle edge. Re-arming replaces the previous timer, so a burst of replies ends
732
+ * with exactly one pending check.
717
733
  */
718
734
  private armReplyGrace;
735
+ private finishReplyGrace;
719
736
  /** Retire (delete) every cancel button belonging to an instance. */
720
737
  private retireInstanceButtons;
721
738
  /** Begin retiring one button (delete + bounded retry on failure). Idempotent:
@@ -999,7 +1016,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
999
1016
  /**
1000
1017
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
1001
1018
  * Starts new, stops removed, restarts modified instances.
1002
- * 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.
1003
1021
  */
1004
1022
  private reconcileInstances;
1005
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";
@@ -107,6 +108,8 @@ const CANCEL_BTN_IDLE_RETIRE_GRACE_MS = 2_000;
107
108
  * button alone (the idle edge retires it when the run really ends).
108
109
  */
109
110
  const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
111
+ /** Bound for the daemon to capture the pane and answer a post-reply state query. */
112
+ const REPLY_STATE_REFRESH_TIMEOUT_MS = 2_000;
110
113
  /**
111
114
  * The daemon only broadcasts execution state on TRANSITIONS, so a long
112
115
  * single-state run sends nothing for hours. The idle backstop therefore pokes a
@@ -163,6 +166,30 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
163
166
  * emoji never changes the documented delivery-state protocol.
164
167
  */
165
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
+ }
166
193
  /**
167
194
  * How long a delivery waits out a disconnected instance IPC before giving up.
168
195
  *
@@ -748,6 +775,30 @@ export class FleetManager {
748
775
  return null;
749
776
  return this.instanceStateCache.get(name)?.state ?? null;
750
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
+ }
751
802
  isClassicInstance(name) {
752
803
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
753
804
  }
@@ -899,6 +950,48 @@ export class FleetManager {
899
950
  query();
900
951
  });
901
952
  }
953
+ /**
954
+ * Ask the daemon to capture the pane before answering, then wait for any state
955
+ * report produced after this request. The ordinary query is intentionally
956
+ * cache-only (idle gates call it frequently); this opt-in refresh is reserved
957
+ * for lifecycle decisions where a stale state would strand UI.
958
+ */
959
+ refreshInstanceExecutionState(instanceName, timeoutMs) {
960
+ const ipc = this.instanceIpcClients.get(instanceName);
961
+ if (!ipc?.connected)
962
+ return Promise.resolve(false);
963
+ const previous = this.instanceStateCache.get(instanceName);
964
+ return new Promise(resolve => {
965
+ let settled = false;
966
+ const finish = (refreshed) => {
967
+ if (settled)
968
+ return;
969
+ settled = true;
970
+ clearTimeout(timeout);
971
+ const waiters = this.instanceIdleWaiters.get(instanceName);
972
+ waiters?.delete(check);
973
+ if (waiters?.size === 0)
974
+ this.instanceIdleWaiters.delete(instanceName);
975
+ resolve(refreshed);
976
+ };
977
+ const check = () => {
978
+ if (this.instanceStateCache.get(instanceName) !== previous)
979
+ finish(true);
980
+ };
981
+ const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
982
+ waiters.add(check);
983
+ this.instanceIdleWaiters.set(instanceName, waiters);
984
+ const timeout = setTimeout(() => finish(false), timeoutMs);
985
+ timeout.unref?.();
986
+ const sent = ipc.send({
987
+ type: "query_instance_state",
988
+ requestId: `reply-grace-${Date.now()}`,
989
+ refresh: true,
990
+ });
991
+ if (!sent)
992
+ finish(false);
993
+ });
994
+ }
902
995
  async deliverWithIdleGate(instanceName, payload, timeoutMs) {
903
996
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
904
997
  if (this.lifecycle.isPaused(instanceName)) {
@@ -2334,7 +2427,7 @@ export class FleetManager {
2334
2427
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2335
2428
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2336
2429
  // slash_command is Discord-only; editReply renders Markdown natively.
2337
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2430
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2338
2431
  }
2339
2432
  catch (err) {
2340
2433
  await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
@@ -2636,7 +2729,7 @@ export class FleetManager {
2636
2729
  const { getUsageSnapshot } = await import("./usage/usage-api.js");
2637
2730
  const { renderUsageMarkdown } = await import("./usage/format-rich.js");
2638
2731
  // slash_command is Discord-only; editReply renders Markdown natively.
2639
- await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
2732
+ await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2640
2733
  }
2641
2734
  catch (err) {
2642
2735
  await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
@@ -5424,8 +5517,11 @@ export class FleetManager {
5424
5517
  }
5425
5518
  /**
5426
5519
  * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
5427
- * it has not, retire its button. Re-arming replaces the previous timer, so a
5428
- * burst of replies ends with exactly one pending check.
5520
+ * it has not, retire its button. The daemon is asked for a fresh pane capture
5521
+ * before deciding. Reading only the transition cache stranded the first
5522
+ * post-restart bubble when its startup "working" report never got a matching
5523
+ * idle edge. Re-arming replaces the previous timer, so a burst of replies ends
5524
+ * with exactly one pending check.
5429
5525
  */
5430
5526
  armReplyGrace(instanceName) {
5431
5527
  for (const entry of this.cancelButtons.values()) {
@@ -5437,14 +5533,26 @@ export class FleetManager {
5437
5533
  entry.replyGraceTimer = undefined;
5438
5534
  if (!this.cancelButtons.has(entry.messageId))
5439
5535
  return;
5440
- if (!this.getInstanceIdle(instanceName))
5441
- return; // resumed — a long run keeps its button
5442
- this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
5443
- this.retireButton(entry);
5536
+ void this.finishReplyGrace(instanceName, entry);
5444
5537
  }, REPLY_RETIRE_GRACE_MS);
5445
5538
  entry.replyGraceTimer.unref?.();
5446
5539
  }
5447
5540
  }
5541
+ async finishReplyGrace(instanceName, entry) {
5542
+ const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
5543
+ if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
5544
+ return;
5545
+ if (!refreshed) {
5546
+ // Fail safe: without an authoritative answer, retain a potentially live
5547
+ // Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
5548
+ this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
5549
+ return;
5550
+ }
5551
+ if (!this.getInstanceIdle(instanceName))
5552
+ return; // genuine long run keeps its button
5553
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
5554
+ this.retireButton(entry);
5555
+ }
5448
5556
  /** Retire (delete) every cancel button belonging to an instance. */
5449
5557
  retireInstanceButtons(instanceName) {
5450
5558
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -7580,7 +7688,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7580
7688
  /**
7581
7689
  * Hot-reload: re-read fleet.yaml and reconcile running instances.
7582
7690
  * Starts new, stops removed, restarts modified instances.
7583
- * 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.
7584
7693
  */
7585
7694
  async reconcileInstances() {
7586
7695
  if (!this.configPath)
@@ -7634,9 +7743,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7634
7743
  this.scheduler?.reload();
7635
7744
  const newInstances = this.fleetConfig.instances;
7636
7745
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
7637
- // Detect fleet-level config changes and warn
7638
- const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldConfig?.defaults });
7639
- 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 });
7640
7756
  if (oldFleetLevel !== newFleetLevel) {
7641
7757
  this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
7642
7758
  }
@@ -7648,7 +7764,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7648
7764
  await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
7649
7765
  }
7650
7766
  }
7651
- // 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.
7652
7770
  for (const [name, config] of Object.entries(newInstances)) {
7653
7771
  if (!this.daemons.has(name)) {
7654
7772
  // New instance — startInstance already calls connectIpcToInstance
@@ -7656,14 +7774,33 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7656
7774
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
7657
7775
  }
7658
7776
  else if (oldConfig?.instances[name]) {
7659
- // Restart if any config field changed
7660
- 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)) {
7661
7783
  this.logger.info({ name }, "Instance config changed — restarting");
7662
7784
  await this.stopInstance(name).catch(() => { });
7663
7785
  await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
7664
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
+ }
7665
7799
  }
7666
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();
7667
7804
  this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
7668
7805
  }
7669
7806
  async restartInstances() {