mixdog 0.9.17 → 0.9.19

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 (129) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/output-style-smoke.mjs +2 -2
  4. package/scripts/recall-bench-cases.json +11 -0
  5. package/scripts/recall-bench.mjs +91 -2
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/tool-efficiency-diag.mjs +5 -2
  9. package/scripts/tool-smoke.mjs +101 -27
  10. package/src/agents/debugger/AGENT.md +3 -3
  11. package/src/agents/heavy-worker/AGENT.md +7 -10
  12. package/src/agents/maintainer/AGENT.md +1 -2
  13. package/src/agents/reviewer/AGENT.md +1 -2
  14. package/src/agents/worker/AGENT.md +5 -8
  15. package/src/defaults/agents.json +3 -0
  16. package/src/mixdog-session-runtime.mjs +23 -6
  17. package/src/rules/agent/00-core.md +4 -3
  18. package/src/rules/agent/30-explorer.md +53 -22
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +24 -29
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  25. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  28. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  35. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  36. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  39. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  40. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  41. package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
  42. package/src/runtime/channels/backends/discord.mjs +139 -7
  43. package/src/runtime/channels/index.mjs +290 -76
  44. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  45. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  46. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  47. package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
  48. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  49. package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
  50. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  51. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  52. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  53. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  54. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  55. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  56. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  57. package/src/runtime/shared/atomic-file.mjs +10 -4
  58. package/src/runtime/shared/background-tasks.mjs +4 -2
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  61. package/src/runtime/shared/tool-surface.mjs +30 -1
  62. package/src/session-runtime/config-lifecycle.mjs +1 -1
  63. package/src/session-runtime/cwd-plugins.mjs +46 -3
  64. package/src/session-runtime/mcp-glue.mjs +24 -3
  65. package/src/session-runtime/output-styles.mjs +44 -10
  66. package/src/session-runtime/workflow.mjs +16 -1
  67. package/src/standalone/channel-worker.mjs +88 -7
  68. package/src/standalone/explore-tool.mjs +1 -1
  69. package/src/tui/App.jsx +57 -77
  70. package/src/tui/app/channel-pickers.mjs +45 -0
  71. package/src/tui/app/slash-commands.mjs +0 -1
  72. package/src/tui/app/slash-dispatch.mjs +0 -16
  73. package/src/tui/app/transcript-window.mjs +66 -1
  74. package/src/tui/app/use-mouse-input.mjs +9 -2
  75. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  76. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  77. package/src/tui/app/use-transcript-window.mjs +74 -8
  78. package/src/tui/components/PromptInput.jsx +33 -64
  79. package/src/tui/components/ToolExecution.jsx +2 -2
  80. package/src/tui/dist/index.mjs +4744 -4806
  81. package/src/tui/engine.mjs +109 -20
  82. package/src/tui/lib/voice-setup.mjs +166 -0
  83. package/src/tui/paste-attachments.mjs +12 -5
  84. package/src/tui/prompt-history-store.mjs +125 -12
  85. package/scripts/bench/cache-probe-tasks.json +0 -8
  86. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  87. package/scripts/bench/lead-review-tasks.json +0 -20
  88. package/scripts/bench/r4-mixed-tasks.json +0 -20
  89. package/scripts/bench/r5-orchestrated-task.json +0 -7
  90. package/scripts/bench/review-tasks.json +0 -20
  91. package/scripts/bench/round-codex.json +0 -114
  92. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  93. package/scripts/bench/round-mixdog-lead.json +0 -269
  94. package/scripts/bench/round-mixdog.json +0 -126
  95. package/scripts/bench/round-r10-bigsample.json +0 -679
  96. package/scripts/bench/round-r11-codexalign.json +0 -257
  97. package/scripts/bench/round-r13-clientmeta.json +0 -464
  98. package/scripts/bench/round-r14-betafeatures.json +0 -466
  99. package/scripts/bench/round-r15-fulldefault.json +0 -462
  100. package/scripts/bench/round-r16-sessionid.json +0 -466
  101. package/scripts/bench/round-r17-wirebytes.json +0 -456
  102. package/scripts/bench/round-r18-prewarm.json +0 -468
  103. package/scripts/bench/round-r19-clean.json +0 -472
  104. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  105. package/scripts/bench/round-r21-delta-retry.json +0 -473
  106. package/scripts/bench/round-r22-full-probe.json +0 -693
  107. package/scripts/bench/round-r23-itemprobe.json +0 -701
  108. package/scripts/bench/round-r24-shapefix.json +0 -677
  109. package/scripts/bench/round-r25-serial.json +0 -464
  110. package/scripts/bench/round-r26-parallel3.json +0 -671
  111. package/scripts/bench/round-r27-parallel10.json +0 -894
  112. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  113. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  114. package/scripts/bench/round-r30-instid.json +0 -253
  115. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  116. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  117. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  118. package/scripts/bench/round-r34-orchestrated.json +0 -120
  119. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  120. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  121. package/scripts/bench/round-r4-codex.json +0 -114
  122. package/scripts/bench/round-r4-mixed.json +0 -225
  123. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  124. package/scripts/bench/round-r6-codex.json +0 -114
  125. package/scripts/bench/round-r6-solo.json +0 -257
  126. package/scripts/bench/round-r7-full.json +0 -254
  127. package/scripts/bench/round-r8-fulldefault.json +0 -255
  128. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  129. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -190,25 +190,42 @@ function atomicWrite(targetPath, contents) {
190
190
  catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
191
191
  }
192
192
 
193
+ // In-memory dedupeKey(filePath) -> absolute buffer path index. Replaces the
194
+ // per-event O(N) readdir+read+parse dedupe scan in bufferToDisk. Seeded once
195
+ // (lazily) from disk so cross-restart dedupe (one buffered ingest per
196
+ // transcriptPath) still holds; maintained incrementally on write. A stale
197
+ // entry (file drained/renamed) is caught by an existsSync guard at the call
198
+ // site, so no drain/replay format or TTL semantics change.
199
+ const _dedupeIndex = new Map()
200
+ let _dedupeIndexSeeded = false
201
+ function seedDedupeIndex(kind) {
202
+ if (_dedupeIndexSeeded) return
203
+ _dedupeIndexSeeded = true
204
+ let existing = []
205
+ try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
206
+ for (const name of existing) {
207
+ if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
208
+ try {
209
+ const prev = JSON.parse(fs.readFileSync(path.join(BUFFER_DIR, name), 'utf8'))
210
+ if (prev && prev.filePath != null) _dedupeIndex.set(prev.filePath, path.join(BUFFER_DIR, name))
211
+ } catch {}
212
+ }
213
+ }
214
+
193
215
  function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
194
216
  try {
195
217
  fs.mkdirSync(BUFFER_DIR, { recursive: true })
196
218
  if (dedupeKey != null) {
197
- let existing = []
198
- try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
199
- for (const name of existing) {
200
- if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
201
- const full = path.join(BUFFER_DIR, name)
202
- try {
203
- const prev = JSON.parse(fs.readFileSync(full, 'utf8'))
204
- if (prev && prev.filePath === dedupeKey) {
205
- // Atomic overwrite (tmp+rename); preserves the original (oldest)
206
- // ordering slot without exposing a half-written file to a drainer.
207
- atomicWrite(full, JSON.stringify(payload, null, 2))
208
- return full
209
- }
210
- } catch {}
219
+ // In-memory index (seeded once from disk) replaces the per-event O(N)
220
+ // readdir+read+parse scan. Overwrite the existing buffered file for this
221
+ // dedupeKey in place (atomic tmp+rename) to keep its oldest ordering slot.
222
+ seedDedupeIndex(kind)
223
+ const idx = _dedupeIndex.get(dedupeKey)
224
+ if (idx && fs.existsSync(idx)) {
225
+ atomicWrite(idx, JSON.stringify(payload, null, 2))
226
+ return idx
211
227
  }
228
+ if (idx) _dedupeIndex.delete(dedupeKey)
212
229
  }
213
230
  enforceBufferCap()
214
231
  const random = Math.random().toString(36).slice(2, 10)
@@ -216,6 +233,7 @@ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
216
233
  // ordering under a lexicographic sort.
217
234
  const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
218
235
  atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
236
+ if (dedupeKey != null) _dedupeIndex.set(dedupeKey, bufferPath)
219
237
  return bufferPath
220
238
  } catch (bufErr) {
221
239
  process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
@@ -28,8 +28,24 @@ import {
28
28
  buildToolLine
29
29
  } from "./tool-format.mjs";
30
30
 
31
+ // Per-item send watchdog. drainQueue awaits ONE backend send at a time; a hung
32
+ // send would otherwise never settle, so drainQueue never returns and `sending`
33
+ // stays true forever — wedging ALL outbound behind the queue head. The watchdog
34
+ // does NOT abandon the send (racing a still-running send against a retry would
35
+ // double-deliver): it only pokes the backend to reset its wedged client so the
36
+ // AWAITED send fails fast and `sending` releases on real settlement. Sized above
37
+ // the backend's worst-case per-attempt retries.
38
+ const SEND_ITEM_TIMEOUT_MS = 120_000;
39
+ // After the watchdog resets the backend, wait at most this long for the
40
+ // abandoned send to actually settle before releasing the queue.
41
+ const SEND_SETTLE_CAP_MS = 15_000;
42
+ const TAIL_RECOVERY_BYTES = 256 * 1024;
43
+
31
44
  class OutputForwarder {
32
45
  ownerGetter = null;
46
+ // Generation of the in-flight supervised send. Bumped when a send is
47
+ // abandoned (settlement cap) so a late/zombie completion can't double-advance.
48
+ _activeSendGen = 0;
33
49
  setOwnerGetter(fn) {
34
50
  this.ownerGetter = fn;
35
51
  }
@@ -60,6 +76,9 @@ class OutputForwarder {
60
76
  lastFileSize = 0;
61
77
  readFileSize = 0;
62
78
  watchingPath = "";
79
+ // Set by readNewLines when a capped tick left more complete bytes on disk,
80
+ // so the poll callback re-arms a flush without waiting on a fresh fs event.
81
+ _pendingReadMore = false;
63
82
  watcher = null;
64
83
  idleTimer = null;
65
84
  onIdleCallback = null;
@@ -80,6 +99,11 @@ class OutputForwarder {
80
99
  // into the next pending text payload so we don't grow unbounded but
81
100
  // also don't lose content.
82
101
  static SEND_QUEUE_MAX = 200;
102
+ // Cap the bytes read per streaming tick so a large burst (e.g. a big tool
103
+ // result written at once) is amortized across ticks instead of allocating a
104
+ // multi-MB buffer and blocking the loop on one readSync. The cursor only
105
+ // advances by bytes actually consumed; the remainder is picked up next tick.
106
+ static MAX_TICK_READ_BYTES = 2 * 1024 * 1024;
83
107
  // Persisted final-flush ledger so a forwarder restart can resume final
84
108
  // forwarding instead of giving up after 5 short retries.
85
109
  pendingFinalFlush = false;
@@ -111,20 +135,35 @@ class OutputForwarder {
111
135
  try {
112
136
  const stat = existsSync(this.transcriptPath) ? statSync(this.transcriptPath) : null;
113
137
  const currentSize = stat?.size ?? 0;
138
+ const persisted = this.statusState?.read?.();
139
+ const sameTranscript = persisted?.transcriptPath &&
140
+ sameResolvedPath(persisted.transcriptPath, this.transcriptPath);
114
141
  let fileSize;
115
142
  if (options.replayFromStart) {
116
143
  fileSize = 0;
117
144
  } else if (options.catchUpFromPersisted) {
118
- const persisted = this.statusState?.read?.();
119
145
  const persistedSize = typeof persisted?.lastFileSize === "number" ? persisted.lastFileSize : -1;
120
- const sameTranscript = persisted?.transcriptPath &&
121
- sameResolvedPath(persisted.transcriptPath, this.transcriptPath);
122
146
  fileSize = (sameTranscript && persistedSize >= 0)
123
147
  ? Math.min(Math.max(persistedSize, 0), currentSize)
124
148
  : currentSize;
125
149
  } else {
126
150
  fileSize = currentSize;
127
151
  }
152
+ if (options.recoverUnsyncedTail && fileSize === currentSize && currentSize > 0) {
153
+ const sentCount = Number(persisted?.sentCount ?? 0);
154
+ const lastSentTime = Number(persisted?.lastSentTime ?? 0);
155
+ const hasSentHash = typeof persisted?.lastSentHash === "string" && persisted.lastSentHash.length > 0;
156
+ const noSendEvidence = (!Number.isFinite(sentCount) || sentCount <= 0)
157
+ && (!Number.isFinite(lastSentTime) || lastSentTime <= 0)
158
+ && !hasSentHash;
159
+ if (!sameTranscript || noSendEvidence) {
160
+ const tailOffset = this.findLastAssistantTextOffset(currentSize);
161
+ if (tailOffset >= 0 && tailOffset < currentSize) {
162
+ fileSize = tailOffset;
163
+ dropTrace("context.recoverUnsyncedTail", { path: this.transcriptPath, offset: tailOffset, currentSize, sameTranscript: !!sameTranscript });
164
+ }
165
+ }
166
+ }
128
167
  this.lastFileSize = fileSize;
129
168
  this.readFileSize = fileSize;
130
169
  } catch {
@@ -132,6 +171,46 @@ class OutputForwarder {
132
171
  this.readFileSize = 0;
133
172
  }
134
173
  }
174
+ findLastAssistantTextOffset(fileSize) {
175
+ if (!this.transcriptPath || fileSize <= 0) return -1;
176
+ const bytesToRead = Math.min(fileSize, TAIL_RECOVERY_BYTES);
177
+ const startOffset = fileSize - bytesToRead;
178
+ let fd = null;
179
+ try {
180
+ fd = openSync(this.transcriptPath, "r");
181
+ const buf = Buffer.alloc(bytesToRead);
182
+ readSync(fd, buf, 0, bytesToRead, startOffset);
183
+ const raw = buf.toString("utf8");
184
+ let chunkStart = 0;
185
+ if (startOffset > 0) {
186
+ const firstNewline = raw.indexOf("\n");
187
+ if (firstNewline < 0) return -1;
188
+ chunkStart = firstNewline + 1;
189
+ }
190
+ let cursor = startOffset + chunkStart;
191
+ let lastOffset = -1;
192
+ for (const line of raw.slice(chunkStart).split("\n")) {
193
+ const lineOffset = cursor;
194
+ cursor += Buffer.byteLength(line, "utf8") + 1;
195
+ if (!line.trim()) continue;
196
+ try {
197
+ const entry = JSON.parse(line);
198
+ if (entry.isSidechain || entry.type !== "assistant") continue;
199
+ const parts = entry.message?.content;
200
+ if (!Array.isArray(parts)) continue;
201
+ if (parts.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim())) {
202
+ lastOffset = lineOffset;
203
+ }
204
+ } catch {
205
+ }
206
+ }
207
+ return lastOffset;
208
+ } catch {
209
+ return -1;
210
+ } finally {
211
+ if (fd != null) closeSync(fd);
212
+ }
213
+ }
135
214
  /** Reset counters for new turn */
136
215
  reset() {
137
216
  this.sentCount = 0;
@@ -146,12 +225,13 @@ class OutputForwarder {
146
225
  }
147
226
  }
148
227
  /** Read new bytes from transcript file since readFileSize */
149
- readNewLines() {
228
+ readNewLines(uncapped = false) {
150
229
  if (!this.transcriptPath || !existsSync(this.transcriptPath)) {
151
230
  return { lines: [], nextFileSize: this.readFileSize };
152
231
  }
153
232
  let fd = null;
154
233
  try {
234
+ this._pendingReadMore = false;
155
235
  const stat = this._pendingStat ?? statSync(this.transcriptPath);
156
236
  this._pendingStat = null;
157
237
  // File shrank below our cursor: the transcript was rotated out from
@@ -166,16 +246,33 @@ class OutputForwarder {
166
246
  return { lines: [], nextFileSize: this.readFileSize };
167
247
  }
168
248
  const startOffset = this.readFileSize;
249
+ const available = stat.size - startOffset;
250
+ // Cap the per-tick read (streaming path). The final-flush path passes
251
+ // uncapped=true to drain the whole tail in one shot.
252
+ let readLen = uncapped ? available : Math.min(available, OutputForwarder.MAX_TICK_READ_BYTES);
169
253
  fd = openSync(this.transcriptPath, "r");
170
- const buf = Buffer.alloc(stat.size - startOffset);
171
- readSync(fd, buf, 0, buf.length, startOffset);
254
+ let buf = Buffer.alloc(readLen);
255
+ readSync(fd, buf, 0, readLen, startOffset);
172
256
  // Only advance readFileSize to the last newline boundary. A trailing
173
257
  // partial line (writer still appending) must be re-read next tick;
174
258
  // otherwise the parse-failed slice is silently consumed forever.
175
- const lastNl = buf.lastIndexOf(0x0a);
259
+ let lastNl = buf.lastIndexOf(0x0a);
260
+ // The capped window split a single JSONL line larger than the cap (no
261
+ // newline inside it). Fall back to reading the full available range this
262
+ // tick so the line boundary is found — matches the pre-cap behavior and
263
+ // avoids getting stuck forever on an oversize line.
264
+ if (lastNl < 0 && readLen < available) {
265
+ readLen = available;
266
+ buf = Buffer.alloc(readLen);
267
+ readSync(fd, buf, 0, readLen, startOffset);
268
+ lastNl = buf.lastIndexOf(0x0a);
269
+ }
176
270
  const consumed = lastNl >= 0 ? lastNl + 1 : 0;
177
271
  const nextFileSize = startOffset + consumed;
178
272
  this.readFileSize = nextFileSize;
273
+ // Capped read left more complete bytes on disk: signal the poll callback
274
+ // to re-tick so a burst drains without waiting on another fs event.
275
+ this._pendingReadMore = readLen < available && nextFileSize > startOffset;
179
276
  const text = consumed > 0 ? buf.slice(0, consumed).toString("utf8") : "";
180
277
  return {
181
278
  lines: text ? text.split("\n").filter((l) => l.trim()) : [],
@@ -193,8 +290,8 @@ class OutputForwarder {
193
290
  lastToolName = "";
194
291
  lastToolFilePath = "";
195
292
  /** Extract new assistant text + tool logs from transcript since readFileSize */
196
- extractNewText() {
197
- const { lines: newLines, nextFileSize } = this.readNewLines();
293
+ extractNewText(uncapped = false) {
294
+ const { lines: newLines, nextFileSize } = this.readNewLines(uncapped);
198
295
  let newText = "";
199
296
  for (const l of newLines) {
200
297
  try {
@@ -282,6 +379,7 @@ class OutputForwarder {
282
379
  }
283
380
  async deliverQueueItem(item) {
284
381
  const targetChannelId = item.channelId ?? this.channelId;
382
+ const gen = ++this._activeSendGen;
285
383
  if (!item.text || !targetChannelId) {
286
384
  this.commitReadProgress(item.nextFileSize);
287
385
  return;
@@ -314,10 +412,13 @@ class OutputForwarder {
314
412
  // re-sending chunks that already landed. The token is opaque here —
315
413
  // the forwarder only stores/passes/clears it, never interprets it.
316
414
  await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
415
+ dropTrace("discord.send.ok", null);
416
+ // A zombie completion (send abandoned past the settlement cap, gen bumped)
417
+ // must not mutate ANY item/queue state — including the resume token.
418
+ if (gen !== this._activeSendGen) return;
317
419
  // Full success — drop any stale token so a later reuse of this item
318
420
  // object can't carry a dead token.
319
421
  item._resumeToken = undefined;
320
- dropTrace("discord.send.ok", null);
321
422
  } catch (err) {
322
423
  // Persist the opaque resume token (if any) on the item so the requeued
323
424
  // retry resumes from the failed chunk.
@@ -349,7 +450,18 @@ ${_bt.trim()}` : _bt.trim();
349
450
  }
350
451
  /** Forward new assistant text to Discord. Returns true if text was sent. */
351
452
  async forwardNewText() {
352
- if (!this._isOwner()) return false;
453
+ if (!this._isOwner()) {
454
+ // Silent no-send is invisible and has masked real ownership-gate bugs
455
+ // (a live seat that never forwards). Surface it, rate-limited to one
456
+ // line per 60s so a persistent non-owner state stays visible without
457
+ // flooding stderr on every poll tick.
458
+ const now = Date.now();
459
+ if (!this._lastOwnerGateLogAt || now - this._lastOwnerGateLogAt >= 60_000) {
460
+ this._lastOwnerGateLogAt = now;
461
+ process.stderr.write(`[output-forwarder] forwardNewText skipped: not seat owner (channelId=${this.channelId ?? "none"})\n`);
462
+ }
463
+ return false;
464
+ }
353
465
  if (!this.channelId) return false;
354
466
  const { text: newText, nextFileSize } = this.extractNewText();
355
467
  if (!newText) {
@@ -428,6 +540,31 @@ ${_bt.trim()}` : _bt.trim();
428
540
  _kickDrain() {
429
541
  this.drainQueue().catch(err => process.stderr.write(`[output-forwarder] drainQueue rejected: ${err?.message || err}\n`));
430
542
  }
543
+ /** Supervise a per-item send. The underlying promise is AWAITED to real
544
+ * settlement so `sending` is never released early (no unsupervised send
545
+ * racing a retry → duplicate). The watchdog's only job is to reset the
546
+ * backend's wedged client so the awaited send fails fast. */
547
+ _sendSupervised(promise, label) {
548
+ const settled = Promise.resolve(promise);
549
+ settled.catch(() => {}); // never leak an unhandled rejection on late settle
550
+ return new Promise((resolve, reject) => {
551
+ let capTimer = null;
552
+ const watchdog = setTimeout(() => {
553
+ process.stderr.write(`[output-forwarder] ${label} watchdog fired after ${SEND_ITEM_TIMEOUT_MS}ms; resetting backend\n`);
554
+ Promise.resolve(this.cb?.resetBackend?.()).catch(() => {});
555
+ // Bound the post-reset settlement wait; past the cap, invalidate the
556
+ // in-flight send (gen bump → zombie can't double-advance) and release.
557
+ capTimer = setTimeout(() => {
558
+ this._activeSendGen++;
559
+ reject(new Error(`${label} abandoned after ${SEND_SETTLE_CAP_MS}ms settlement cap`));
560
+ }, SEND_SETTLE_CAP_MS);
561
+ }, SEND_ITEM_TIMEOUT_MS);
562
+ settled.then(
563
+ (v) => { clearTimeout(watchdog); if (capTimer) clearTimeout(capTimer); resolve(v); },
564
+ (e) => { clearTimeout(watchdog); if (capTimer) clearTimeout(capTimer); reject(e); }
565
+ );
566
+ });
567
+ }
431
568
  /** Drain both priority lanes sequentially. finalLane drains first when non-empty. */
432
569
  async drainQueue() {
433
570
  if (this.sending) return;
@@ -440,9 +577,9 @@ ${_bt.trim()}` : _bt.trim();
440
577
  const item = lane[0];
441
578
  try {
442
579
  if (item.type === "text") {
443
- await this.deliverQueueItem(item);
580
+ await this._sendSupervised(this.deliverQueueItem(item), "deliverQueueItem");
444
581
  } else if (item.type === "toolLog") {
445
- await this.processToolLog(item);
582
+ await this._sendSupervised(this.processToolLog(item), "processToolLog");
446
583
  }
447
584
  lane.shift();
448
585
  } catch (err) {
@@ -524,7 +661,9 @@ ${_bt.trim()}` : _bt.trim();
524
661
  await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
525
662
  } catch {} // best-effort: remove reaction is non-critical
526
663
  }
527
- const { text: newText, nextFileSize } = this.extractNewText();
664
+ // Final flush drains the whole remaining tail (uncapped) so a turn's
665
+ // last write can't be split across ticks with nothing left to re-tick it.
666
+ const { text: newText, nextFileSize } = this.extractNewText(true);
528
667
  if (newText) {
529
668
  const finalItem = { type: "text", text: newText, nextFileSize, bufferText: newText, channelId };
530
669
  try {
@@ -725,6 +864,9 @@ ${_bt.trim()}` : _bt.trim();
725
864
  if (hadText) {
726
865
  this.resetIdleTimer();
727
866
  }
867
+ // A capped tick left more complete bytes on disk: re-arm a flush so the
868
+ // burst drains even if the writer has gone quiet (no further fs event).
869
+ if (this._pendingReadMore) this.scheduleWatchFlush();
728
870
  }).catch(err => process.stderr.write(`[output-forwarder] forwardNewText rejected: ${err?.message || err}\n`));
729
871
  }, 100);
730
872
  }
@@ -33,9 +33,15 @@ function createOwnerHeartbeat({
33
33
  function getBridgeOwnershipSnapshot() {
34
34
  return currentOwnerState();
35
35
  }
36
- function claimBridgeOwnership(reason) {
37
- refreshActiveInstance(getInstanceId());
38
- logOwnership(`claimed owner (${reason})`);
36
+ function claimBridgeOwnership(reason, options = {}) {
37
+ // Returns true only when THIS instance actually holds the seat after the
38
+ // write. With options.timeoutMs:0 (try-once) a contended lock throws
39
+ // ELOCKCONTENDED — the caller catches it and treats the seat as busy.
40
+ const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
41
+ const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
42
+ const claimed = res?.instanceId === getInstanceId();
43
+ if (claimed) logOwnership(`claimed owner (${reason})`);
44
+ return claimed;
39
45
  }
40
46
  function startOwnerHeartbeat() {
41
47
  if (ownerHeartbeatTimer) return;
@@ -49,7 +55,10 @@ function createOwnerHeartbeat({
49
55
  // onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
50
56
  // owner claiming the seat between currentOwnerState() and the locked
51
57
  // write is never overwritten (see refreshActiveInstance CAS guard).
52
- if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true });
58
+ // Try-once (timeoutMs:0): the heartbeat must never block on the
59
+ // active-instance lock. On contention refreshActiveInstance throws and
60
+ // we simply skip this tick — the next tick catches up.
61
+ if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
53
62
  } catch (e) {
54
63
  process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
55
64
  }
@@ -211,6 +211,34 @@ function writeActiveInstance(state) {
211
211
  ensureRuntimeDirs();
212
212
  writeJsonFile(ACTIVE_INSTANCE_FILE, state);
213
213
  }
214
+ // Non-blocking ownership probe for the periodic refresh/heartbeat tick. Reads
215
+ // active-instance WITHOUT taking the lock (never blocks). Distinguishes:
216
+ // 'absent' — file does not exist → seat is claimable
217
+ // 'stale' — parseable but owner PID is dead → seat is claimable
218
+ // 'live' — parseable, owner PID alive → seat is held (state.instanceId)
219
+ // 'unknown' — file present but unreadable/partial (concurrent atomic rename)
220
+ // → INDETERMINATE; callers must treat as busy and NEVER claim.
221
+ // This is the read-side guard for "locked/unreadable = busy/unknown owner,
222
+ // never claimable/no-owner": a torn read during another writer's rename must
223
+ // not be mistaken for an empty seat.
224
+ function probeActiveOwner() {
225
+ try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
226
+ let raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
227
+ if (!raw) {
228
+ // Transient partial content during an atomic rename — retry once.
229
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
230
+ raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
231
+ if (!raw) {
232
+ // Re-check existence to disambiguate a completed delete (absent) from a
233
+ // still-unreadable file (unknown/busy).
234
+ try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
235
+ return { status: 'unknown', state: null };
236
+ }
237
+ }
238
+ const staleReason = activeInstanceStaleReason(raw);
239
+ if (staleReason) return { status: 'stale', state: raw, staleReason };
240
+ return { status: 'live', state: raw };
241
+ }
214
242
  function buildActiveInstanceState(instanceId, meta) {
215
243
  const gatewayMeta = Object.fromEntries(
216
244
  Object.entries(meta || {}).filter(([k]) => k.startsWith('gateway_'))
@@ -233,6 +261,11 @@ function buildActiveInstanceState(instanceId, meta) {
233
261
  }
234
262
  function refreshActiveInstance(instanceId, meta, options) {
235
263
  ensureRuntimeDirs();
264
+ // Periodic refresh/heartbeat callers pass options.timeoutMs:0 (try-once) so
265
+ // the ownership tick never blocks on the active-instance lock — contention
266
+ // throws ELOCKCONTENDED, which the caller treats as "busy, skip this tick".
267
+ const writeOpts = { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' };
268
+ if (options && Number.isFinite(options.timeoutMs)) writeOpts.timeoutMs = options.timeoutMs;
236
269
  return updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
237
270
  const prevForPreserve = curRaw;
238
271
  const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
@@ -360,8 +393,21 @@ function refreshActiveInstance(instanceId, meta, options) {
360
393
  }
361
394
  }
362
395
  }
396
+ // Worker-owned seat: when THIS process is the channels worker and it is
397
+ // the seat owner (no distinct TUI/supervisor Lead — ownerLeadPid resolves
398
+ // to our own pid), keep ui_heartbeat_at fresh. A headless worker never
399
+ // runs the TUI render loop, so an inherited heartbeat (written by a prior
400
+ // TUI session and carried forward in prevRest) would otherwise go stale at
401
+ // UI_HEARTBEAT_STALE_MS and falsely flag a live worker-owned seat. A
402
+ // TUI-owned seat has a distinct supervisor (ownerLeadPid !== our pid), so
403
+ // this branch is a no-op there and TUI staleness is preserved.
404
+ const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
405
+ && identity.ownerLeadPid === process.pid;
406
+ if (workerOwnsSeat) {
407
+ next.ui_heartbeat_at = Date.now();
408
+ }
363
409
  return { ...preservedExtra, ...next };
364
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' });
410
+ }, writeOpts);
365
411
  }
366
412
  const SERVER_PID_FILE = join(
367
413
  RUNTIME_ROOT,
@@ -512,6 +558,7 @@ export {
512
558
  notePreviousServerIfAny,
513
559
  makeInstanceId,
514
560
  readActiveInstance,
561
+ probeActiveOwner,
515
562
  refreshActiveInstance,
516
563
  releaseOwnedChannelLocks,
517
564
  touchUiHeartbeat,
@@ -19,10 +19,12 @@ function createToolDispatch({
19
19
  const {
20
20
  getBridgeRuntimeConnected,
21
21
  getChannelBridgeActive,
22
+ getOwned,
22
23
  setChannelBridgeActive,
23
24
  writeBridgeState,
24
25
  stopServerTyping,
25
26
  claimBridgeOwnership,
27
+ notifyRemoteAcquired,
26
28
  refreshBridgeOwnership,
27
29
  bindPersistedTranscriptIfAny,
28
30
  stopOwnedRuntime,
@@ -48,13 +50,21 @@ function createToolDispatch({
48
50
  setChannelBridgeActive(active);
49
51
  writeBridgeState(active);
50
52
  if (active) {
51
- // Force-takeover semantics: activation ALWAYS claims the seat
52
- // (last-wins overwrite of active-instance.json) and refreshes the
53
- // output-forwarder binding onto THIS session even when the
54
- // bridge was already active. `/remote` relies on this to re-occupy
55
- // after another session stole ownership, and to re-pin forwarding
56
- // to the current session transcript.
57
- claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
53
+ // Claim the seat only when we are NOT already the owner — a
54
+ // genuine /remote takeover or re-occupy after being superseded.
55
+ // When this session already owns the seat (e.g. the boot-time
56
+ // activate that fires right after start() claimed after READY),
57
+ // skip the redundant claim so boot performs ONE claim, not a
58
+ // remote-start + re-activate double claim (the latter forced a
59
+ // double reconnect). refreshBridgeOwnership below still re-pins
60
+ // the forwarder binding onto THIS session either way.
61
+ if (getOwned?.() !== true) {
62
+ claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
63
+ // Genuine acquire transition (we were NOT the owner) — tell the
64
+ // parent to flip remote ON. Not fired when we already own the
65
+ // seat, so an idempotent re-activate never re-notifies.
66
+ notifyRemoteAcquired?.();
67
+ }
58
68
  try {
59
69
  await refreshBridgeOwnership({ restoreBinding: true });
60
70
  // An already-connected owner returns early from
@@ -167,9 +167,11 @@ function createVoiceTranscription({ getConfig, dataDir }) {
167
167
  if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
168
168
  return result;
169
169
  } catch (err) {
170
- if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
170
+ // Propagate ALL real failures so the caller's retry (network-class) and
171
+ // failure marker (index.mjs catch) fire; null is reserved strictly for a
172
+ // legit empty transcript.
171
173
  process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
172
- return null;
174
+ throw err;
173
175
  }
174
176
  }
175
177
 
@@ -50,8 +50,14 @@ export function buildRecallScopeFilter(offset, options = {}, tableAlias = '') {
50
50
  ]
51
51
  const params = []
52
52
  let next = offset
53
- const tsFrom = Number.isFinite(Number(options.ts_from)) ? Number(options.ts_from) : null
54
- const tsTo = Number.isFinite(Number(options.ts_to)) ? Number(options.ts_to) : null
53
+ // Treat null AND undefined as "no bound". Number(null) === 0 (finite), so a
54
+ // caller forwarding a null ts bound would otherwise inject `ts >= 0` /
55
+ // `ts <= 0` — the latter silently drops every row (epoch-ms ts > 0). Callers
56
+ // pass null for "absent" throughout the recall path; never coerce it to 0.
57
+ const tsFrom = options.ts_from == null ? null
58
+ : (Number.isFinite(Number(options.ts_from)) ? Number(options.ts_from) : null)
59
+ const tsTo = options.ts_to == null ? null
60
+ : (Number.isFinite(Number(options.ts_to)) ? Number(options.ts_to) : null)
55
61
  if (tsFrom != null) { clauses.push(`${p}ts >= $${next++}`); params.push(tsFrom) }
56
62
  if (tsTo != null) { clauses.push(`${p}ts <= $${next++}`); params.push(tsTo) }
57
63
  const excludeStatuses = Array.isArray(options.excludeStatuses)