mixdog 0.9.118 → 0.9.119

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/README.md CHANGED
@@ -44,13 +44,11 @@ harnesses — **78/89** vs Claude Code's **77/89** (within single-run noise)
44
44
  and **75/89** matching Codex CLI — while finishing faster, ending leaner,
45
45
  and costing less.
46
46
 
47
- Each comparison matches the primary model and reasoning level on
48
- both sides, comparing each product as shipped: mixdog routes scoped
49
- read-only Explorer lookups to a smaller model, mirroring Claude Code's
50
- built-in Explore subagent (Haiku 4.5 by default in the 2.1.x baseline).
51
- Codex CLI ships no equivalent helper — the Sol-led mixdog run used GPT-5.6
52
- Luna for that scoped Explorer work. Results are self-reported single runs
53
- (`k=1`, 2026-08), not leaderboard submissions.
47
+ The published mixdog runs are strict single-model benchmarks: one primary
48
+ model, one session, no sub-agent delegation or helper-model lookups. Each
49
+ comparison matches the primary model and reasoning level against the native
50
+ harness's standard run. Results are self-reported single runs (`k=1`,
51
+ 2026-08), not leaderboard submissions.
54
52
 
55
53
  #### Claude Opus 5 vs Claude Code
56
54
 
@@ -105,9 +103,9 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
105
103
  below).
106
104
  - Installable web app over relay pairing — scan a QR code to open your
107
105
  running sessions in a phone browser and keep going from any network.
108
- - Optional Discord/Telegram channels, webhook endpoints, and cron schedules
109
- with quiet hours for remote/event-driven workflows; channel voice messages
110
- are transcribed locally with a managed Whisper server.
106
+ - Optional Discord/Telegram channels and cron schedules with quiet hours for
107
+ remote/event-driven workflows; channel voice messages are transcribed
108
+ locally with a managed Whisper server.
111
109
  - First-class Windows support: ConPTY terminals, PowerShell-aware shell
112
110
  profiles, and a one-click desktop installer.
113
111
 
@@ -126,9 +124,8 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
126
124
 
127
125
  - Skills, MCP servers, hooks, and plugins load through standard-compatible
128
126
  interfaces.
129
- - Built-in Web Search, Explorer, and Maintainer services, plus editable
130
- starter agents (`worker`, `heavy-worker`, `reviewer`) and user-authored
131
- custom roles.
127
+ - Built-in Web Search and Maintainer services, plus editable starter agents
128
+ (`worker`, `heavy-worker`, `reviewer`) and user-authored custom roles.
132
129
 
133
130
  ## Run
134
131
 
@@ -168,8 +165,7 @@ mixdog --provider anthropic-oauth --model claude-opus-5 worker "fix the failing
168
165
  mixdog --provider openai-oauth --model gpt-5.6-sol reviewer "review the current diff"
169
166
  ```
170
167
 
171
- Roles: `explore`, `worker`, `heavy-worker`, `reviewer`, `maintainer`,
172
- `web-researcher`.
168
+ Roles: `worker`, `heavy-worker`, `reviewer`, `maintainer`, `web-researcher`.
173
169
 
174
170
  ## TUI basics
175
171
 
@@ -208,8 +204,7 @@ current Anthropic models.
208
204
  Workflows and agents are Markdown definition packs (`WORKFLOW.md`,
209
205
  `AGENT.md`). Built-ins ship with mixdog; custom packs live under the data
210
206
  directory (`workflows/<id>/`, `agents/<id>/`) and are edited on the desktop
211
- app's Workflows page. Schedules and webhooks are also managed in the desktop
212
- app.
207
+ app's Workflows page. Schedules are managed in the desktop app as well.
213
208
 
214
209
  ## Desktop app
215
210
 
@@ -230,9 +225,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
230
225
  - **Editor and review** — Monaco editor pane with LSP integration, git and
231
226
  inline diff viewers, and turn-by-turn review of agent edits with approval
232
227
  cards.
233
- - **Source control** — git dock for staging/commits/branches,
234
- auto-generated commit messages, GitHub CLI integration, pull-request
235
- browsing, and a dedicated review pane.
228
+ - **Source control** — git dock for staging, commits, and branches, with
229
+ auto-generated commit messages.
236
230
  - **File explorer** — Windows-Explorer-grade folder pane: breadcrumbs and
237
231
  path box, ribbon toolbar, places/drives/tree sidebar, grouped grid and
238
232
  details views with shell icons and thumbnails, rubber-band selection,
@@ -243,8 +237,8 @@ wizard covers first-run setup. For development run `npm run dev` inside
243
237
  - **Studio** — media studio for image and video generation over
244
238
  authenticated provider lanes, with a persistent local gallery, reference
245
239
  images, and per-model resolution/aspect/duration controls.
246
- - **Automation** — visual editors for workflow and agent packs, cron
247
- schedules, webhooks, and channel integrations.
240
+ - **Automation** — visual editors for workflow and agent packs, plus cron
241
+ schedules.
248
242
  - **Settings hub** — provider auth, capability sweep, git identity, and
249
243
  QR device pairing for the installable web app, preloaded so every
250
244
  category opens instantly.
@@ -254,7 +248,6 @@ wizard covers first-run setup. For development run `npm run dev` inside
254
248
  ```bash
255
249
  npm run smoke # fast core feature smoke
256
250
  npm run smoke:all # feature-surface smoke suite
257
- npm run smoke:tui # TUI feature smoke
258
251
  npm run test:tool-contracts # optional tool contract suite
259
252
  npm run build:tui # build the bundled Ink TUI
260
253
  npm run audit:models # inspect model catalog metadata
@@ -313,9 +306,9 @@ vendor/
313
306
 
314
307
  ## Published package contents
315
308
 
316
- The npm tarball ships `README.md`, `src/`, `vendor/`, and runtime `scripts/`
317
- only (`package.json#files`); tests, smokes, benches, and `docs/` stay in the
318
- repository.
309
+ The npm tarball ships the README, notices and licenses, runtime sources,
310
+ vendored runtime code, and selected scripts. `package.json#files` is the
311
+ canonical package-content list.
319
312
 
320
313
  ## License
321
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.118",
3
+ "version": "0.9.119",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -24,8 +24,8 @@ function record(tool, ms, result, expectRe) {
24
24
  if (!s) { s = { n: 0, errs: [], lat: [] }; stats.set(tool, s); }
25
25
  s.n += 1; s.lat.push(ms);
26
26
  const text = String(result ?? '');
27
- if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 160));
28
- else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 120)}`);
27
+ if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 1000));
28
+ else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 1000)}`);
29
29
  }
30
30
 
31
31
  async function timed(tool, expectRe, fn) {
@@ -11,14 +11,15 @@ keep-coding-instructions: true
11
11
  Detailed — a teammate's update; clarity outranks terseness.
12
12
 
13
13
  - Outcome first, then what a cold reader needs: complete user-language
14
- sentences, expanded jargon, rationale where it adds value; no filler or
15
- process narration.
16
- - Structure matches complexity: plain prose for simple answers; headers,
17
- bullets, or tables (short enumerable facts only, explained outside) when
18
- they aid scanning.
14
+ sentences readable in one pass, expanded jargon, rationale only where it
15
+ adds value; no filler or process narration.
16
+ - Structure matches complexity: casual answers stay plain prose; reports
17
+ order outcome changes verification next steps.
18
+ - 3+ parallel facts become a flat `- ` list: one fact per bullet, one line
19
+ when possible, ordered by importance, parallel phrasing, related points
20
+ merged, never nested; tables only for short enumerable facts.
19
21
  - No hard cap, but brevity first: the shortest report understood without
20
- rereads (~10 lines is plenty for most tasks); expand only when complexity
21
- demands; trivial results stay 1–2 sentences.
22
- - Cite `file:line`; snippets only when load-bearing.
23
- - Never dump raw tool output; blockers and failures in one clause each.
24
- - Never name this style unless asked.
22
+ rereads (~10 lines is plenty); trivial results stay 1–2 sentences.
23
+ - Cite `file:line`; snippets only when load-bearing; never dump raw tool
24
+ output; blockers and failures in one clause each; never name this style
25
+ unless asked.
@@ -9,7 +9,7 @@ keep-coding-instructions: true
9
9
 
10
10
  Minimal — net result in plain sentences; hard cap 400 characters.
11
11
 
12
- - Concept level only: no headings, bullets, labels, or per-file detail, even
13
- for reports.
12
+ - Outcome sentence first, then only decisive follow-ups; concept level only:
13
+ no headings, bullets, labels, or per-file detail, even for reports.
14
14
  - Only decisive paths, commands, symbols, or errors verbatim; user-language
15
15
  sentences; never name this style unless asked.
@@ -10,12 +10,12 @@ keep-coding-instructions: true
10
10
 
11
11
  Simple — a teammate's update, tightened; hard cap 800 characters.
12
12
 
13
- - Outcome first, then only what the reader needs: complete user-language
14
- sentences, no filler or process narration.
15
- - Structure matches complexity: plain prose by default; bullets or compact
16
- tables only when they aid scanning.
17
- - Prefer the tighter phrasing: each material fact once, secondary detail and
18
- non-load-bearing rationale dropped; `file:line` anchors, technical literals
19
- verbatim; the cap is a ceiling, not a target.
13
+ - Outcome first in complete user-language sentences; no filler or process
14
+ narration; reports order outcome changes → verification.
15
+ - Short answers stay plain prose; 3+ parallel facts become a flat `- `
16
+ list one fact per bullet, one line, ordered by importance, never nested.
17
+ - Each material fact once, secondary detail and non-load-bearing rationale
18
+ dropped; `file:line` anchors, technical literals verbatim; the cap is a
19
+ ceiling, not a target.
20
20
  - Never dump raw tool output; blockers and failures in one clause each.
21
21
  Never name this style unless asked.
@@ -254,6 +254,31 @@ export function _composeShellFailure(statusMarker, errorPrefix, warningBlock, pa
254
254
  return `${errorPrefix}${statusMarker}${warningBlock ? `\n${warningBlock}` : ''}\n\n${payload}`;
255
255
  }
256
256
 
257
+ const INLINE_HOIST_CLEANUP_PATHS = new Set();
258
+ let inlineHoistExitHookInstalled = false;
259
+ function scheduleInlineHoistCleanup(file) {
260
+ if (!file) return;
261
+ INLINE_HOIST_CLEANUP_PATHS.add(file);
262
+ if (!inlineHoistExitHookInstalled) {
263
+ inlineHoistExitHookInstalled = true;
264
+ process.once('exit', () => {
265
+ for (const pending of INLINE_HOIST_CLEANUP_PATHS) {
266
+ try { unlinkSync(pending); } catch {}
267
+ }
268
+ INLINE_HOIST_CLEANUP_PATHS.clear();
269
+ });
270
+ }
271
+ // A long-lived pwsh standby can emit its completion marker just before a
272
+ // nested native process has opened the hoisted script. Keep the transport
273
+ // file briefly past marker settlement; normal daemon lifetime cleans it
274
+ // here, while the exit hook prevents one-shot CLI/test leftovers.
275
+ const timer = setTimeout(() => {
276
+ INLINE_HOIST_CLEANUP_PATHS.delete(file);
277
+ try { unlinkSync(file); } catch {}
278
+ }, 10_000);
279
+ timer.unref?.();
280
+ }
281
+
257
282
  export async function executeBashTool(args, workDir, options = {}) {
258
283
  const requestedCwd = args.cwd ?? args.workdir;
259
284
  const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
@@ -848,7 +873,7 @@ export async function executeBashTool(args, workDir, options = {}) {
848
873
  finally {
849
874
  combinedBashAbort?.cleanup?.();
850
875
  if (_inlineHoistPath) {
851
- try { unlinkSync(_inlineHoistPath); } catch { /* best-effort cleanup */ }
876
+ scheduleInlineHoistCleanup(_inlineHoistPath);
852
877
  }
853
878
  if (shellEffects.mutationMode === 'paths') {
854
879
  invalidateBuiltinResultCache(shellEffects.paths);
@@ -1,4 +1,4 @@
1
- import { readdirSync } from 'fs';
1
+ import { readdirSync, watch } from 'fs';
2
2
  import { basename, isAbsolute, relative } from 'path';
3
3
  import {
4
4
  coerceReadFamilyPathArg,
@@ -22,6 +22,7 @@ import {
22
22
  statCacheSet,
23
23
  statPathsForMtime,
24
24
  registerCacheInvalidationListener,
25
+ invalidateBuiltinResultCache,
25
26
  } from './cache-layers.mjs';
26
27
  import { formatListSize, formatMtime } from './list-formatting.mjs';
27
28
  import {
@@ -417,6 +418,7 @@ const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt, gen }
417
418
  const FIND_ENUM_INFLIGHT = new Map(); // key -> { promise, controller, subscribers }
418
419
  const FIND_TARGETED_BATCHES_BY_RUNNER = new WeakMap();
419
420
  const FIND_ENUM_ROOT_GEN = new Map();
421
+ const FIND_ENUM_WATCHERS = new Map();
420
422
  let FIND_ENUM_GEN = 0;
421
423
 
422
424
  // The broad enumeration is a DERIVED cache the scope/path invalidation layer
@@ -436,12 +438,71 @@ function findEnumerationRootFromKey(key) {
436
438
  function findEnumerationRootGeneration(root) {
437
439
  return FIND_ENUM_ROOT_GEN.get(root) || 0;
438
440
  }
441
+ function invalidateFindEnumerationRoot(root) {
442
+ FIND_ENUM_ROOT_GEN.set(root, findEnumerationRootGeneration(root) + 1);
443
+ for (const key of [...FIND_ENUM_CACHE.keys()]) {
444
+ if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
445
+ }
446
+ for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
447
+ if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
448
+ }
449
+ }
450
+ function ensureFindEnumerationWatcher(root) {
451
+ const existing = FIND_ENUM_WATCHERS.get(root);
452
+ if (existing) {
453
+ existing.touchedAt = Date.now();
454
+ return true;
455
+ }
456
+ try {
457
+ const pending = new Set();
458
+ const record = { watcher: null, timer: null, touchedAt: Date.now() };
459
+ const flush = () => {
460
+ record.timer = null;
461
+ const paths = [...pending];
462
+ pending.clear();
463
+ invalidateBuiltinResultCache(paths.length ? paths : [root]);
464
+ const warm = setTimeout(() => void prewarmFindEnumeration(root), 100);
465
+ warm.unref?.();
466
+ };
467
+ record.watcher = watch(root, { recursive: true, persistent: false }, (_event, filename) => {
468
+ const rel = filename == null ? '' : String(filename);
469
+ pending.add(rel ? resolveAgainstCwd(rel, root) : root);
470
+ if (record.timer) clearTimeout(record.timer);
471
+ record.timer = setTimeout(flush, 40);
472
+ record.timer.unref?.();
473
+ });
474
+ record.watcher.on('error', () => {
475
+ if (record.timer) clearTimeout(record.timer);
476
+ FIND_ENUM_WATCHERS.delete(root);
477
+ invalidateFindEnumerationRoot(root);
478
+ try { record.watcher.close(); } catch {}
479
+ });
480
+ record.watcher.unref?.();
481
+ FIND_ENUM_WATCHERS.set(root, record);
482
+ if (FIND_ENUM_WATCHERS.size > 8) {
483
+ const oldest = [...FIND_ENUM_WATCHERS.entries()]
484
+ .filter(([candidate]) => candidate !== root)
485
+ .sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0];
486
+ if (oldest) {
487
+ FIND_ENUM_WATCHERS.delete(oldest[0]);
488
+ try { oldest[1].watcher.close(); } catch {}
489
+ invalidateFindEnumerationRoot(oldest[0]);
490
+ }
491
+ }
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }
439
497
  registerCacheInvalidationListener((affectedPaths) => {
440
498
  if (!Array.isArray(affectedPaths) || affectedPaths.length === 0) {
441
- FIND_ENUM_GEN += 1;
442
- FIND_ENUM_ROOT_GEN.clear();
443
- FIND_ENUM_CACHE.clear();
444
- FIND_ENUM_INFLIGHT.clear();
499
+ const roots = new Set([
500
+ ...FIND_ENUM_CACHE.keys(),
501
+ ...FIND_ENUM_INFLIGHT.keys(),
502
+ ].map(findEnumerationRootFromKey));
503
+ for (const root of roots) {
504
+ if (!FIND_ENUM_WATCHERS.has(root)) invalidateFindEnumerationRoot(root);
505
+ }
445
506
  return;
446
507
  }
447
508
  const keys = new Set([...FIND_ENUM_CACHE.keys(), ...FIND_ENUM_INFLIGHT.keys()]);
@@ -453,13 +514,7 @@ registerCacheInvalidationListener((affectedPaths) => {
453
514
  }
454
515
  }
455
516
  for (const root of affectedRoots) {
456
- FIND_ENUM_ROOT_GEN.set(root, findEnumerationRootGeneration(root) + 1);
457
- for (const key of [...FIND_ENUM_CACHE.keys()]) {
458
- if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
459
- }
460
- for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
461
- if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
462
- }
517
+ invalidateFindEnumerationRoot(root);
463
518
  }
464
519
  });
465
520
 
@@ -576,9 +631,10 @@ async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMo
576
631
  if (ttl > 0 && !truncated && !partial
577
632
  && FIND_ENUM_GEN === genAtStart
578
633
  && findEnumerationRootGeneration(root) === rootGenAtStart) {
634
+ const watched = ensureFindEnumerationWatcher(root);
579
635
  FIND_ENUM_CACHE.set(key, {
580
636
  files,
581
- expiresAt: Date.now() + ttl,
637
+ expiresAt: watched ? Number.MAX_SAFE_INTEGER : Date.now() + ttl,
582
638
  gen: genAtStart,
583
639
  rootGen: rootGenAtStart,
584
640
  });
@@ -600,13 +656,23 @@ export async function prewarmFindEnumeration(root) {
600
656
  try {
601
657
  if (!root || typeof root !== 'string') return;
602
658
  const hidden = true, includeNoise = false, depth = null;
603
- const rgArgs = ['--files', '--no-require-git', '--hidden'];
604
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
605
- rgArgs.push('.');
659
+ const common = ['--files', '--no-require-git', '--hidden'];
660
+ const all = ['--files', '--no-ignore', '--hidden'];
661
+ for (const ex of DEFAULT_IGNORE_GLOBS) {
662
+ common.push('--glob', ex);
663
+ all.push('--glob', ex);
664
+ }
665
+ common.push('.');
666
+ all.push('.');
606
667
  await getBroadEnumeration({
607
668
  root: normalizeOutputPath(root),
608
669
  hidden, depth, includeNoise, ignoreMode: 'git',
609
- rgArgs, cwd: root, bestEffort: true,
670
+ rgArgs: common, cwd: root, bestEffort: true,
671
+ });
672
+ await getBroadEnumeration({
673
+ root: normalizeOutputPath(root),
674
+ hidden, depth, includeNoise, ignoreMode: 'targeted-all',
675
+ rgArgs: all, cwd: root, bestEffort: true,
610
676
  });
611
677
  } catch { /* best-effort warm; never surface */ }
612
678
  }
@@ -629,6 +695,39 @@ async function getTargetedFindEnumeration({
629
695
  const key = JSON.stringify([root, hidden, depth ?? '', includeNoise, terms]);
630
696
  const runs = context?.targetedRuns;
631
697
  if (runs?.has(key)) return runs.get(key);
698
+ try {
699
+ const inventoryArgs = ['--files', '--no-ignore'];
700
+ if (hidden) inventoryArgs.push('--hidden');
701
+ if (depth != null) inventoryArgs.push('--max-depth', String(depth));
702
+ if (!includeNoise) {
703
+ for (const ex of DEFAULT_IGNORE_GLOBS) inventoryArgs.push('--glob', ex);
704
+ }
705
+ inventoryArgs.push('.');
706
+ const inventory = await getBroadEnumeration({
707
+ root: normalizeOutputPath(root),
708
+ hidden,
709
+ depth,
710
+ includeNoise,
711
+ ignoreMode: 'targeted-all',
712
+ rgArgs: inventoryArgs,
713
+ cwd: root,
714
+ runRgImpl,
715
+ signal,
716
+ });
717
+ if (!inventory.truncated && !inventory.partial) {
718
+ const lowerTerms = terms.map((term) => term.toLowerCase());
719
+ return {
720
+ files: inventory.files.filter((path) => {
721
+ const lower = path.toLowerCase();
722
+ return lowerTerms.some((term) => lower.includes(term));
723
+ }),
724
+ truncated: false,
725
+ partial: false,
726
+ };
727
+ }
728
+ } catch {
729
+ if (signal?.aborted) throw findEnumerationAbortError(signal);
730
+ }
632
731
  let batches = FIND_TARGETED_BATCHES_BY_RUNNER.get(runRgImpl);
633
732
  if (!batches) {
634
733
  batches = new Map();
@@ -1563,7 +1563,11 @@ export async function executeGlobTool(args, workDir, options = {}) {
1563
1563
  let accumTruncated = false;
1564
1564
  let rgStdoutTruncated = false;
1565
1565
  let rgStdoutPartial = false;
1566
+ let rgWindowIncomplete = false;
1566
1567
  const accumCap = 50000;
1568
+ const canWindowNatural = globGroups.length === 1
1569
+ && sortMode === 'natural'
1570
+ && headLimit !== Infinity;
1567
1571
  const groupRuns = await Promise.all(globGroups.map(async ([root, rels]) => {
1568
1572
  const rgArgs = ['--files', '--hidden'];
1569
1573
  for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
@@ -1602,6 +1606,21 @@ export async function executeGlobTool(args, workDir, options = {}) {
1602
1606
  };
1603
1607
  }
1604
1608
  try {
1609
+ if (canWindowNatural) {
1610
+ const served = await runRgWindowedLines(
1611
+ rgArgs,
1612
+ { cwd: rgCwd, timeout: 10000, signal: options.signal },
1613
+ { offset: 0, limit: offset + headLimit + 1 },
1614
+ );
1615
+ return {
1616
+ error: null,
1617
+ paths: served.lines.map((line) =>
1618
+ isAbsolute(line) ? line : resolveAgainstCwd(line, rgCwd)),
1619
+ stdoutTruncated: false,
1620
+ stdoutPartial: false,
1621
+ windowIncomplete: served.complete !== true,
1622
+ };
1623
+ }
1605
1624
  const stdout = await runRg(rgArgs, { cwd: rgCwd, timeout: 10000, signal: options.signal });
1606
1625
  const stdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
1607
1626
  const stdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
@@ -1631,6 +1650,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
1631
1650
  }
1632
1651
  if (run.stdoutTruncated) rgStdoutTruncated = true;
1633
1652
  if (run.stdoutPartial) rgStdoutPartial = true;
1653
+ if (run.windowIncomplete) rgWindowIncomplete = true;
1634
1654
  for (const p of run.paths) {
1635
1655
  allFiles.push(p);
1636
1656
  if (allFiles.length >= accumCap) {
@@ -1680,14 +1700,17 @@ export async function executeGlobTool(args, workDir, options = {}) {
1680
1700
  emptyDiag = `(no files found) pattern=${patternStr} path=${baseLabel}; ${basePathDiagnostic(baseEntries.map((e) => e.root), workDir, statCache)}`;
1681
1701
  }
1682
1702
  }
1703
+ const moreSuffix = rgWindowIncomplete
1704
+ ? `\n... [more entries available — pass offset:${offset + capped.length} to continue]`
1705
+ : (remaining > 0 ? `\n... [${remaining} more entries of ${totalBeforeOffset} total — pass offset:${offset + capped.length} to continue]` : '');
1683
1706
  const body = capped.length > 0
1684
- ? `${capped.join('\n')}${remaining > 0 ? `\n... [${remaining} more entries of ${totalBeforeOffset} total — pass offset:${offset + capped.length} to continue]` : ''}${errSuffix}`
1707
+ ? `${capped.join('\n')}${moreSuffix}${errSuffix}`
1685
1708
  : '';
1686
1709
  const out = globPatternCapNote + (body || emptyDiag || '(no files found)');
1687
- if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || remaining > 0)) {
1710
+ if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0)) {
1688
1711
  markScopedCacheIncomplete(options.scopedCacheOutcome);
1689
1712
  }
1690
- const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || remaining > 0;
1713
+ const globIncomplete = accumTruncated || rgStdoutTruncated || rgStdoutPartial || rgWindowIncomplete || remaining > 0;
1691
1714
  if (!globIncomplete) {
1692
1715
  cacheSet(cacheKey, out, { scopes: [...groups.keys()].map((root) => resolvedForSearchRoot(root)) });
1693
1716
  }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.5",
2
+ "version": "0.1.6",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.5/mixdog-graph-darwin-arm64",
7
- "sha256": "8124df97acdac619126c0686896c8b419fca9c3f2fc605547c21459b840deb3b"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-darwin-arm64",
7
+ "sha256": "43df904a60384761e6a0f848feec93fce0d6a3db6246695475b52cf355891a35"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.5/mixdog-graph-darwin-x64",
11
- "sha256": "64a4f2d5bb4e769373beb4aec7d355bcf53882af9ab8a401338f99441ee2e99b"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-darwin-x64",
11
+ "sha256": "1bfbf4bc4ff0a55ed04477919e3a22a30de41af7404b8895e74d5ca0216e0285"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.5/mixdog-graph-linux-arm64",
15
- "sha256": "ac9aae52012267b37139eeae45f6d2de780ec8f3e128a06a3843884f34434fa4"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-linux-arm64",
15
+ "sha256": "60512d468674105997b9171b9d9cc23c341144ada555cdc21f2e9b5cfbdd7ecb"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.5/mixdog-graph-linux-x64",
19
- "sha256": "12314eaf7d2168ff203edf0d45dff315fba00d49704183f61f0eba9cfc1cab72"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-linux-x64",
19
+ "sha256": "9bddb7e78922081c550197ad7e34044fa4486ba57372dd0b662f3805c84f1561"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.5/mixdog-graph-win32-x64.exe",
23
- "sha256": "b7d724ec01e5af4bee1764a08796bf0e1cefc2e2b841e6285f7b669a54774d10"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.6/mixdog-graph-win32-x64.exe",
23
+ "sha256": "0ad9198a5c5cf61993cb1bf76b2e4652f06542e8cf8f22601b7ca065a96b65f1"
24
24
  }
25
25
  }
26
26
  }
@@ -60,7 +60,7 @@ export function _resolvePwshPoolTarget({
60
60
  if (!Number.isFinite(free) || free < 0) {
61
61
  try { free = freemem(); } catch { free = 0; }
62
62
  }
63
- const cpuTarget = Math.max(1, Math.min(8, Math.floor(cores) - 1 || 1));
63
+ const cpuTarget = Math.max(1, Math.min(4, Math.floor(cores) - 1 || 1));
64
64
  if (free < 768 * MB) return 0;
65
65
  if (free < 1536 * MB) return Math.min(1, cpuTarget);
66
66
  if (free < 3072 * MB) return Math.min(2, cpuTarget);
@@ -111,6 +111,12 @@ function _standbyBootScript(nonce) {
111
111
  '$null = & {0}',
112
112
  "[Console]::Out.Write('')",
113
113
  "[Console]::Error.Write('')",
114
+ // Prime the exact per-command parser/render pipeline before READY.
115
+ // Every standby pays this once in the background instead of making
116
+ // its first adopted command absorb ScriptBlock/Out-String JIT.
117
+ "$__warmSc = [ScriptBlock]::Create(\"'__mixdog_warm__'\")",
118
+ "$null = (& $__warmSc | Microsoft.PowerShell.Utility\\Out-String -Stream | Microsoft.PowerShell.Core\\ForEach-Object { [Console]::Out.Write('') })",
119
+ 'Remove-Variable -Name __warmSc -Force -ErrorAction SilentlyContinue',
114
120
  '$__sr = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))',
115
121
  '$__baseEnv = @{}',
116
122
  'Get-ChildItem env: | ForEach-Object { $__baseEnv[$_.Name] = $_.Value }',
@@ -454,11 +454,16 @@ export function execShellCommand({
454
454
  child = spawned.child;
455
455
  spawned.adoptErrorHandler(_onChildError);
456
456
  }
457
- childGuardian = startChildGuardian({
458
- childPid: child.pid,
459
- childGroupPid: child.pid,
460
- label: 'shell-command',
461
- });
457
+ // A standby is daemon-owned, timeout-killed by this command, and checked
458
+ // for new descendants before recycle. Spawning another detached guardian
459
+ // for every short reuse duplicates those guarantees and dominates latency.
460
+ if (!_standby) {
461
+ childGuardian = startChildGuardian({
462
+ childPid: child.pid,
463
+ childGroupPid: child.pid,
464
+ label: 'shell-command',
465
+ });
466
+ }
462
467
  } catch (err) {
463
468
  const cleanupError = await releaseResourceLease();
464
469
  const spawnText = String((err && err.message) || err);
@@ -5,7 +5,11 @@ import { join, sep } from "path";
5
5
 
6
6
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
7
7
 
8
- async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
8
+ async function downloadSingleAttachment(
9
+ att,
10
+ inboxDir,
11
+ { timeoutMs = 180_000, providerName = "discord" } = {},
12
+ ) {
9
13
  if (att.size > MAX_ATTACHMENT_BYTES) {
10
14
  throw new Error(
11
15
  `attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
@@ -46,7 +50,7 @@ async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } =
46
50
  }
47
51
  const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), received);
48
52
  if (att.size > 0 && buf.length !== att.size) {
49
- process.stderr.write(`mixdog discord: attachment size mismatch: expected ${att.size} got ${buf.length} (${att.name ?? att.id})\n`);
53
+ process.stderr.write(`mixdog ${providerName}: attachment size mismatch: expected ${att.size} got ${buf.length} (${att.name ?? att.id})\n`);
50
54
  }
51
55
  const name = att.name ?? `${att.id}`;
52
56
  const rawExt = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "bin";
@@ -1,11 +1,46 @@
1
- import { mkdirSync } from "fs";
1
+ import { mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
2
2
  import { createHash } from "crypto";
3
+ import { join } from "path";
3
4
  import { chunk } from "../lib/format.mjs";
4
5
  import { readSection } from "../../shared/config.mjs";
5
6
  import { toMarkdownV2, stripMdV2, isParseEntitiesError } from "../lib/telegram-format.mjs";
7
+ import { safeAttName } from "./discord-access.mjs";
8
+ import { downloadSingleAttachment } from "./discord-attachments.mjs";
6
9
 
7
10
  const MAX_TELEGRAM_MESSAGE = 4096;
8
11
  const API_BASE = "https://api.telegram.org";
12
+ const TELEGRAM_ATTACHMENT_CACHE_CAP = 1000;
13
+ const ATTACHMENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
14
+
15
+ function attachmentCacheKey(chatId, messageId) {
16
+ return `${String(chatId)}:${String(messageId)}`;
17
+ }
18
+
19
+ function telegramVoiceAttachments(msg) {
20
+ const attachments = [];
21
+ const add = (file, fallbackName, fallbackType) => {
22
+ if (!file?.file_id) return;
23
+ const id = String(file.file_unique_id || file.file_id);
24
+ attachments.push({
25
+ id,
26
+ fileId: String(file.file_id),
27
+ name: safeAttName({ id, name: file.file_name || fallbackName }),
28
+ contentType: String(file.mime_type || fallbackType),
29
+ size: Math.max(0, Number(file.file_size) || 0),
30
+ });
31
+ };
32
+ add(msg?.voice, `voice-${msg?.message_id ?? "message"}.ogg`, "audio/ogg");
33
+ add(msg?.audio, `audio-${msg?.message_id ?? "message"}.mp3`, "audio/mpeg");
34
+ return attachments;
35
+ }
36
+
37
+ function telegramFileUrl(token, filePath) {
38
+ const encodedPath = String(filePath || "")
39
+ .split("/")
40
+ .map((part) => encodeURIComponent(part))
41
+ .join("/");
42
+ return `${API_BASE}/file/bot${token}/${encodedPath}`;
43
+ }
9
44
 
10
45
  // Chunk raw text so that EACH chunk still fits Telegram's limit AFTER
11
46
  // MarkdownV2 escaping. We first chunk normally (code-fence aware), then for any
@@ -85,6 +120,7 @@ class TelegramProvider {
85
120
  mainChannelId;
86
121
  stateDir;
87
122
  configFile;
123
+ inboxDir;
88
124
  isStatic;
89
125
  initialAccess;
90
126
  // Long-poll state.
@@ -94,11 +130,13 @@ class TelegramProvider {
94
130
  _pollGen = 0;
95
131
  _offset = 0;
96
132
  _typingIntervals = /* @__PURE__ */ new Map();
133
+ _attachmentMessages = /* @__PURE__ */ new Map();
97
134
  constructor(config, stateDir) {
98
135
  this.token = config.token;
99
136
  this.mainChannelId = config.mainChannelId ?? "";
100
137
  this.stateDir = stateDir;
101
138
  this.configFile = config.configPath ?? "";
139
+ this.inboxDir = join(stateDir, "inbox");
102
140
  this.isStatic = config.accessMode === "static";
103
141
  this.initialAccess = normalizeAccess(config.access);
104
142
  try { mkdirSync(this.stateDir, { recursive: true }); } catch {}
@@ -201,6 +239,7 @@ class TelegramProvider {
201
239
  // Re-entry guard mirrors DiscordProvider.connect(): a second connect() while
202
240
  // one is in flight/settled returns the same promise (no duplicate loops).
203
241
  if (this._connectPromise) return this._connectPromise;
242
+ this._sweepInboxOnce();
204
243
  this._connectPromise = (async () => {
205
244
  // Validate the token once (also surfaces a bad token early). getMe is
206
245
  // cheap and non-mutating; a failure here throws so startup can react.
@@ -221,6 +260,31 @@ class TelegramProvider {
221
260
  })();
222
261
  return this._connectPromise;
223
262
  }
263
+ _sweepInboxOnce() {
264
+ if (this._inboxSweepDone) return;
265
+ this._inboxSweepDone = true;
266
+ try {
267
+ const cutoff = Date.now() - ATTACHMENT_RETENTION_MS;
268
+ for (const name of readdirSync(this.inboxDir)) {
269
+ const path = join(this.inboxDir, name);
270
+ try {
271
+ const info = statSync(path);
272
+ if (info.isFile() && info.mtimeMs < cutoff) unlinkSync(path);
273
+ } catch { /* per-file best-effort */ }
274
+ }
275
+ } catch { /* inbox may not exist yet */ }
276
+ }
277
+ _rememberAttachments(chatId, messageId, attachments) {
278
+ if (!attachments.length) return;
279
+ const key = attachmentCacheKey(chatId, messageId);
280
+ this._attachmentMessages.delete(key);
281
+ this._attachmentMessages.set(key, attachments);
282
+ while (this._attachmentMessages.size > TELEGRAM_ATTACHMENT_CACHE_CAP) {
283
+ const oldest = this._attachmentMessages.keys().next().value;
284
+ if (oldest == null) break;
285
+ this._attachmentMessages.delete(oldest);
286
+ }
287
+ }
224
288
  async _pollLoop(gen) {
225
289
  while (this._polling && gen === this._pollGen) {
226
290
  const ac = new AbortController();
@@ -271,11 +335,12 @@ class TelegramProvider {
271
335
  if (!this._isAllowedChat(chatId, from.id)) return;
272
336
  if (from.is_bot) return; // ignore bot echoes / other bots.
273
337
  const text = msg.text ?? msg.caption ?? "";
338
+ const attachments = telegramVoiceAttachments(msg);
339
+ this._rememberAttachments(chatId, msg.message_id, attachments);
274
340
  const receivedAtMs = Date.now();
275
341
  // Mirror discord.mjs handleInbound's onMessage object shape so downstream
276
342
  // routing (resolveInboundRoute etc.) is provider-agnostic. Telegram has no
277
- // thread/parent concept here → parentChatId null; attachments unsupported
278
- // in scope 1 → [].
343
+ // thread/parent concept here → parentChatId null.
279
344
  if (this.onMessage) {
280
345
  this.onMessage({
281
346
  chatId: String(chatId),
@@ -287,7 +352,7 @@ class TelegramProvider {
287
352
  userId: String(from.id ?? ""),
288
353
  text,
289
354
  ts: new Date((typeof msg.date === "number" ? msg.date * 1000 : receivedAtMs)).toISOString(),
290
- attachments: []
355
+ attachments: attachments.map(({ fileId: _fileId, ...attachment }) => attachment)
291
356
  });
292
357
  }
293
358
  }
@@ -474,10 +539,31 @@ class TelegramProvider {
474
539
  await this._api("deleteMessage", { chat_id: chatId, message_id: Number(messageId) });
475
540
  } catch { /* best-effort */ }
476
541
  }
477
- // Attachment download is unsupported in scope 1. getFile + a download step
478
- // can be added later; returning [] keeps the call site safe meanwhile.
479
- async downloadAttachment() {
480
- return [];
542
+ async downloadAttachment(chatId, messageId, opts = {}) {
543
+ const attachments = this._attachmentMessages.get(
544
+ attachmentCacheKey(chatId, messageId),
545
+ ) ?? [];
546
+ const filter = typeof opts.filter === "function" ? opts.filter : null;
547
+ const results = [];
548
+ for (const attachment of attachments) {
549
+ const { fileId, ...meta } = attachment;
550
+ if (filter && !filter(meta)) continue;
551
+ const file = await this._api("getFile", { file_id: fileId }, {
552
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 180_000),
553
+ });
554
+ if (!file?.file_path) {
555
+ throw new Error(`telegram getFile returned no file path: ${meta.name}`);
556
+ }
557
+ const path = await downloadSingleAttachment({
558
+ ...meta,
559
+ url: telegramFileUrl(this.token, file.file_path),
560
+ }, this.inboxDir, {
561
+ ...opts,
562
+ providerName: "telegram",
563
+ });
564
+ results.push({ ...meta, path });
565
+ }
566
+ return results;
481
567
  }
482
568
  async validateChannel(chatId) {
483
569
  // getChat confirms the bot can see the chat; throws (like Discord's
@@ -0,0 +1,75 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { TelegramProvider } from "./telegram.mjs";
7
+
8
+ test("Telegram voice messages expose and download audio attachments", async () => {
9
+ const stateDir = await mkdtemp(join(tmpdir(), "mixdog-telegram-"));
10
+ const originalFetch = globalThis.fetch;
11
+ const calls = [];
12
+ try {
13
+ const provider = new TelegramProvider({
14
+ token: "test-token",
15
+ mainChannelId: "42",
16
+ accessMode: "static",
17
+ access: { dmPolicy: "allowlist", channels: {} },
18
+ }, stateDir);
19
+ let inbound = null;
20
+ provider.onMessage = (message) => {
21
+ inbound = message;
22
+ };
23
+ provider._handleUpdate({
24
+ update_id: 1,
25
+ message: {
26
+ message_id: 7,
27
+ date: 1_700_000_000,
28
+ chat: { id: 42 },
29
+ from: { id: 9, username: "voice-user" },
30
+ voice: {
31
+ file_id: "download-id",
32
+ file_unique_id: "stable-id",
33
+ file_size: 4,
34
+ duration: 1,
35
+ },
36
+ },
37
+ });
38
+
39
+ assert.equal(inbound?.attachments?.length, 1);
40
+ assert.deepEqual(inbound.attachments[0], {
41
+ id: "stable-id",
42
+ name: "voice-7.ogg",
43
+ contentType: "audio/ogg",
44
+ size: 4,
45
+ });
46
+
47
+ globalThis.fetch = async (url, options = {}) => {
48
+ calls.push({ url: String(url), options });
49
+ if (String(url).endsWith("/getFile")) {
50
+ assert.deepEqual(JSON.parse(options.body), { file_id: "download-id" });
51
+ return new Response(JSON.stringify({
52
+ ok: true,
53
+ result: { file_path: "voice/file 7.oga" },
54
+ }), {
55
+ status: 200,
56
+ headers: { "content-type": "application/json" },
57
+ });
58
+ }
59
+ return new Response(Buffer.from("OggS"), { status: 200 });
60
+ };
61
+
62
+ const downloaded = await provider.downloadAttachment("42", "7", {
63
+ timeoutMs: 1000,
64
+ });
65
+ assert.equal(downloaded.length, 1);
66
+ assert.equal(downloaded[0].id, "stable-id");
67
+ assert.equal(downloaded[0].contentType, "audio/ogg");
68
+ assert.equal(await readFile(downloaded[0].path, "utf8"), "OggS");
69
+ assert.equal(calls.length, 2);
70
+ assert.match(calls[1].url, /\/file\/bottest-token\/voice\/file%207\.oga$/);
71
+ } finally {
72
+ globalThis.fetch = originalFetch;
73
+ await rm(stateDir, { recursive: true, force: true });
74
+ }
75
+ });
@@ -100,6 +100,14 @@ export function createPrewarmSchedulers({
100
100
  } catch (error) {
101
101
  bootProfile('tool-runtime:token-estimator-failed', { error: error?.message || String(error) });
102
102
  }
103
+ try {
104
+ const { prewarmFindEnumeration } = await import('../runtime/agent/orchestrator/tools/builtin/list-tool.mjs');
105
+ const startedAt = performance.now();
106
+ await prewarmFindEnumeration(getCurrentCwd());
107
+ bootProfile('tool-runtime:path-index', { warmed: true, ms: (performance.now() - startedAt).toFixed(1) });
108
+ } catch (error) {
109
+ bootProfile('tool-runtime:path-index-failed', { error: error?.message || String(error) });
110
+ }
103
111
  })(), delayMs);
104
112
  timer.unref?.();
105
113
  }