@songsid/agend 2.1.6 → 2.1.7-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.
package/dist/daemon.js CHANGED
@@ -37,11 +37,17 @@ 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
- 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.";
50
+ const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. Do not reply to this system instruction except through the react or reply tool.";
45
51
  /** Point a resumed CLI at its one backend-native instruction source. */
46
52
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
47
53
  const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
@@ -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. */
@@ -1214,10 +1229,26 @@ export class Daemon extends EventEmitter {
1214
1229
  // capture buffer the count drops — we lower the baseline so a re-occurrence
1215
1230
  // still registers as new (prevents the old hash-dedup's permanent suppression).
1216
1231
  lastErrorCount = new Map();
1232
+ // A lifecycle-provided count is a lower bound while a restarted Codex pane is
1233
+ // still booting. Its new tmux window starts empty before resume restores the
1234
+ // old transcript, so pre-ready scans must not lower this seeded baseline.
1235
+ seededErrorCountBaselines = new Map();
1217
1236
  lastDetectedErrorType = null;
1218
1237
  static errorPatternKey(ep) {
1219
1238
  return `${ep.type}:${ep.pattern.source}`;
1220
1239
  }
1240
+ /** Read the occurrence baseline so a lifecycle-owned restart can carry it forward. */
1241
+ getErrorPatternOccurrenceCount(type, pattern) {
1242
+ return this.lastErrorCount.get(`${type}:${pattern.source}`) ?? 0;
1243
+ }
1244
+ /** Seed a replacement daemon with an already-reported pattern count. */
1245
+ seedErrorPatternOccurrenceCount(type, pattern, count) {
1246
+ if (!Number.isSafeInteger(count) || count <= 0)
1247
+ return;
1248
+ const key = `${type}:${pattern.source}`;
1249
+ this.lastErrorCount.set(key, count);
1250
+ this.seededErrorCountBaselines.set(key, count);
1251
+ }
1221
1252
  /**
1222
1253
  * Undo the auth suspicion a pattern match armed, once the backend's token-free
1223
1254
  * probe has said the credentials are fine.
@@ -2440,6 +2471,23 @@ export class Daemon extends EventEmitter {
2440
2471
  // notification, a false recovery log, then silence.
2441
2472
  const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
2442
2473
  const codexLivePane = this.isCodexLivePaneSnapshot(pane);
2474
+ const rebasedSeededBaselines = new Set();
2475
+ // The baseline belongs to the resumed transcript, not the empty startup
2476
+ // frame from the replacement tmux window. Keep it as a floor until resume
2477
+ // presents a live composer, then rebase to the lower of the pane's actual
2478
+ // count and the seed. Any higher count already includes a new occurrence,
2479
+ // which the normal scan below must still report.
2480
+ if (codexLivePane && this.seededErrorCountBaselines.size > 0) {
2481
+ for (const [key, seed] of this.seededErrorCountBaselines) {
2482
+ const ep = patterns.find(candidate => Daemon.errorPatternKey(candidate) === key);
2483
+ if (ep)
2484
+ this.lastErrorCount.set(key, Math.min(countMatches(ep.pattern), seed));
2485
+ this.seededErrorCountBaselines.delete(key);
2486
+ // Do not let the generic scrolling-count path repeat this rebase if the
2487
+ // live-pane assignment above is removed or changed (#949 mutation guard).
2488
+ rebasedSeededBaselines.add(key);
2489
+ }
2490
+ }
2443
2491
  // State: waiting for recovery. A missing/outdated ready pattern must not
2444
2492
  // suppress every future error forever, so the gate has a hard deadline.
2445
2493
  if (this.errorWaitingForRecovery) {
@@ -2517,8 +2565,11 @@ export class Daemon extends EventEmitter {
2517
2565
  if (count <= seen) {
2518
2566
  // Occurrences scrolled out of the capture buffer → lower the baseline
2519
2567
  // so a future re-occurrence still counts as new (no permanent suppress).
2520
- if (count < seen)
2568
+ // A seeded Codex count stays a floor until resume reaches a live composer;
2569
+ // an empty startup frame is not evidence that its history scrolled away.
2570
+ if (count < seen && !this.seededErrorCountBaselines.has(key) && !rebasedSeededBaselines.has(key)) {
2521
2571
  this.lastErrorCount.set(key, count);
2572
+ }
2522
2573
  continue;
2523
2574
  }
2524
2575
  // count > seen → a NEW occurrence of this exact pattern appeared.
@@ -3211,7 +3262,7 @@ export class Daemon extends EventEmitter {
3211
3262
  const turn = this.turnReplyGuard.snapshot();
3212
3263
  if (!turn || this.isPaused)
3213
3264
  return;
3214
- if (turn.replyDelivered) {
3265
+ if (turn.completionDelivered) {
3215
3266
  if (turn.phase === "recovering") {
3216
3267
  this.emit("reply_drop_recovered", {
3217
3268
  name: this.name,
@@ -3269,9 +3320,9 @@ export class Daemon extends EventEmitter {
3269
3320
  return;
3270
3321
  }
3271
3322
  }
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.
3323
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Any verified
3324
+ // outbound action suppresses a duplicate proxy; only a delivered
3325
+ // human-facing channel action satisfies the completion guard below.
3275
3326
  if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
3276
3327
  && mcpServerState(this.instanceDir).state === "dead") {
3277
3328
  this.turnReplyGuard.complete(turn.generation);
@@ -3331,7 +3382,7 @@ export class Daemon extends EventEmitter {
3331
3382
  const current = this.turnReplyGuard.snapshot();
3332
3383
  if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3333
3384
  return;
3334
- if (current.replyDelivered) {
3385
+ if (current.completionDelivered) {
3335
3386
  this.turnReplyGuard.complete(turn.generation);
3336
3387
  this.emit("reply_drop_recovered", {
3337
3388
  name: this.name,
@@ -4204,11 +4255,7 @@ export class Daemon extends EventEmitter {
4204
4255
  this.recordRecentUserMessage(content, meta);
4205
4256
  const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
4206
4257
  + 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;
4258
+ const status = channelStatus(meta);
4212
4259
  this.steerLock = this.steerLock.then(async () => {
4213
4260
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4214
4261
  return;
@@ -4246,11 +4293,7 @@ export class Daemon extends EventEmitter {
4246
4293
  this.recordRecentUserMessage(content, meta);
4247
4294
  const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
4248
4295
  + 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;
4296
+ const status = channelStatus(meta);
4254
4297
  this.steerLock = this.steerLock.then(async () => {
4255
4298
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4256
4299
  return;
@@ -4327,7 +4370,9 @@ export class Daemon extends EventEmitter {
4327
4370
  this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up");
4328
4371
  }
4329
4372
  if (wasQueued && chatId && messageId) {
4330
- this.emit("message_queued", { chatId: meta.thread_id || chatId, messageId });
4373
+ const queuedStatus = channelStatus(meta);
4374
+ if (queuedStatus)
4375
+ this.emit("message_queued", queuedStatus);
4331
4376
  }
4332
4377
  this.pasteLock = this.pasteLock.then(async () => {
4333
4378
  try {
@@ -4346,9 +4391,7 @@ export class Daemon extends EventEmitter {
4346
4391
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4347
4392
  return;
4348
4393
  }
4349
- const status = (chatId && messageId)
4350
- ? { chatId: meta.thread_id || chatId, messageId }
4351
- : undefined;
4394
+ const status = channelStatus(meta);
4352
4395
  // A fresh delivery begins a fresh turn — its bubble must not inherit
4353
4396
  // the previous turn's tool list.
4354
4397
  this.resetToolProgress();
@@ -6050,14 +6093,14 @@ export class Daemon extends EventEmitter {
6050
6093
  // an unmapped socket is retained for legacy/tests where mcp_ready was not
6051
6094
  // observed before the first tool call.
6052
6095
  const sourceSession = this.socketSessionNames.get(socket);
6053
- const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
6054
- ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
6096
+ const replyAttempt = TURN_OUTBOUND_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
6097
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply", TURN_COMPLETION_TOOLS.has(tool))
6055
6098
  : null;
6056
6099
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
6057
6100
  const respond = (result, error) => {
6058
6101
  // A message that verifiably went out stands down the dead-MCP proxy reply
6059
6102
  // for this turn: the agent proved it can still speak for itself.
6060
- if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
6103
+ if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
6061
6104
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
6062
6105
  }
6063
6106
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;