@songsid/agend 2.1.6 → 2.1.7-beta.1

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.
@@ -115,6 +115,10 @@ export interface ChannelAdapter extends EventEmitter {
115
115
  * threads are their own channel; Telegram reactions key on the supergroup
116
116
  * chat_id and ignore it). */
117
117
  react(chatId: string, messageId: string, emoji: string, threadId?: string): Promise<void>;
118
+ /** Remove the bot's own reaction from a message. Optional so third-party
119
+ * adapters keep working — callers fall back to a plain add when absent.
120
+ * threadId follows the same routing rule as react. */
121
+ unreact?(chatId: string, messageId: string, emoji: string, threadId?: string): Promise<void>;
118
122
  sendApproval(prompt: PermissionPrompt, callback: (decision: "approve" | "approve_always" | "deny") => void, signal?: AbortSignal, threadId?: string): Promise<ApprovalHandle>;
119
123
  downloadAttachment(fileId: string): Promise<string>;
120
124
  handlePairing(chatId: string, userId: string): Promise<string>;
package/dist/daemon.js CHANGED
@@ -37,9 +37,15 @@ const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "sta
37
37
  const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
38
38
  const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]);
39
39
  const TASK_TOOL = "task";
40
- // Tools whose success proves the agent got a message out this turn. While any
41
- // of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
42
- const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
40
+ // Tools whose success proves the agent performed an outbound action this turn.
41
+ // While any of these succeeded, a dead-MCP proxy reply would duplicate output.
42
+ const TURN_OUTBOUND_TOOLS = new Set([
43
+ "reply", "react", "edit_message", "send_to_instance", "report_result",
44
+ "request_information", "delegate_task", "broadcast",
45
+ ]);
46
+ // These actions reach the human-facing channel and satisfy reply-completion:
47
+ // a deliberate reaction or edit is a valid response even without new text.
48
+ const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
43
49
  const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
44
50
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
45
51
  /** Point a resumed CLI at its one backend-native instruction source. */
@@ -495,6 +501,15 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
495
501
  const INPUT_TRANSIENT_POLL_MS = 250;
496
502
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
497
503
  const STRANDED_INPUT_MAX_ROUNDS = 3;
504
+ /** Build a status holder from channel metadata, keeping chat and thread apart. */
505
+ function channelStatus(meta) {
506
+ const chatId = meta.chat_id;
507
+ const messageId = meta.message_id;
508
+ if (!chatId || !messageId)
509
+ return undefined;
510
+ const threadId = meta.thread_id || undefined;
511
+ return threadId ? { chatId, messageId, threadId } : { chatId, messageId };
512
+ }
498
513
  const FIRST_ENTER_SETTLE_MS = 1_750;
499
514
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
500
515
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
@@ -3211,7 +3226,7 @@ export class Daemon extends EventEmitter {
3211
3226
  const turn = this.turnReplyGuard.snapshot();
3212
3227
  if (!turn || this.isPaused)
3213
3228
  return;
3214
- if (turn.replyDelivered) {
3229
+ if (turn.completionDelivered) {
3215
3230
  if (turn.phase === "recovering") {
3216
3231
  this.emit("reply_drop_recovered", {
3217
3232
  name: this.name,
@@ -3269,9 +3284,9 @@ export class Daemon extends EventEmitter {
3269
3284
  return;
3270
3285
  }
3271
3286
  }
3272
- // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
3273
- // tools still suppress that proxy, but only a delivered `reply` satisfies
3274
- // the human-facing completion guard below.
3287
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Any verified
3288
+ // outbound action suppresses a duplicate proxy; only a delivered
3289
+ // human-facing channel action satisfies the completion guard below.
3275
3290
  if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
3276
3291
  && mcpServerState(this.instanceDir).state === "dead") {
3277
3292
  this.turnReplyGuard.complete(turn.generation);
@@ -3331,7 +3346,7 @@ export class Daemon extends EventEmitter {
3331
3346
  const current = this.turnReplyGuard.snapshot();
3332
3347
  if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3333
3348
  return;
3334
- if (current.replyDelivered) {
3349
+ if (current.completionDelivered) {
3335
3350
  this.turnReplyGuard.complete(turn.generation);
3336
3351
  this.emit("reply_drop_recovered", {
3337
3352
  name: this.name,
@@ -4204,11 +4219,7 @@ export class Daemon extends EventEmitter {
4204
4219
  this.recordRecentUserMessage(content, meta);
4205
4220
  const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
4206
4221
  + this.formatInboundMessage(content, meta);
4207
- const chatId = meta.chat_id;
4208
- const messageId = meta.message_id;
4209
- const status = (chatId && messageId)
4210
- ? { chatId: meta.thread_id || chatId, messageId }
4211
- : undefined;
4222
+ const status = channelStatus(meta);
4212
4223
  this.steerLock = this.steerLock.then(async () => {
4213
4224
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4214
4225
  return;
@@ -4246,11 +4257,7 @@ export class Daemon extends EventEmitter {
4246
4257
  this.recordRecentUserMessage(content, meta);
4247
4258
  const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
4248
4259
  + this.formatInboundMessage(content, meta);
4249
- const chatId = meta.chat_id;
4250
- const messageId = meta.message_id;
4251
- const status = (chatId && messageId)
4252
- ? { chatId: meta.thread_id || chatId, messageId }
4253
- : undefined;
4260
+ const status = channelStatus(meta);
4254
4261
  this.steerLock = this.steerLock.then(async () => {
4255
4262
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4256
4263
  return;
@@ -4327,7 +4334,9 @@ export class Daemon extends EventEmitter {
4327
4334
  this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up");
4328
4335
  }
4329
4336
  if (wasQueued && chatId && messageId) {
4330
- this.emit("message_queued", { chatId: meta.thread_id || chatId, messageId });
4337
+ const queuedStatus = channelStatus(meta);
4338
+ if (queuedStatus)
4339
+ this.emit("message_queued", queuedStatus);
4331
4340
  }
4332
4341
  this.pasteLock = this.pasteLock.then(async () => {
4333
4342
  try {
@@ -4346,9 +4355,7 @@ export class Daemon extends EventEmitter {
4346
4355
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4347
4356
  return;
4348
4357
  }
4349
- const status = (chatId && messageId)
4350
- ? { chatId: meta.thread_id || chatId, messageId }
4351
- : undefined;
4358
+ const status = channelStatus(meta);
4352
4359
  // A fresh delivery begins a fresh turn — its bubble must not inherit
4353
4360
  // the previous turn's tool list.
4354
4361
  this.resetToolProgress();
@@ -6050,14 +6057,14 @@ export class Daemon extends EventEmitter {
6050
6057
  // an unmapped socket is retained for legacy/tests where mcp_ready was not
6051
6058
  // observed before the first tool call.
6052
6059
  const sourceSession = this.socketSessionNames.get(socket);
6053
- const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
6054
- ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
6060
+ const replyAttempt = TURN_OUTBOUND_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
6061
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply", TURN_COMPLETION_TOOLS.has(tool))
6055
6062
  : null;
6056
6063
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
6057
6064
  const respond = (result, error) => {
6058
6065
  // A message that verifiably went out stands down the dead-MCP proxy reply
6059
6066
  // for this turn: the agent proved it can still speak for itself.
6060
- if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
6067
+ if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
6061
6068
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
6062
6069
  }
6063
6070
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;