@songsid/agend 2.1.3 → 2.1.4-beta.2

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/README.zh-TW.md +2 -2
  4. package/dist/backend/antigravity.d.ts +1 -0
  5. package/dist/backend/antigravity.js +1 -0
  6. package/dist/backend/antigravity.js.map +1 -1
  7. package/dist/backend/claude-code.d.ts +1 -0
  8. package/dist/backend/claude-code.js +1 -0
  9. package/dist/backend/claude-code.js.map +1 -1
  10. package/dist/backend/codex.d.ts +1 -0
  11. package/dist/backend/codex.js +1 -0
  12. package/dist/backend/codex.js.map +1 -1
  13. package/dist/backend/gemini-cli.d.ts +1 -0
  14. package/dist/backend/gemini-cli.js +1 -0
  15. package/dist/backend/gemini-cli.js.map +1 -1
  16. package/dist/backend/grok.d.ts +1 -0
  17. package/dist/backend/grok.js +1 -0
  18. package/dist/backend/grok.js.map +1 -1
  19. package/dist/backend/kiro.d.ts +1 -0
  20. package/dist/backend/kiro.js +1 -0
  21. package/dist/backend/kiro.js.map +1 -1
  22. package/dist/backend/mock.d.ts +1 -0
  23. package/dist/backend/mock.js +1 -0
  24. package/dist/backend/mock.js.map +1 -1
  25. package/dist/backend/opencode.d.ts +24 -0
  26. package/dist/backend/opencode.js +72 -5
  27. package/dist/backend/opencode.js.map +1 -1
  28. package/dist/backend/types.d.ts +6 -0
  29. package/dist/backend/types.js.map +1 -1
  30. package/dist/channel/adapters/discord.js +5 -1
  31. package/dist/channel/adapters/discord.js.map +1 -1
  32. package/dist/channel/types.d.ts +1 -1
  33. package/dist/cli.js +9 -10
  34. package/dist/cli.js.map +1 -1
  35. package/dist/daemon.d.ts +0 -9
  36. package/dist/daemon.js +0 -53
  37. package/dist/daemon.js.map +1 -1
  38. package/dist/fleet-manager.d.ts +17 -2
  39. package/dist/fleet-manager.js +211 -32
  40. package/dist/fleet-manager.js.map +1 -1
  41. package/dist/instance-lifecycle.d.ts +2 -0
  42. package/dist/instance-lifecycle.js +4 -8
  43. package/dist/instance-lifecycle.js.map +1 -1
  44. package/dist/locale.js +30 -0
  45. package/dist/locale.js.map +1 -1
  46. package/dist/outbound-schemas.d.ts +1 -1
  47. package/dist/quickstart.js +4 -2
  48. package/dist/quickstart.js.map +1 -1
  49. package/dist/setup-wizard.d.ts +4 -2
  50. package/dist/setup-wizard.js +2 -2
  51. package/dist/setup-wizard.js.map +1 -1
  52. package/dist/topic-commands.d.ts +9 -0
  53. package/dist/topic-commands.js +49 -0
  54. package/dist/topic-commands.js.map +1 -1
  55. package/dist/ui/dashboard.html +5 -4
  56. package/dist/web-api.js +2 -2
  57. package/dist/web-api.js.map +1 -1
  58. package/package.json +2 -2
@@ -119,6 +119,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
119
119
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
120
120
  private pendingEffortSelects;
121
121
  private pendingModelSelects;
122
+ /** One-shot General assistance buttons for stable interactive terminal prompts. */
123
+ private pendingInteractivePromptAssists;
122
124
  private failoverActive;
123
125
  /** instance → when a click with no live button entry last fired a cancel. */
124
126
  private staleCancelClickAt;
@@ -216,6 +218,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
216
218
  */
217
219
  private isModelAdmin;
218
220
  private handlePauseWakeSlash;
221
+ /** Admin-only full conversation reset for fleet-topic and Classic instances. */
222
+ private handleClearSlash;
219
223
  /** Get the adapter bound to an instance, falling back to primary adapter */
220
224
  getAdapterForInstance(name: string): ChannelAdapter | null;
221
225
  /** Get the world for an instance */
@@ -426,8 +430,6 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
426
430
  private handleOutboundFromInstance;
427
431
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
428
432
  private afterReplyRouted;
429
- /** Handle tool status update from a daemon instance */
430
- private handleToolStatusFromInstance;
431
433
  private handleScheduleTrigger;
432
434
  private notifySourceTopic;
433
435
  private notifyScheduleFailure;
@@ -530,6 +532,19 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
530
532
  private static readonly FLEET_ERROR_THROTTLE_MS;
531
533
  private fleetErrorNotices;
532
534
  notifyInstanceTopic(instanceName: string, text: string, extraOpts?: import("./channel/types.js").SendOpts): void;
535
+ private interactivePromptLabel;
536
+ /**
537
+ * Surface a stable terminal prompt in both places that matter:
538
+ * - the blocked instance gets a plain pointer to General;
539
+ * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
540
+ *
541
+ * Only the prompt category crosses channels. The captured terminal line can
542
+ * contain usernames or secret-adjacent text and stays in daemon.log.
543
+ */
544
+ notifyInteractivePrompt(instanceName: string, kind: string): Promise<void>;
545
+ private retireInteractivePromptButtons;
546
+ /** Consume a General assist button exactly once. */
547
+ private handleInteractivePromptAssist;
533
548
  /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
534
549
  /**
535
550
  * Handle the DC `/save` slash command for both classic AND fleet-topic targets.
@@ -165,6 +165,8 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
165
165
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
166
166
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
167
167
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
168
+ const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
169
+ const INTERACTIVE_ASSIST_TIMEOUT_MS = 15 * 60_000;
168
170
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
169
171
  export class FleetManager {
170
172
  dataDir;
@@ -253,6 +255,8 @@ export class FleetManager {
253
255
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
254
256
  pendingEffortSelects = new Map();
255
257
  pendingModelSelects = new Map();
258
+ /** One-shot General assistance buttons for stable interactive terminal prompts. */
259
+ pendingInteractivePromptAssists = new Map();
256
260
  // Model failover state
257
261
  failoverActive = new Map(); // instance → current failover model
258
262
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -553,6 +557,19 @@ export class FleetManager {
553
557
  }
554
558
  await data.respond(await this.topicCommands.runPauseWake(target, action));
555
559
  }
560
+ /** Admin-only full conversation reset for fleet-topic and Classic instances. */
561
+ async handleClearSlash(data, adapterId) {
562
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
563
+ await data.respond(t("permission.denied"));
564
+ return;
565
+ }
566
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
567
+ if (!name) {
568
+ await data.respond(t("classic.no_agent"));
569
+ return;
570
+ }
571
+ await data.respond(await this.topicCommands.sendClear(name));
572
+ }
556
573
  /** Get the adapter bound to an instance, falling back to primary adapter */
557
574
  getAdapterForInstance(name) {
558
575
  const worldId = this.getInstanceAdapterId(name);
@@ -1980,6 +1997,8 @@ export class FleetManager {
1980
1997
  await this.handleInboundReaction(r);
1981
1998
  }, this.logger, "adapter.reaction"));
1982
1999
  this.adapter.on("callback_query", safeHandler(async (data) => {
2000
+ if (await this.handleInteractivePromptAssist(data, adapterId))
2001
+ return;
1983
2002
  if (await this.handleClassicBackendSelection(data))
1984
2003
  return;
1985
2004
  if (await this.handleModelSelection(data))
@@ -2083,6 +2102,9 @@ export class FleetManager {
2083
2102
  const result = await this.topicCommands.sendCompact(name);
2084
2103
  await data.respond(result);
2085
2104
  }
2105
+ else if (data.command === "clear") {
2106
+ await this.handleClearSlash(data, adapterId);
2107
+ }
2086
2108
  else if (data.command === "model") {
2087
2109
  await this.handleModelSlash(data, adapterId);
2088
2110
  }
@@ -2290,6 +2312,8 @@ export class FleetManager {
2290
2312
  await this.handleInboundReaction(r);
2291
2313
  }, this.logger, `adapter[${adapterId}].reaction`));
2292
2314
  adapter.on("callback_query", safeHandler(async (data) => {
2315
+ if (await this.handleInteractivePromptAssist(data, adapterId))
2316
+ return;
2293
2317
  if (await this.handleClassicBackendSelection(data))
2294
2318
  return;
2295
2319
  if (await this.handleModelSelection(data))
@@ -2385,6 +2409,9 @@ export class FleetManager {
2385
2409
  else if (data.command === "model") {
2386
2410
  await this.handleModelSlash(data, adapterId);
2387
2411
  }
2412
+ else if (data.command === "clear") {
2413
+ await this.handleClearSlash(data, adapterId);
2414
+ }
2388
2415
  else if (data.command === "effort") {
2389
2416
  await this.handleEffortSlash(data, adapterId);
2390
2417
  }
@@ -2607,9 +2634,6 @@ export class FleetManager {
2607
2634
  }
2608
2635
  await this.handleOutboundFromInstance(name, msg);
2609
2636
  }
2610
- else if (msg.type === "fleet_tool_status") {
2611
- this.handleToolStatusFromInstance(name, msg);
2612
- }
2613
2637
  else if (msg.type === "fleet_schedule_create" || msg.type === "fleet_schedule_list" ||
2614
2638
  msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
2615
2639
  this.handleScheduleCrud(name, msg);
@@ -3076,6 +3100,21 @@ export class FleetManager {
3076
3100
  await msgAdapter?.sendText(chatId, result);
3077
3101
  return;
3078
3102
  }
3103
+ // Handle /clear command (admin only) — unlike /compact this starts a
3104
+ // fresh conversation and intentionally discards the current history.
3105
+ if (text === "/clear" || text.startsWith("/clear@")) {
3106
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
3107
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/clear"));
3108
+ return;
3109
+ }
3110
+ const clearName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3111
+ if (!clearName) {
3112
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3113
+ return;
3114
+ }
3115
+ await msgAdapter?.sendText(chatId, await this.topicCommands.sendClear(clearName));
3116
+ return;
3117
+ }
3079
3118
  // Handle /cancel command
3080
3119
  if (text === "/cancel" || text.startsWith("/cancel@")) {
3081
3120
  const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
@@ -3482,34 +3521,6 @@ export class FleetManager {
3482
3521
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
3483
3522
  }
3484
3523
  }
3485
- /** Handle tool status update from a daemon instance */
3486
- handleToolStatusFromInstance(instanceName, msg) {
3487
- const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3488
- if (!statusAdapter)
3489
- return;
3490
- const text = msg.text;
3491
- const editMessageId = msg.editMessageId;
3492
- const senderSessionName = msg.senderSessionName;
3493
- const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
3494
- ? senderSessionName
3495
- : null;
3496
- const routingConfig = senderInstanceName
3497
- ? this.fleetConfig?.instances[senderInstanceName]
3498
- : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]);
3499
- const threadId = routingConfig?.topic_id ? String(routingConfig.topic_id) : undefined;
3500
- const chatId = statusAdapter.getChatId();
3501
- if (!chatId)
3502
- return;
3503
- if (editMessageId) {
3504
- statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
3505
- }
3506
- else {
3507
- statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
3508
- const ipc = this.instanceIpcClients.get(instanceName);
3509
- ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId });
3510
- }).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message"));
3511
- }
3512
- }
3513
3524
  // ===================== Scheduler =====================
3514
3525
  async handleScheduleTrigger(schedule) {
3515
3526
  const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
@@ -4243,7 +4254,13 @@ export class FleetManager {
4243
4254
  }
4244
4255
  catch (err) {
4245
4256
  const e = err;
4246
- return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
4257
+ const output = stripAnsi(e.stdout ?? "").trim();
4258
+ if (output)
4259
+ return output;
4260
+ if (e.code === "ENOENT") {
4261
+ return "Backend doctor unavailable — agend CLI not found in PATH";
4262
+ }
4263
+ return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4247
4264
  }
4248
4265
  }
4249
4266
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4395,6 +4412,163 @@ export class FleetManager {
4395
4412
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
4396
4413
  }
4397
4414
  }
4415
+ interactivePromptLabel(kind) {
4416
+ const key = `interactive.kind.${kind}`;
4417
+ const translated = t(key);
4418
+ return translated === key ? kind.replaceAll("_", " ") : translated;
4419
+ }
4420
+ /**
4421
+ * Surface a stable terminal prompt in both places that matter:
4422
+ * - the blocked instance gets a plain pointer to General;
4423
+ * - General gets one-shot Confirm/Cancel controls on its own bound adapter.
4424
+ *
4425
+ * Only the prompt category crosses channels. The captured terminal line can
4426
+ * contain usernames or secret-adjacent text and stays in daemon.log.
4427
+ */
4428
+ async notifyInteractivePrompt(instanceName, kind) {
4429
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
4430
+ // Multi-world fleets can have one General per bot/platform. Route the assist
4431
+ // request to the General bound to the same adapter as the blocked instance.
4432
+ const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
4433
+ if (!generalName) {
4434
+ this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
4435
+ return;
4436
+ }
4437
+ const adapterId = this.getInstanceAdapterId(generalName);
4438
+ const adapter = this.getAdapterForInstance(generalName);
4439
+ const chatId = this.getGroupIdForInstance(generalName);
4440
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
4441
+ const threadId = topicId != null ? String(topicId) : undefined;
4442
+ if (!adapter || !adapterId || !chatId) {
4443
+ this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
4444
+ return;
4445
+ }
4446
+ const nonce = randomBytes(8).toString("hex");
4447
+ const label = this.interactivePromptLabel(kind);
4448
+ const entry = {
4449
+ instanceName,
4450
+ generalName,
4451
+ kind,
4452
+ adapterId,
4453
+ adapter,
4454
+ chatId,
4455
+ threadId,
4456
+ };
4457
+ entry.timer = setTimeout(() => {
4458
+ const pending = this.pendingInteractivePromptAssists.get(nonce);
4459
+ if (pending !== entry)
4460
+ return;
4461
+ this.pendingInteractivePromptAssists.delete(nonce);
4462
+ if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
4463
+ entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, t("interactive.expired", entry.instanceName), entry.threadId).catch(err => this.logger.debug({ err, instanceName }, "Failed to expire interactive prompt controls"));
4464
+ }
4465
+ }, INTERACTIVE_ASSIST_TIMEOUT_MS);
4466
+ entry.timer.unref?.();
4467
+ this.pendingInteractivePromptAssists.set(nonce, entry);
4468
+ try {
4469
+ const sent = await adapter.notifyAlert(chatId, {
4470
+ type: "interactive_prompt",
4471
+ instanceName,
4472
+ message: t("interactive.general_notice", instanceName, label),
4473
+ choices: [
4474
+ { id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:confirm`, label: t("interactive.confirm") },
4475
+ { id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:cancel`, label: t("interactive.cancel") },
4476
+ ],
4477
+ }, threadId ? { threadId } : undefined);
4478
+ entry.messageId = sent.messageId;
4479
+ }
4480
+ catch (err) {
4481
+ this.pendingInteractivePromptAssists.delete(nonce);
4482
+ if (entry.timer)
4483
+ clearTimeout(entry.timer);
4484
+ this.logger.warn({ err, instanceName, generalName }, "Failed to send interactive prompt assistance controls");
4485
+ }
4486
+ }
4487
+ async retireInteractivePromptButtons(pending, messageId, text) {
4488
+ if (!pending.adapter.editMessageRemoveButtons) {
4489
+ this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove interactive prompt controls");
4490
+ return;
4491
+ }
4492
+ try {
4493
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
4494
+ }
4495
+ catch (err) {
4496
+ // The action was already atomically consumed. An edit failure must not
4497
+ // prevent Confirm from reaching General or make Cancel actionable again.
4498
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire interactive prompt controls");
4499
+ }
4500
+ }
4501
+ /** Consume a General assist button exactly once. */
4502
+ async handleInteractivePromptAssist(data, callbackAdapterId) {
4503
+ if (!data.callbackData.startsWith(INTERACTIVE_ASSIST_CALLBACK_PREFIX))
4504
+ return false;
4505
+ const match = data.callbackData.match(/^interactive-assist:([0-9a-f]+):(confirm|cancel)$/);
4506
+ if (!match)
4507
+ return true;
4508
+ const pending = this.pendingInteractivePromptAssists.get(match[1]);
4509
+ if (!pending)
4510
+ return true;
4511
+ // Bind the capability to the exact message/world that created it. Telegram
4512
+ // keyboards are visible to everyone, so the click also requires fleet admin.
4513
+ if (pending.adapterId !== callbackAdapterId
4514
+ || data.chatId !== pending.chatId
4515
+ || (pending.threadId != null && data.threadId !== pending.threadId)
4516
+ || (pending.messageId != null && data.messageId !== pending.messageId)
4517
+ || !data.userId
4518
+ || !this.isFleetAdmin(data.userId, callbackAdapterId)) {
4519
+ this.logger.warn({ instanceName: pending.instanceName, userId: data.userId }, "Rejected unauthorized or mismatched interactive prompt callback");
4520
+ return true;
4521
+ }
4522
+ // Claim before any await: double clicks and duplicate callback delivery can
4523
+ // never inject two messages into General.
4524
+ this.pendingInteractivePromptAssists.delete(match[1]);
4525
+ if (pending.timer)
4526
+ clearTimeout(pending.timer);
4527
+ const action = match[2];
4528
+ this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
4529
+ action,
4530
+ userId: data.userId,
4531
+ generalName: pending.generalName,
4532
+ });
4533
+ if (action === "cancel") {
4534
+ await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
4535
+ return true;
4536
+ }
4537
+ // Injecting into General while General itself is blocked would type into the
4538
+ // terminal prompt instead of the agent input. Fail safe and require attach.
4539
+ if (pending.instanceName === pending.generalName) {
4540
+ await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
4541
+ return true;
4542
+ }
4543
+ await this.retireInteractivePromptButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
4544
+ try {
4545
+ await this.deliverToInstance(pending.generalName, {
4546
+ type: "fleet_inbound",
4547
+ content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.kind)),
4548
+ targetSession: pending.generalName,
4549
+ meta: {
4550
+ chat_id: pending.chatId,
4551
+ message_id: pending.messageId ?? data.messageId,
4552
+ user: "AgEnD",
4553
+ user_id: data.userId,
4554
+ ts: new Date().toISOString(),
4555
+ thread_id: pending.threadId ?? "",
4556
+ adapter_id: pending.adapterId,
4557
+ source: pending.adapter.type,
4558
+ },
4559
+ }, {
4560
+ // This is system-generated work, not a live user message. Queue behind
4561
+ // General's current turn so Kiro/Claude do not lose it while busy.
4562
+ isCrossInstance: true,
4563
+ });
4564
+ this.logger.info({ instanceName: pending.instanceName, generalName: pending.generalName }, "Interactive prompt assistance delivered to General");
4565
+ }
4566
+ catch (err) {
4567
+ this.logger.warn({ err, instanceName: pending.instanceName, generalName: pending.generalName }, "Failed to deliver interactive prompt assistance to General");
4568
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName), pending.threadId);
4569
+ }
4570
+ return true;
4571
+ }
4398
4572
  // ── Cancel button ────────────────────────────────────────────────────
4399
4573
  // Sent after delivering a user message to an instance; clicking it (or
4400
4574
  // /cancel) sends Escape to the instance's pane to interrupt generation.
@@ -6432,6 +6606,11 @@ When users create specialized instances, suggest these configurations:
6432
6606
  for (const pending of this.pendingClassicStarts.values())
6433
6607
  clearTimeout(pending.timer);
6434
6608
  this.pendingClassicStarts.clear();
6609
+ for (const pending of this.pendingInteractivePromptAssists.values()) {
6610
+ if (pending.timer)
6611
+ clearTimeout(pending.timer);
6612
+ }
6613
+ this.pendingInteractivePromptAssists.clear();
6435
6614
  this.topicArchiver.stop();
6436
6615
  this.scheduler?.shutdown();
6437
6616
  // Stop instances in parallel batches to avoid long sequential waits.