@songsid/agend 2.1.0-beta.31 → 2.1.0-beta.33

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
@@ -4015,13 +4072,25 @@ When users create specialized instances, suggest these configurations:
4015
4072
  const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
4016
4073
  mkdirSync(inboxDir, { recursive: true });
4017
4074
  const dest = join(inboxDir, basename(tmpPath));
4075
+ // Copy to destination — failure means this attachment is skipped
4018
4076
  try {
4019
- renameSync(tmpPath, dest);
4020
- }
4021
- catch {
4022
4077
  copyFileSync(tmpPath, dest);
4078
+ }
4079
+ catch (copyErr) {
4080
+ try {
4081
+ unlinkSync(dest);
4082
+ }
4083
+ catch { } // clean partial
4084
+ this.logger.warn({ err: copyErr.message, instanceName, dest }, "Attachment copy failed — skipping");
4085
+ continue;
4086
+ }
4087
+ // Cleanup source — failure is non-fatal (dest already valid)
4088
+ try {
4023
4089
  unlinkSync(tmpPath);
4024
4090
  }
4091
+ catch (cleanupErr) {
4092
+ this.logger.debug({ tmpPath, err: cleanupErr.message }, "Orphan tmp not cleaned");
4093
+ }
4025
4094
  const savedKind = att.kind === "sticker" ? "photo" : att.kind;
4026
4095
  paths.push(dest);
4027
4096
  if (paths.length === 1)
@@ -4139,11 +4208,16 @@ When users create specialized instances, suggest these configurations:
4139
4208
  return undefined;
4140
4209
  return t("classic.backend_not_installed", backend, installation.binary, installation.install);
4141
4210
  }
4142
- /** Handle Discord's optional static slash choice, warning before a likely startup failure. */
4143
- async handleClassicStartSlash(data, adapterId, adapter) {
4211
+ /** Handle Discord's required static slash choice, warning before a likely startup failure. */
4212
+ async handleClassicStartSlash(data, adapterId) {
4144
4213
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
4145
4214
  if (!requestedBackend) {
4146
- await this.beginClassicBackendSelection(data, adapter);
4215
+ // beta.31 made this option required. Discord can briefly retain the old
4216
+ // command schema client-side, however, so stale clients may still submit
4217
+ // `/start` without it. Do not resurrect the legacy 60-second component
4218
+ // menu in that case: fail immediately and make the user invoke the newly
4219
+ // registered command, which guarantees an explicit backend choice.
4220
+ await data.respond(t("classic.backend_required"));
4147
4221
  return;
4148
4222
  }
4149
4223
  const warning = this.getMissingBackendWarning(requestedBackend);