mixdog 0.9.23 → 0.9.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.
Files changed (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -37,7 +37,7 @@ import { startChildGuardian } from '../../../shared/child-guardian.mjs';
37
37
  // transition). shell-jobs.mjs imports stripAnsi from this module, so this is
38
38
  // a static cycle — safe because neither binding is touched at module-eval
39
39
  // time, only when the respective functions actually run.
40
- import { adoptForegroundShellJob } from './builtin/shell-jobs.mjs';
40
+ import { adoptForegroundShellJob, killShellJob } from './builtin/shell-jobs.mjs';
41
41
  import {
42
42
  _maybeEncodePowerShellCommand,
43
43
  extractPowerShellCommandInner,
@@ -494,6 +494,7 @@ export function execShellCommand({
494
494
  autoBackgroundMs,
495
495
  onProgress,
496
496
  clientHostPid,
497
+ backgroundOnTimeout,
497
498
  }) {
498
499
  return new Promise(async (resolve) => {
499
500
  const taskId = `shell_${randomUUID().slice(0, 8)}`;
@@ -723,16 +724,20 @@ export function execShellCommand({
723
724
  if (grace.unref) grace.unref();
724
725
  });
725
726
 
726
- // Auto-background transition (CC ASSISTANT_BLOCKING_BUDGET_MS +
727
- // startBackgrounding analogue). Fires once, autoBackgroundMs after spawn,
728
- // IFF the child is still running and the run has not already settled /
729
- // been killed / timed out. It adopts the child into the shell-jobs
730
- // registry while keeping it owned by this CLI process, then resolves the
731
- // call immediately with a 'backgrounded' result so the tool stops hanging.
732
- // The 600 s timeoutMs upper bound is carried into the adopted job detail
733
- // so refreshShellJob still enforces it. Mutually exclusive with settle()
734
- // via the autoBackgrounded flag set synchronously at the top before any await.
735
- const _autoBackground = async () => {
727
+ // Auto-background transition (CC startBackgrounding analogue). Two triggers
728
+ // resolve the call immediately with a 'backgrounded' result while the
729
+ // child keeps running, adopted into the shell-jobs registry but still
730
+ // owned by this CLI process:
731
+ // 1. the optional autoBackgroundMs soft threshold (MIXDOG_SHELL_AUTO_
732
+ // BACKGROUND_MS opt-in) an EARLIER promotion before the timeout, and
733
+ // 2. the foreground timeout deadline (backgroundOnTimeout) the default
734
+ // promote-on-timeout that replaces the old tree-kill.
735
+ // Either way the adopted job runs UNLIMITED (timeoutMs 0, matching the
736
+ // async default): the original foreground timeout no longer bounds it, and
737
+ // the adopted-job cap poll still enforces the 100 MB output ceiling.
738
+ // Mutually exclusive with settle() via the autoBackgrounded flag set
739
+ // synchronously at the top before any await.
740
+ const _autoBackground = async ({ reason = 'threshold' } = {}) => {
736
741
  // Win the race: bail if a terminal transition already happened, and
737
742
  // claim the transition synchronously so a concurrently-queued settle()
738
743
  // (which checks autoBackgrounded) becomes inert.
@@ -740,16 +745,18 @@ export function execShellCommand({
740
745
  if (child.exitCode != null || child.signalCode != null) return;
741
746
  autoBackgrounded = true;
742
747
  // The foreground capture is over; stop the local watchdogs/timers so
743
- // they cannot treeKill the now-adopted child. The 600 s bound lives
744
- // on in the adopted job detail (timeoutMs) for refreshShellJob.
748
+ // they cannot treeKill the now-adopted child. The adopted job runs
749
+ // unlimited; refreshShellJob only enforces the output cap.
745
750
  if (timer) { clearTimeout(timer); timer = null; }
746
751
  _clearProgressTimer();
747
752
  if (sizeWatchdog) { clearInterval(sizeWatchdog); sizeWatchdog = null; }
748
753
  if (autoBgTimer) { clearTimeout(autoBgTimer); autoBgTimer = null; }
749
- if (abortSignal && abortHandler) {
750
- try { abortSignal.removeEventListener('abort', abortHandler); } catch {}
751
- abortHandler = null;
752
- }
754
+ // Keep the abort handler ATTACHED through the promotion window. A user
755
+ // cancel racing in after promotion starts must still bring the adopted
756
+ // child down — the handler's treeKill(child) does exactly that (settle()
757
+ // is inert once autoBackgrounded, but the kill itself still lands, and
758
+ // refreshShellJob then flags the job failed). We only detach on a real
759
+ // settle() or on the adoption-failure fallback below.
753
760
  // Every subsequent stdout/stderr chunk must hit disk — the call is
754
761
  // about to resolve and nobody will drain the in-memory buffers again.
755
762
  try { taskOutput.forceSpill(); } catch {}
@@ -766,7 +773,9 @@ export function execShellCommand({
766
773
  command,
767
774
  cwd,
768
775
  pid: child.pid,
769
- timeoutMs,
776
+ // Unlimited: the promoted job is no longer bounded by the foreground
777
+ // timeout (matches the async omitted-default behavior).
778
+ timeoutMs: 0,
770
779
  mergeStderr: false,
771
780
  stdoutPath,
772
781
  stderrPath,
@@ -777,6 +786,18 @@ export function execShellCommand({
777
786
  } catch {
778
787
  job = null;
779
788
  }
789
+ // Adoption failed AFTER the foreground timers/size-watchdog were already
790
+ // torn down. Do NOT resolve as backgrounded — that would leave the child
791
+ // running unlimited with no task_id and no watcher. Release the claim and
792
+ // fall back to the old kill path so the command never outlives a failed
793
+ // promotion. (The abort handler is still attached, so an in-flight cancel
794
+ // is honored by the kill path too.)
795
+ if (!job) {
796
+ autoBackgrounded = false;
797
+ if (reason === 'timeout') timedOut = true; else killed = true;
798
+ _treeKillForceSettle();
799
+ return;
800
+ }
780
801
  // Wire the lifecycle: on close, write the exit-code file FIRST then
781
802
  // touch donePath STRICTLY AFTER — the exact ordering refreshShellJob()
782
803
  // gates completion on (donePath visible ⇒ exit file fully flushed).
@@ -787,6 +808,14 @@ export function execShellCommand({
787
808
  try { writeFileSync(job.donePath, ''); } catch {}
788
809
  });
789
810
  }
811
+ // Adoption committed. If a cancel already fired (before or during the
812
+ // adoption window), bring the now-adopted child down via its jobId so the
813
+ // promotion can't outlive a user abort. Idempotent with the still-attached
814
+ // abort handler's treeKill.
815
+ if (abortSignal && abortSignal.aborted) {
816
+ try { killShellJob(job.jobId); } catch {}
817
+ try { treeKill(child); } catch {}
818
+ }
790
819
  // Snapshot the partial output captured so far for the immediate result.
791
820
  let stdout = '';
792
821
  let stderr = '';
@@ -795,7 +824,10 @@ export function execShellCommand({
795
824
  try { stderr = await taskOutput.getStderr(); }
796
825
  catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
797
826
  const jobId = job ? job.jobId : null;
798
- const secs = Math.round(autoBackgroundMs / 1000);
827
+ const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
828
+ const _verb = reason === 'timeout'
829
+ ? `moved to background at timeout after ${secs}s`
830
+ : `auto-backgrounded after ${secs}s`;
799
831
  resolve(
800
832
  new ExecResult({
801
833
  stdout,
@@ -814,14 +846,31 @@ export function execShellCommand({
814
846
  backgrounded: true,
815
847
  jobId,
816
848
  backgroundMessage: jobId
817
- ? `auto-backgrounded after ${secs}s; still running — completion will be delivered as a background task notification. Use task with task_id:${jobId} only for manual wait/status/read/cancel.`
818
- : `auto-backgrounded after ${secs}s; still running`,
849
+ ? `${_verb}; still running — completion will be delivered as a background task notification. Use task with task_id:${jobId} only for manual wait/status/read/cancel.`
850
+ : `${_verb}; still running`,
819
851
  }),
820
852
  );
821
853
  };
822
854
 
823
855
  if (timeoutMs > 0) {
824
856
  timer = setTimeout(() => {
857
+ // Promote-on-timeout: if the caller allows backgrounding and the child
858
+ // is still running (not a trailing-`&` detach), adopt it as a tracked
859
+ // background job instead of tree-killing it. Falls through to the old
860
+ // kill path for disallowed/opted-out commands (backgroundOnTimeout
861
+ // false) or when a terminal transition already won the race.
862
+ if (
863
+ backgroundOnTimeout &&
864
+ !_isBackground &&
865
+ !settled &&
866
+ !autoBackgrounded &&
867
+ !killed &&
868
+ child.exitCode == null &&
869
+ child.signalCode == null
870
+ ) {
871
+ _autoBackground({ reason: 'timeout' });
872
+ return;
873
+ }
825
874
  timedOut = true;
826
875
  _treeKillForceSettle();
827
876
  }, timeoutMs);
@@ -435,14 +435,21 @@ class DiscordBackend {
435
435
  const msg = await ch.messages.fetch(messageId);
436
436
  if (msg.attachments.size === 0) return [];
437
437
  const results = [];
438
+ // Optional filter bounds what gets fetched (e.g. image/* only) so a
439
+ // caller after images never pulls unrelated large attachments.
440
+ const filter = typeof opts.filter === "function" ? opts.filter : null;
438
441
  for (const att of msg.attachments.values()) {
439
- const path = await this.downloadSingleAttachment(att, opts);
440
- results.push({
442
+ const meta = {
441
443
  id: att.id,
442
- path,
443
444
  name: safeAttName(att),
444
445
  contentType: att.contentType ?? "unknown",
445
446
  size: att.size
447
+ };
448
+ if (filter && !filter(meta)) continue;
449
+ const path = await this.downloadSingleAttachment(att, opts);
450
+ results.push({
451
+ ...meta,
452
+ path
446
453
  });
447
454
  }
448
455
  return results;
@@ -23,8 +23,9 @@ function isChannelsDegraded() { return _channelsDegraded; }
23
23
  try {
24
24
  process.stderr.on('error', (e) => {
25
25
  if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
26
+ // Parent stdio pipe loss stops stderr writes only. It is NOT runtime
27
+ // corruption, so it must not degrade tool dispatch (_channelsDegraded).
26
28
  _stderrBroken = true;
27
- _channelsDegraded = true;
28
29
  }
29
30
  });
30
31
  } catch {}
@@ -75,7 +76,8 @@ ${err instanceof Error ? err.stack : ""}
75
76
  _writeCrashLine(crashLog, msg);
76
77
  }
77
78
  if (err instanceof Error && err.message.includes("EPIPE")) {
78
- _channelsDegraded = true;
79
+ // EPIPE = a broken pipe (parent stdio/IPC gone), not corrupted runtime
80
+ // state. Stop writing to the dead pipe but keep serving tool calls.
79
81
  _stderrBroken = true;
80
82
  }
81
83
  crashLogging = false;
@@ -10,6 +10,9 @@ import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
10
10
  // preserving): serial inbound queue, backend.onMessage transcript (re)bind +
11
11
  // steal logic, and handleInbound voice-transcription + parent notify. Bound to
12
12
  // live runtime getters and shared primitives.
13
+ function isImageAttachment(contentType) {
14
+ return typeof contentType === "string" && contentType.toLowerCase().startsWith("image/");
15
+ }
13
16
  export function createInboundHandler({
14
17
  getBackend,
15
18
  getConfig,
@@ -283,6 +286,46 @@ async function handleInbound(msg, route, options = {}) {
283
286
  }
284
287
  }
285
288
  const hasVoiceAtt = voiceAtts.length > 0;
289
+ // ── Inbound image attachments → downloaded local paths (vision) ──────
290
+ // Mirror the voice path: download image/* attachments to the inbox and
291
+ // hand their local paths downstream so the agent session can attach them
292
+ // as real image content blocks. A short per-attempt timeout bounds the
293
+ // serial inboundQueue against a slow/broken image; on failure we degrade
294
+ // to the metadata-only marker (attMeta below) instead of dropping.
295
+ const imageAtts = msg.attachments.filter((a) => isImageAttachment(a.contentType));
296
+ let imagePaths = [];
297
+ if (imageAtts.length > 0) {
298
+ try {
299
+ const files = (await retryOnNetwork(
300
+ () => getBackend().downloadAttachment(msg.chatId, msg.messageId, {
301
+ timeoutMs: 20_000,
302
+ filter: (a) => isImageAttachment(a.contentType),
303
+ }),
304
+ { label: "image.download" }
305
+ )) || [];
306
+ imagePaths = imageAtts
307
+ .map((a) => files.find((df) => df.id === a.id) ?? null)
308
+ .filter(Boolean)
309
+ .map((f) => f.path)
310
+ .filter((p) => typeof p === "string" && p.length > 0);
311
+ if (imagePaths.length > 0) {
312
+ process.stderr.write(`mixdog: inbound images downloaded (${imagePaths.length})\n`);
313
+ }
314
+ } catch (err) {
315
+ const netFail = isNetworkError(err);
316
+ process.stderr.write(`mixdog: image.download error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
317
+ const marker = netFail
318
+ ? "[attachment: image download failed (network)]"
319
+ : "[attachment: image download failed]";
320
+ text = text ? `${text} ${marker}` : marker;
321
+ }
322
+ }
323
+ // An image-only message can arrive with empty text; channelNotificationModelContent
324
+ // drops empty content (runtime-core), which would strip meta.image_paths and lose
325
+ // the image. Give it a non-empty marker so the notification (and its images) flow.
326
+ if (imagePaths.length > 0 && !String(text || "").trim()) {
327
+ text = "[image]";
328
+ }
286
329
  const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
287
330
  attachment_count: String(msg.attachments.length),
288
331
  attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
@@ -300,7 +343,8 @@ async function handleInbound(msg, route, options = {}) {
300
343
  ...route.sourceLabel ? { source_label: route.sourceLabel } : {}
301
344
  } : {},
302
345
  ...attMeta,
303
- ...msg.imagePath ? { image_path: msg.imagePath } : {}
346
+ ...msg.imagePath ? { image_path: msg.imagePath } : {},
347
+ ...imagePaths.length > 0 ? { image_paths: JSON.stringify(imagePaths) } : {}
304
348
  };
305
349
  const notificationContent = messageBody;
306
350
  sendNotifyToParent("notifications/claude/channel", {
@@ -6,6 +6,7 @@ import {
6
6
  formatToolSurface,
7
7
  isExplorerSurface,
8
8
  isMemorySurface,
9
+ stripToolPrefix,
9
10
  } from "../../shared/tool-surface.mjs";
10
11
  import {
11
12
  cwdToProjectSlug,
@@ -726,7 +727,7 @@ ${_bt.trim()}` : _bt.trim();
726
727
  // The non-set checks are inlined rather than delegated to the imported
727
728
  // isHidden, because that helper would re-consult the module-local
728
729
  // HIDDEN_TOOLS Set and ignore the OutputForwarder static.
729
- if (OutputForwarder.HIDDEN_TOOLS.has(name)) return true;
730
+ if (OutputForwarder.HIDDEN_TOOLS.has(name) || OutputForwarder.HIDDEN_TOOLS.has(stripToolPrefix(name))) return true;
730
731
  if (name === "reply" || name === "fetch") return true;
731
732
  return false;
732
733
  };