@songsid/agend 2.1.0-beta.30 → 2.1.0-beta.32

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.
@@ -122,6 +122,8 @@ export class FleetManager {
122
122
  lastActivity = new Map();
123
123
  /** Latest pane-derived execution snapshot reported by each daemon. */
124
124
  instanceStateCache = new Map();
125
+ /** CLI pane status overrides; daemon.pid alone only proves FleetManager lives. */
126
+ instanceProcessStatus = new Map();
125
127
  /** Instances currently being auto-paused by warm_cap, so concurrent checks don't double-evict. */
126
128
  warmCapEvicting = new Set();
127
129
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
@@ -369,6 +371,9 @@ export class FleetManager {
369
371
  getInstanceStatus(name) {
370
372
  if (this.lifecycle.isPaused(name))
371
373
  return "paused";
374
+ const processStatus = this.instanceProcessStatus.get(name);
375
+ if (processStatus)
376
+ return processStatus;
372
377
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
373
378
  if (!existsSync(pidPath))
374
379
  return "stopped";
@@ -410,6 +415,20 @@ export class FleetManager {
410
415
  if (state === "idle" && previous?.state !== "idle")
411
416
  this.enforceWarmCap();
412
417
  }
418
+ cacheInstanceProcessStatus(name, status) {
419
+ if (status === "running") {
420
+ this.instanceProcessStatus.delete(name);
421
+ return;
422
+ }
423
+ if (status !== "crashed" && status !== "stopped")
424
+ return;
425
+ this.instanceProcessStatus.set(name, status);
426
+ // Never display the last ready prompt as current execution state after its
427
+ // owning CLI process has exited.
428
+ this.instanceStateCache.delete(name);
429
+ for (const check of this.instanceIdleWaiters.get(name) ?? [])
430
+ check();
431
+ }
413
432
  /**
414
433
  * Fleet-wide warm cap: if more than `defaults.warm_cap` instances are running,
415
434
  * auto-pause the least-recently-active idle instances until back at the cap.
@@ -586,6 +605,7 @@ export class FleetManager {
586
605
  }
587
606
  this.ensureGeneralInstructions(config.working_directory, config.backend);
588
607
  }
608
+ this.instanceProcessStatus.delete(name);
589
609
  await this.lifecycle.start(name, config, topicMode);
590
610
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
591
611
  await this.connectIpcToInstance(name);
@@ -688,22 +708,53 @@ export class FleetManager {
688
708
  async stopInstance(name) {
689
709
  this.failoverActive.delete(name);
690
710
  this.instanceStateCache.delete(name);
711
+ this.instanceProcessStatus.delete(name);
691
712
  this.lastDeliveryAt.delete(name);
692
713
  return this.lifecycle.stop(name);
693
714
  }
694
715
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
695
- async restartSingleInstance(name) {
716
+ async restartSingleInstance(name, opts) {
696
717
  if (this.configPath) {
697
718
  this.loadConfig(this.configPath);
698
719
  this.routing.rebuild(this.fleetConfig);
699
720
  this.reregisterClassicChannels();
700
721
  }
701
722
  const config = this.fleetConfig?.instances[name];
702
- if (!config)
703
- throw new Error(`Instance not found: ${name}`);
704
- await this.stopInstance(name);
705
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
706
- await this.startInstance(name, config, topicMode ?? false);
723
+ if (config) {
724
+ await this.stopInstance(name);
725
+ if (opts?.freshStart)
726
+ this.writeFreshStartMarker(name);
727
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
728
+ await this.startInstance(name, config, topicMode ?? false);
729
+ return;
730
+ }
731
+ // Classic instance fallback
732
+ const channelId = this.classicChannels?.getChannelIdByInstance(name);
733
+ if (channelId) {
734
+ const fleetBackend = this.fleetConfig?.defaults?.backend;
735
+ const adapterId = this.classicChannels.getAdapterIdByInstance(name);
736
+ await this.stopInstance(name);
737
+ await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
738
+ if (opts?.freshStart)
739
+ this.writeFreshStartMarker(name);
740
+ await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
741
+ return;
742
+ }
743
+ throw new Error(`Instance not found: ${name}`);
744
+ }
745
+ /**
746
+ * Mark an instance so its next daemon start skips session resume. Written AFTER
747
+ * stop (survives the old daemon's cleanup) and BEFORE start so the respawn reads
748
+ * it — reuses the crash-state → resumeDisabled path from crash-loop recovery.
749
+ * One-shot: the daemon deletes it on startup.
750
+ */
751
+ writeFreshStartMarker(name) {
752
+ try {
753
+ writeFileSync(join(this.getInstanceDir(name), "crash-state.json"), JSON.stringify({ resumeDisabled: true, reason: "pty_error_restart" }));
754
+ }
755
+ catch (err) {
756
+ this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
757
+ }
707
758
  }
708
759
  /** Load .env file from data dir into process.env */
709
760
  loadEnvFile() {
@@ -1328,7 +1379,7 @@ export class FleetManager {
1328
1379
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
1329
1380
  this.adapter.on("slash_command", safeHandler(async (data) => {
1330
1381
  if (data.command === "start") {
1331
- await this.handleClassicStartSlash(data, adapterId, this.adapter);
1382
+ await this.handleClassicStartSlash(data, adapterId);
1332
1383
  }
1333
1384
  else if (data.command === "stop") {
1334
1385
  const reply = await this.handleClassicStop(data.channelId, adapterId);
@@ -1611,7 +1662,7 @@ export class FleetManager {
1611
1662
  // Slash commands: classic bot + admin commands
1612
1663
  adapter.on("slash_command", safeHandler(async (data) => {
1613
1664
  if (data.command === "start") {
1614
- await this.handleClassicStartSlash(data, adapterId, adapter);
1665
+ await this.handleClassicStartSlash(data, adapterId);
1615
1666
  }
1616
1667
  else if (data.command === "stop") {
1617
1668
  const reply = await this.handleClassicStop(data.channelId, adapterId);
@@ -1876,8 +1927,14 @@ export class FleetManager {
1876
1927
  else if (msg.type === "fleet_set_description") {
1877
1928
  this.handleSetDescription(name, msg);
1878
1929
  }
1930
+ else if (msg.type === "instance_process_state") {
1931
+ this.cacheInstanceProcessStatus(name, msg.status);
1932
+ }
1879
1933
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
1880
1934
  this.cacheInstanceExecutionState(name, msg);
1935
+ if (msg.type === "instance_state_response") {
1936
+ this.cacheInstanceProcessStatus(name, msg.processStatus);
1937
+ }
1881
1938
  }
1882
1939
  }, this.logger, `ipc.message[${name}]`));
1883
1940
  // Ask daemon for any sessions that registered before we connected
@@ -4139,11 +4196,16 @@ When users create specialized instances, suggest these configurations:
4139
4196
  return undefined;
4140
4197
  return t("classic.backend_not_installed", backend, installation.binary, installation.install);
4141
4198
  }
4142
- /** Handle Discord's optional static slash choice, warning before a likely startup failure. */
4143
- async handleClassicStartSlash(data, adapterId, adapter) {
4199
+ /** Handle Discord's required static slash choice, warning before a likely startup failure. */
4200
+ async handleClassicStartSlash(data, adapterId) {
4144
4201
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
4145
4202
  if (!requestedBackend) {
4146
- await this.beginClassicBackendSelection(data, adapter);
4203
+ // beta.31 made this option required. Discord can briefly retain the old
4204
+ // command schema client-side, however, so stale clients may still submit
4205
+ // `/start` without it. Do not resurrect the legacy 60-second component
4206
+ // menu in that case: fail immediately and make the user invoke the newly
4207
+ // registered command, which guarantees an explicit backend choice.
4208
+ await data.respond(t("classic.backend_required"));
4147
4209
  return;
4148
4210
  }
4149
4211
  const warning = this.getMissingBackendWarning(requestedBackend);
@@ -4180,7 +4242,12 @@ When users create specialized instances, suggest these configurations:
4180
4242
  await data.respond(text);
4181
4243
  };
4182
4244
  const timer = setTimeout(() => {
4183
- void this.finishClassicBackendSelection(nonce).catch(err => this.logger.warn({ err, channelId: data.channelId, adapterId }, "Classic backend selection timeout fallback failed"));
4245
+ // Timeout: cancel the selection do NOT fall back to default.
4246
+ const p = this.pendingClassicStarts.get(nonce);
4247
+ if (p) {
4248
+ this.pendingClassicStarts.delete(nonce);
4249
+ p.complete(t("classic.selection_expired"), p.messageId).catch(() => { });
4250
+ }
4184
4251
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
4185
4252
  timer.unref?.();
4186
4253
  const pending = {