@songsid/agend 2.1.0-beta.23 → 2.1.0-beta.25

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.
@@ -24,13 +24,13 @@ import { processAttachments } from "./channel/attachment-handler.js";
24
24
  import { routeToolCall } from "./channel/tool-router.js";
25
25
  import { Scheduler } from "./scheduler/index.js";
26
26
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
27
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
27
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
28
28
  import { DailySummary } from "./daily-summary.js";
29
29
  import { WebhookEmitter } from "./webhook-emitter.js";
30
30
  import { TmuxControlClient } from "./tmux-control.js";
31
31
  import { safeHandler } from "./safe-async.js";
32
32
  import { RoutingEngine } from "./routing-engine.js";
33
- import { InstanceLifecycle } from "./instance-lifecycle.js";
33
+ import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
34
34
  import { TopicArchiver } from "./topic-archiver.js";
35
35
  import { StatuslineWatcher } from "./statusline-watcher.js";
36
36
  import { outboundHandlers } from "./outbound-handlers.js";
@@ -40,6 +40,7 @@ import { handleSettingsRequest } from "./settings-api.js";
40
40
  import { setLocale, detectLocale, t } from "./locale.js";
41
41
  import { handleAgentRequest } from "./agent-endpoint.js";
42
42
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend } from "./classic-channel-manager.js";
43
+ import { readLastInboundAt } from "./daemon.js";
43
44
  import { getTmuxSession } from "./config.js";
44
45
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
45
46
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -251,6 +252,41 @@ export class FleetManager {
251
252
  return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
252
253
  ?? this.routing.resolve(channelId)?.name;
253
254
  }
255
+ async handlePauseWakeSlash(data, adapterId) {
256
+ const action = data.command;
257
+ const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
258
+ if (classicName) {
259
+ if (!this.classicChannels?.isAdmin(data.userId)) {
260
+ await data.respond(t("permission.denied"));
261
+ return;
262
+ }
263
+ await data.respond(await this.topicCommands.runPauseWake(classicName, action));
264
+ return;
265
+ }
266
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
267
+ await data.respond(t("permission.denied"));
268
+ return;
269
+ }
270
+ const route = this.routing.resolve(data.channelId);
271
+ if (!route) {
272
+ await data.respond(t("classic.no_agent"));
273
+ return;
274
+ }
275
+ let target = route.name;
276
+ if (route.kind === "general") {
277
+ const requested = typeof data.options?.instance === "string" ? data.options.instance : undefined;
278
+ if (!requested) {
279
+ await data.respond(t(`${action}.usage`));
280
+ return;
281
+ }
282
+ if (!this.fleetConfig?.instances[requested]) {
283
+ await data.respond(t("instance.not_found", requested));
284
+ return;
285
+ }
286
+ target = requested;
287
+ }
288
+ await data.respond(await this.topicCommands.runPauseWake(target, action));
289
+ }
254
290
  /** Get the adapter bound to an instance, falling back to primary adapter */
255
291
  getAdapterForInstance(name) {
256
292
  const worldId = this.instanceWorldBinding.get(name);
@@ -345,6 +381,19 @@ export class FleetManager {
345
381
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
346
382
  ipc.send(payload);
347
383
  }
384
+ /** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. */
385
+ isFleetAdmin(userId, adapterId) {
386
+ const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
387
+ return allowed.some(entry => String(entry) === String(userId));
388
+ }
389
+ async changeInstancePauseState(name, action) {
390
+ if (action === "wake") {
391
+ await this.lifecycle.wake(name, 30_000);
392
+ return "awake";
393
+ }
394
+ await this.lifecycle.pause(name);
395
+ return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
396
+ }
348
397
  async startInstance(name, config, topicMode) {
349
398
  if (config.general_topic) {
350
399
  this.ensureGeneralInstructions(config.working_directory, config.backend);
@@ -1038,19 +1087,15 @@ export class FleetManager {
1038
1087
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
1039
1088
  this.adapter.on("slash_command", safeHandler(async (data) => {
1040
1089
  if (data.command === "start") {
1041
- const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
1042
- if (requestedBackend) {
1043
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
1044
- await data.respond(reply);
1045
- }
1046
- else {
1047
- await this.beginClassicBackendSelection(data, this.adapter);
1048
- }
1090
+ await this.handleClassicStartSlash(data, adapterId, this.adapter);
1049
1091
  }
1050
1092
  else if (data.command === "stop") {
1051
1093
  const reply = await this.handleClassicStop(data.channelId, adapterId);
1052
1094
  await data.respond(reply);
1053
1095
  }
1096
+ else if (data.command === "pause" || data.command === "wake") {
1097
+ await this.handlePauseWakeSlash(data, adapterId);
1098
+ }
1054
1099
  else if (data.command === "chat") {
1055
1100
  const text = data.text ?? "";
1056
1101
  if (!text) {
@@ -1325,19 +1370,15 @@ export class FleetManager {
1325
1370
  // Slash commands: classic bot + admin commands
1326
1371
  adapter.on("slash_command", safeHandler(async (data) => {
1327
1372
  if (data.command === "start") {
1328
- const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
1329
- if (requestedBackend) {
1330
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
1331
- await data.respond(reply);
1332
- }
1333
- else {
1334
- await this.beginClassicBackendSelection(data, adapter);
1335
- }
1373
+ await this.handleClassicStartSlash(data, adapterId, adapter);
1336
1374
  }
1337
1375
  else if (data.command === "stop") {
1338
1376
  const reply = await this.handleClassicStop(data.channelId, adapterId);
1339
1377
  await data.respond(reply);
1340
1378
  }
1379
+ else if (data.command === "pause" || data.command === "wake") {
1380
+ await this.handlePauseWakeSlash(data, adapterId);
1381
+ }
1341
1382
  else if (data.command === "chat") {
1342
1383
  const text = data.text ?? "";
1343
1384
  if (!text) {
@@ -1897,6 +1938,20 @@ export class FleetManager {
1897
1938
  await msgAdapter?.sendText(chatId, reply);
1898
1939
  return;
1899
1940
  }
1941
+ const pauseWake = parsePauseWakeCommand(text);
1942
+ if (pauseWake) {
1943
+ if (!this.classicChannels.isAdmin(msg.userId)) {
1944
+ await msgAdapter?.sendText(chatId, t("permission.denied"));
1945
+ return;
1946
+ }
1947
+ const name = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1948
+ if (!name) {
1949
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1950
+ return;
1951
+ }
1952
+ await msgAdapter?.sendText(chatId, await this.topicCommands.runPauseWake(name, pauseWake.action));
1953
+ return;
1954
+ }
1900
1955
  // Handle /compact command (admin only)
1901
1956
  if (text === "/compact" || text.startsWith("/compact@")) {
1902
1957
  if (!this.classicChannels.isAdmin(msg.userId)) {
@@ -3829,6 +3884,33 @@ When users create specialized instances, suggest these configurations:
3829
3884
  return t("classic.topic_bound");
3830
3885
  return undefined;
3831
3886
  }
3887
+ isBackendInstalled(backend) {
3888
+ const installation = BACKEND_INSTALLATION_INFO[backend];
3889
+ return !!installation && checkBinaryInstalled(installation.binary);
3890
+ }
3891
+ getMissingBackendWarning(backend) {
3892
+ if (!backend)
3893
+ return undefined;
3894
+ const installation = BACKEND_INSTALLATION_INFO[backend];
3895
+ if (!installation || this.isBackendInstalled(backend))
3896
+ return undefined;
3897
+ return t("classic.backend_not_installed", backend, installation.binary, installation.install);
3898
+ }
3899
+ /** Handle Discord's optional static slash choice, warning before a likely startup failure. */
3900
+ async handleClassicStartSlash(data, adapterId, adapter) {
3901
+ const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
3902
+ if (!requestedBackend) {
3903
+ await this.beginClassicBackendSelection(data, adapter);
3904
+ return;
3905
+ }
3906
+ const warning = this.getMissingBackendWarning(requestedBackend);
3907
+ // Keep the deferred ephemeral response useful even if daemon startup later
3908
+ // fails because the executable is absent. This is advisory, not a gate.
3909
+ if (warning)
3910
+ await data.respond(warning);
3911
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
3912
+ await data.respond(warning ? `${warning}\n\n${reply}` : reply);
3913
+ }
3832
3914
  /** Present platform-native backend choices, then start on selection or timeout. */
3833
3915
  async beginClassicBackendSelection(data, adapter) {
3834
3916
  const adapterId = adapter.id;
@@ -3840,7 +3922,7 @@ When users create specialized instances, suggest these configurations:
3840
3922
  const nonce = randomBytes(6).toString("hex");
3841
3923
  const choices = getClassicBackendChoices().map(choice => ({
3842
3924
  id: `${CLASSIC_BACKEND_CALLBACK_PREFIX}${nonce}:${choice.id}`,
3843
- label: choice.label,
3925
+ label: `${this.isBackendInstalled(choice.id) ? "✅" : "❌"} ${choice.label}`,
3844
3926
  }));
3845
3927
  const complete = data.respondChoices
3846
3928
  ? async (text) => { await data.respond(text); }
@@ -3908,8 +3990,17 @@ When users create specialized instances, suggest these configurations:
3908
3990
  this.pendingClassicStarts.delete(nonce);
3909
3991
  clearTimeout(pending.timer);
3910
3992
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
3993
+ const effectiveBackend = selectedBackend
3994
+ ?? this.classicChannels?.getDefaults().backend
3995
+ ?? this.fleetConfig?.defaults?.backend
3996
+ ?? "claude-code";
3997
+ const warning = this.getMissingBackendWarning(effectiveBackend);
3998
+ // Show the warning before starting so it survives a missing-binary startup
3999
+ // failure. The selected backend is still attempted as requested.
4000
+ if (warning)
4001
+ await pending.complete(warning, pending.messageId);
3911
4002
  const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
3912
- await pending.complete(reply, pending.messageId);
4003
+ await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
3913
4004
  }
3914
4005
  /** Start a classic channel instance with lightweight config */
3915
4006
  async startClassicInstance(instanceName, backend, preTaskCommand, model) {
@@ -4533,8 +4624,27 @@ When users create specialized instances, suggest these configurations:
4533
4624
  // Fleet API (enriched for agent board)
4534
4625
  if (req.method === "GET" && req.url === "/api/fleet") {
4535
4626
  const sysInfo = this.getSysInfo();
4536
- const enriched = sysInfo.instances.map(inst => {
4627
+ const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
4628
+ const fleetNames = new Set(fleetInstances.map(inst => inst.name));
4629
+ const classicInstances = (this.classicChannels?.getAll() ?? [])
4630
+ .filter(channel => !fleetNames.has(channel.instanceName))
4631
+ .map(channel => ({
4632
+ name: channel.instanceName,
4633
+ status: this.getInstanceStatus(channel.instanceName),
4634
+ state: this.getInstanceExecutionState(channel.instanceName),
4635
+ ipc: this.instanceIpcClients.has(channel.instanceName),
4636
+ costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
4637
+ rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
4638
+ classic: true,
4639
+ classicName: channel.name,
4640
+ channelId: channel.channelId,
4641
+ adapterId: channel.adapterId ?? null,
4642
+ }));
4643
+ const enriched = [...fleetInstances, ...classicInstances].map(inst => {
4537
4644
  const config = this.fleetConfig?.instances[inst.name];
4645
+ const backend = inst.classic
4646
+ ? this.classicChannels?.getBackendByInstance(inst.name, this.fleetConfig?.defaults.backend) ?? "claude-code"
4647
+ : config?.backend ?? "claude-code";
4538
4648
  // Find claimed tasks for this instance
4539
4649
  let currentTask = null;
4540
4650
  try {
@@ -4547,11 +4657,13 @@ When users create specialized instances, suggest these configurations:
4547
4657
  }
4548
4658
  return {
4549
4659
  ...inst,
4550
- description: config?.description ?? null,
4551
- backend: config?.backend ?? "claude-code",
4660
+ description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
4661
+ backend,
4552
4662
  tool_set: config?.tool_set ?? "full",
4553
4663
  general_topic: config?.general_topic ?? false,
4554
- lastActivity: this.lastActivityMs(inst.name) || null,
4664
+ // User activity is persisted by the daemon, so both the board and
4665
+ // auto-pause retain an accurate age across fleet restarts.
4666
+ lastActivity: (readLastInboundAt(this.getInstanceDir(inst.name)) ?? this.lastActivityMs(inst.name)) || null,
4555
4667
  currentTask,
4556
4668
  idle: this.getInstanceIdle(inst.name),
4557
4669
  state: this.getInstanceExecutionState(inst.name),